Merge current main into PR61 finishing branch

This commit is contained in:
amosblomqvist
2026-10-28 07:08:36 +02:00
170 changed files with 10528 additions and 2018 deletions
+13 -7
View File
@@ -1,4 +1,5 @@
import type { Model } from "@earendil-works/pi-ai";
import { onWebSubscriptionPacket, webSubscriptionRequest, webSubscriptionsEnabled } from "./subscription-runtime.ts";
import type { ChatGPTStatus } from "../core/contracts/chatgpt.ts";
import type { AdmissionScope, NoDispatchKind } from "./chatgpt-request-cycle.ts";
export type { ChatGPTStatus } from "../core/contracts/chatgpt.ts";
@@ -20,14 +21,15 @@ const validStamp = (s: Stamp) => s && [s.epoch, s.authorityRevision, s.revision]
function desktopIPCConnected(): boolean {
return process.env.LH_DESKTOP_SERVER === "1" && (process.env.LH_DESKTOP_API_SECRET?.length ?? 0) >= 64 && typeof process.send === "function" && process.connected === true;
}
function runtimeConnected(): boolean { return desktopIPCConnected() || webSubscriptionsEnabled(); }
function bridge(): BridgeState {
const global = globalThis as typeof globalThis & { [key]?: BridgeState };
if (!global[key]) {
const state: BridgeState = { status: unavailable(), stamp: { epoch: 0, authorityRevision: 0, revision: 0 }, models: [], catalogAccount: null, pending: new Map(), controllers: new Map(), listeners: new Set(), counter: 0, catalogRequest: 0 };
global[key] = state;
process.on("message", (raw: unknown) => {
const receive = (raw: unknown) => {
const m = raw as { channel?: string; kind?: string; id?: string; op?: string; ok?: boolean; value?: unknown; stamp: Stamp; status: ChatGPTStatus; invalidate?: string; error?: string };
if (!desktopIPCConnected() || m?.channel !== "lh:chatgpt") return;
if (m?.channel !== "lh:chatgpt") return;
if (m.kind === "state" && validStamp(m.stamp) && m.stamp.revision >= state.stamp.revision) {
acceptStatus(state, { ...m.stamp, status: m.status });
if (m.invalidate === "all") { state.catalogRequest++; clearCatalog(state); }
@@ -43,8 +45,11 @@ function bridge(): BridgeState {
if (m.ok === true) call.resolve(m.value);
else call.reject(new Error(m.error ?? "ChatGPT connection failed."));
}
});
};
process.on("message", (raw: unknown) => { if (desktopIPCConnected()) receive(raw); });
onWebSubscriptionPacket("lh:chatgpt", (packet) => { if (webSubscriptionsEnabled()) receive(packet); });
process.on("disconnect", () => {
if (webSubscriptionsEnabled()) return;
state.status = unavailable(); clearCatalog(state); state.catalogRequest++;
for (const controller of state.controllers.keys()) controller.abort("all");
for (const call of state.pending.values()) { clearTimeout(call.timer); call.reject(new Error("The desktop connection closed.")); }
@@ -60,9 +65,10 @@ function acceptStatus(state: BridgeState, value: StatusValue) {
if (value.epoch !== state.stamp.epoch || value.authorityRevision !== state.stamp.authorityRevision || value.status.activeId !== state.status.activeId) clearCatalog(state);
state.status = value.status; state.stamp = { epoch: value.epoch, authorityRevision: value.authorityRevision, revision: value.revision };
}
/** Runtime-only inherited IPC. No HTTP credential route or renderer method. */
/** Runtime-only: inherited desktop IPC or direct web owner. Never HTTP leases. */
export function chatGPTRequest<T>(op: "status" | "models" | "lease", fields: Record<string, unknown> = {}): Promise<T> {
const state = bridge();
if (webSubscriptionsEnabled()) return webSubscriptionRequest<T>("lh:chatgpt", op, fields);
if (!desktopIPCConnected()) return Promise.reject(new Error("ChatGPT sign-in is available only in the desktop app with a private server connection."));
return new Promise<T>((resolve, reject) => {
const id = `${process.pid}:${++state.counter}`;
@@ -74,7 +80,7 @@ export function chatGPTRequest<T>(op: "status" | "models" | "lease", fields: Rec
});
}
export function chatGPTStatus(): ChatGPTStatus { return bridge().status; }
export function chatGPTConnected(): boolean { const s = chatGPTStatus(); return desktopIPCConnected() && s.available && s.accounts.some((a) => a.id === s.activeId && a.connected && a.planEnabled === true); }
export function chatGPTConnected(): boolean { const s = chatGPTStatus(); return runtimeConnected() && s.available && s.accounts.some((a) => a.id === s.activeId && a.connected && a.planEnabled === true); }
export function chatGPTBackgroundAllowed(): boolean { const s = chatGPTStatus(); return chatGPTConnected() && s.allowBackgroundUse === true; }
export function onChatGPTChange(listener: () => void): () => void { bridge().listeners.add(listener); return () => { bridge().listeners.delete(listener); }; }
export function chatGPTModels(): Model<"openai-responses">[] { return bridge().models; }
@@ -86,7 +92,7 @@ export function chatGPTScope(selectedModelId: string): AdmissionScope {
}
export function chatGPTScopeCurrent(scope: AdmissionScope): boolean {
const s = bridge();
return desktopIPCConnected() && chatGPTConnected() && scope.accountId === s.catalogAccount && scope.accountId === s.status.activeId && scope.epoch === s.stamp.epoch && scope.authorityRevision === s.stamp.authorityRevision && !!chatGPTModel(scope.selectedModelId);
return runtimeConnected() && chatGPTConnected() && scope.accountId === s.catalogAccount && scope.accountId === s.status.activeId && scope.epoch === s.stamp.epoch && scope.authorityRevision === s.stamp.authorityRevision && !!chatGPTModel(scope.selectedModelId);
}
export async function chatGPTLease(scope: AdmissionScope, background: boolean): Promise<LeaseValue> {
if (!chatGPTScopeCurrent(scope)) throw new Error("ChatGPT catalog authority changed");
@@ -108,7 +114,7 @@ export async function chatGPTDenial(scope: AdmissionScope | undefined, controlle
/** Preserve account order; unknown capabilities/windows are never fabricated. */
export async function refreshChatGPTModels(supported: Model<"openai-responses">[]): Promise<void> {
const state = bridge();
if (!desktopIPCConnected()) { clearCatalog(state); return; }
if (!runtimeConnected()) { clearCatalog(state); return; }
const sequence = ++state.catalogRequest;
try {
const value = await chatGPTRequest<ModelsValue>("models");
+5 -7
View File
@@ -41,18 +41,16 @@ export function unregisterJobAbort(jobId: string): void {
* the liveness gate for the stale-job sweep: a "running" record with a
* live registration belongs to a loop this process owns (hung or slow, it
* is the one truth — never re-spawn beside it); without one the record is
* an orphan of a process death and recovers immediately. The span is
* loop-start to delivery-completion: the runner's unregistering finally
* runs only after deliver() resolves, by which point finalizeJob has
* flipped the record — so absence of registration is unambiguous. */
* an orphan of a process death and recovers immediately. The registration
* spans computation and cleanup only. Finished outcomes have independent,
* persisted delivery obligations; no worker waits for their consumption. */
export function isJobLive(jobId: string): boolean {
return registry().has(jobId);
}
/** Abort a running job's loop. Returns false when no controller is live —
* the job's loop is not executing (crashed, or in the delivering-result
* phase where the runner has already returned); the caller then owns the
* whole abort on the record side. */
* the job's loop is not executing. The endpoint separately refuses abort
* for a finished worker, even while its session delivery remains pending. */
export function fireJobAbort(jobId: string): boolean {
const controller = registry().get(jobId);
if (!controller) return false;
+13 -8
View File
@@ -28,7 +28,7 @@ import type { GraphStore } from "../../core/ports.ts";
import { emit } from "../../core/events.ts";
import { settleGraphLayout } from "../../core/layout-settle.ts";
import { batchRuns, coverageOf, messageViewLines } from "../../core/coverage.ts";
import type { LhMessage } from "../../core/native-messages.ts";
import { failedOutputCallIds, type LhMessage } from "../../core/native-messages.ts";
import { auditorGraphTools } from "../tools/graph.ts";
import { runSubmitAgent, healStringifiedArrays } from "./submit-agent.ts";
@@ -124,16 +124,20 @@ export function transcriptView(record: ChatRecord): string {
}
/** The extraction view under the teacher-prose ruling (owner, 2026-09-28):
* ONLY the teacher agent's text blocks — no learner messages, no
* thinking, no tool calls or results, no quiz blocks — each labeled with
* ONLY learner-facing teacher prose (text or send_to_user) — no learner
* messages, thinking, other tool calls/results, or quiz blocks — labeled with
* its ACTUAL message id (owner, 2026-10-13): the view lines ARE
* core/coverage.ts messageViewLines ("TEACHER [id] text"), the exact
* lines the coverage fingerprints hash, so the extractor can anchor
* every proposition to the passage it came from (the session submit's
* required messageId). The shared markdown engine stays reserved for the
* future user-facing export. */
function proseView(messages: ReadonlyArray<LhMessage>, title?: string): string {
const lines = messages.flatMap((message) => messageViewLines(message, message.id));
function proseView(
messages: ReadonlyArray<LhMessage>,
title?: string,
failedOutputCalls: ReadonlySet<string> = failedOutputCallIds(messages),
): string {
const lines = messages.flatMap((message) => messageViewLines(message, message.id, failedOutputCalls));
const head = title && title.length > 0 ? `# ${title}\n\n` : "";
return head + lines.join("\n\n");
}
@@ -1113,6 +1117,7 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti
// stage runs (typed — the trigger skips, routes surface 503).
refuseIfStagesUnready();
const covered = coverageOf(record.messages, graphStore.readCoverage(), chatId);
const failedOutputCalls = failedOutputCallIds(record.messages);
if (covered.uncovered.length === 0) {
console.info(`[extraction ${chatId}] nothing uncovered — no-op`);
return { nodes: 0, chains: 0 };
@@ -1148,14 +1153,14 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti
// fingerprints' messageViewLines ("TEACHER [id] text"), one
// shared seam.
const messages = batch.map((slot) => slot.message);
const view = proseView(messages, record.title);
const view = proseView(messages, record.title, failedOutputCalls);
// The batch's teacher-prose ids — the ONLY anchors this
// job's submit may name (validateSessionAnchors at the
// submit seam refuses anything else: ids from other
// batches — even real ones of this chat — other chats,
// and non-prose messages).
const batchIds = new Set(
messages.filter((m) => messageViewLines(m, m.id).length > 0).map((m) => m.id),
messages.filter((m) => messageViewLines(m, m.id, failedOutputCalls).length > 0).map((m) => m.id),
);
setPhase(jobKey, "queued", "chat", undefined, part);
const findings = await withExtractSlot(jobKey, async () => {
@@ -1188,7 +1193,7 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti
const batchStats = mergeFindings(chatId, findings, auditResult, { chatStore, graphStore }, {
foldedAt,
coverage: batch
.filter((slot) => messageViewLines(slot.message, slot.message.id).length > 0)
.filter((slot) => slot.tokens > 0)
.map((slot) => ({ chatId, fp: slot.fp, tokens: slot.tokens, foldedAt })),
});
// The layout ledger evolves with the merge (sync — the
+2 -1
View File
@@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
import type { AssistantMessage, Message, ToolResultMessage, Usage, Tool } from "@earendil-works/pi-ai";
import { normalizeContext } from "@earendil-works/pi-ai";
import { Type } from "typebox";
import { resolveSourcePath } from "../tools/fs-shared.ts";
import { piEngineFor, type PiEngine, type ThinkingLevel } from "../models.ts";
import { providerEventObserver, type ProviderEventObserver } from "../provider-events.ts";
import type { PdfResult, PdfToolInput } from "../../core/contracts.ts";
@@ -166,7 +167,7 @@ export async function runPdfReaderAgent(
): Promise<PdfResult> {
const headless = opts.headless ?? getHeadless();
const maxIterations = opts.maxIterations ?? MAX_ITERATIONS;
const pdfPath = input.path;
const pdfPath = resolveSourcePath(input.path);
// Deterministic upfront failure: unreadable / unparseable
// for renders — the brief never starts on a broken source.
+35 -27
View File
@@ -3,6 +3,7 @@ import type { SearchProvider } from "../../core/search.ts";
import {
accumulateJobUsage,
buildSubagentSteer,
isSubagentFailure,
piUsageToStep,
type DemoResult,
type DemoToolInput,
@@ -40,13 +41,13 @@ import { runVisualMaker } from "./visual-maker.ts";
* (the job tools' execute fires this via the spawn seam, fire-and-forget),
* updating the job's activity line on every iteration. On completion the
* runner builds the STEER MESSAGE (buildSubagentSteer) and hands it to
* `deliver`; the wake pipeline fires the server-side teacher turn and
* flips the job to done/error when the lesson has actually resumed.
* `deliver` without waiting. The worker's final outcome and pending
* delivery are saved together; the session owns delivery from then on.
*
* On failure the result is the failure shape (empty html/svg + "FAILED: …"
* notes; research: failure brief) — and it STILL steers, because the
* teacher must learn the outcome. If delivery itself fails, the job is
* marked error (the learner's activity card shows it).
* teacher must learn the outcome. Failed delivery starts stay quietly
* pending and never rewrite the worker's computation outcome.
*/
/**
@@ -67,9 +68,9 @@ export interface JobRunnerDeps {
chatStore: ChatStore;
jobStore: JobStore;
/**
* Hand a finished job's steer to the wake pipeline — resolves when the
* steer is delivered (the wake turn completed and the job status
* flipped); rejects when delivery fails unrecoverably.
* Hand a finished job's steer to the session delivery registry. The
* promise tracks durable marker acceptance, but the worker never waits
* on it. Its persisted pending obligation survives process restart.
*/
deliver: (steer: SteerMessage) => Promise<void>;
/** Settings-resolved engine per role (model slots wave, 2026-09-20):
@@ -96,8 +97,8 @@ export interface JobRunnerDeps {
export interface JobRunner {
/** Run one job's sub-agent loop to completion (background; never throws).
* The tool input rides along from dispatch (the spawn fires mid-stream,
* before the tool part is persisted — only crash-recovery re-spawns
* resolve the input from the transcript). */
* before the tool part is persisted). Recovery reads saved job input,
* with transcript fallback for legacy jobs. Delivery is not awaited. */
runJob(jobId: string, input?: JobToolInput): Promise<void>;
}
@@ -270,14 +271,15 @@ export function createJobRunner(deps: JobRunnerDeps): JobRunner {
}
}
/** Resolve the job's tool input: dispatch passes it directly; only a
* crash-recovery re-spawn (no input) falls back to scanning the
* transcript (by then onFinish has long persisted the part). */
/** Dispatch input wins, then the job's saved arguments. Legacy jobs
* without saved input fall back to the durable transcript, which may
* lack a call if the dispatching turn died before settlement. */
function resolveInput(
job: JobRecord,
input: JobToolInput | undefined,
): JobToolInput | undefined {
if (input) return input;
if (job.input !== undefined) return job.input as JobToolInput;
const chat = chatStore.getChat(job.chatId);
if (!chat) return undefined;
// Wave D: the record is native history — the scan reads the native
@@ -295,27 +297,28 @@ export function createJobRunner(deps: JobRunnerDeps): JobRunner {
}
/**
* The tail of every job: store the result, build the steer, hand it to
* the wake pipeline. The pipeline flips the status when the lesson has
* actually resumed — until then the job reads "running" so the client's
* activity card keeps polling and re-syncs once, at the end.
* Save the computation outcome and session delivery obligation together.
* Worker liveness ends after cleanup, not after a lesson consumes the result.
*/
async function finishJob(jobId: string, result: ResearchResult | DemoResult | VisualResult | PdfResult): Promise<void> {
const job = jobStore.getJob(jobId);
if (!job) return;
// Aborted mid-run or mid-delivery (abortJob flips the record before
// Aborted mid-run (abortJob flips the record before
// the loop unwinds): no result write, no steer — the abort path owns
// the record and queues the abort steer itself.
if (job.status !== "running") return;
jobStore.updateJob(jobId, { result, activity: "delivering result" });
const steer = buildSubagentSteer(job, result);
try {
await deliver(steer);
} catch (err) {
const reason = err instanceof Error ? err.message : String(err);
console.error(`[job ${jobId}] steer delivery failed:`, err);
jobStore.updateJob(jobId, { status: "error", error: reason, activity: "delivery failed" });
}
const failed = isSubagentFailure(job.kind, result);
jobStore.updateJob(jobId, {
result,
status: failed ? "error" : "done",
activity: failed ? "failed" : "finished",
delivery: { kind: "result", status: "pending" },
});
// The saved result now belongs to the session. Do not keep the worker,
// abort registration, or temporary resources alive behind a question.
void deliver(buildSubagentSteer(job, result)).catch((err: unknown) => {
console.error(`[job ${jobId}] pending delivery:`, err);
});
}
/** Chat-record + input resolution shared by all three runners. */
@@ -343,7 +346,12 @@ export function createJobRunner(deps: JobRunnerDeps): JobRunner {
const { job } = ctx;
const input = (passedInput ?? resolveInput(job, passedInput)) as ResearchToolInput | undefined;
const question = typeof input?.question === "string" && input.question.trim() ? input.question : "(no question)";
if (!(typeof input?.question === "string" && input.question.trim())) {
jobStore.updateJob(jobId, { error: "research question unavailable" });
await finishJob(jobId, { brief: "Research failed: the research question is unavailable.", sources: [] });
return;
}
const question = input.question;
const context = typeof input?.context === "string" && input.context.trim() ? input.context : undefined;
const prompt = [
+10 -3
View File
@@ -1,4 +1,5 @@
import type { Api, Model, ModelAuth } from "@earendil-works/pi-ai";
import { onWebSubscriptionPacket, webSubscriptionRequest, webSubscriptionsEnabled } from "./subscription-runtime.ts";
import { NATIVE_SUBSCRIPTIONS, type NativeSubscriptionProviderId, type NativeSubscriptionScope, type NativeSubscriptionSnapshot, type NativeSubscriptionStatus } from "../core/contracts/subscriptions.ts";
import type { AdmissionScope, NoDispatchKind } from "./chatgpt-request-cycle.ts";
@@ -18,6 +19,7 @@ export function nativeSubscriptionId(logicalId: string): NativeSubscriptionProvi
function desktopConnected(): boolean {
return process.env.LH_DESKTOP_SERVER === "1" && (process.env.LH_DESKTOP_API_SECRET?.length ?? 0) >= 64 && typeof process.send === "function" && process.connected === true;
}
function runtimeConnected(): boolean { return desktopConnected() || webSubscriptionsEnabled(); }
function notify(state: State) { for (const listener of state.listeners) { try { listener(); } catch { /* observer isolation */ } } }
function invalidate(state: State, providerId?: NativeSubscriptionProviderId) {
for (const [controller, owner] of state.controllers) if (!providerId || owner.providerId === providerId) controller.abort("all");
@@ -60,7 +62,11 @@ function bridge(): State {
if (message.ok) pending.resolve(message.value);
else pending.reject(new Error(message.error ?? "Subscription connection failed."));
});
onWebSubscriptionPacket("lh:subscriptions", (packet) => {
if (webSubscriptionsEnabled() && packet.snapshot) accept(state, packet.snapshot as NativeSubscriptionSnapshot);
});
process.on("disconnect", () => {
if (webSubscriptionsEnabled()) return;
invalidate(state);
for (const pending of state.pending.values()) { clearTimeout(pending.timer); pending.reject(new Error("The desktop connection closed.")); }
state.pending.clear(); notify(state);
@@ -70,6 +76,7 @@ function bridge(): State {
}
export function subscriptionRequest<T>(op: "snapshot" | "lease", fields: Record<string, unknown> = {}): Promise<T> {
const state = bridge();
if (webSubscriptionsEnabled()) return webSubscriptionRequest<T>("lh:subscriptions", op, fields);
if (!desktopConnected()) return Promise.reject(new Error("Subscription sign-in is available only in the desktop app with a private server connection."));
return new Promise<T>((resolve, reject) => {
const id = `${process.pid}:${++state.counter}`;
@@ -82,17 +89,17 @@ export function subscriptionRequest<T>(op: "snapshot" | "lease", fields: Record<
}
export async function refreshSubscriptionModels(): Promise<void> {
const state = bridge();
if (!desktopConnected()) { invalidate(state); return; }
if (!runtimeConnected()) { invalidate(state); return; }
try { accept(state, await subscriptionRequest<NativeSubscriptionSnapshot>("snapshot")); }
catch { invalidate(state); notify(state); }
}
export function nativeSubscriptionStatus(providerId: NativeSubscriptionProviderId): NativeSubscriptionStatus | undefined { return bridge().statuses.get(providerId); }
export function nativeSubscriptionConnected(providerId: NativeSubscriptionProviderId): boolean {
const status = nativeSubscriptionStatus(providerId);
return desktopConnected() && status?.available === true && status.connected && !!status.connectionId;
return runtimeConnected() && status?.available === true && status.connected && !!status.connectionId;
}
export function nativeSubscriptionBackgroundAllowed(providerId: NativeSubscriptionProviderId): boolean { return nativeSubscriptionConnected(providerId) && nativeSubscriptionStatus(providerId)?.allowBackgroundUse === true; }
export function nativeSubscriptionModels(): Model<Api>[] { return desktopConnected() ? [...bridge().models.values()].flat() : []; }
export function nativeSubscriptionModels(): Model<Api>[] { return runtimeConnected() ? [...bridge().models.values()].flat() : []; }
export function nativeSubscriptionModel(logicalId: string, modelId: string): Model<Api> | undefined { return nativeSubscriptionModels().find((m) => m.provider === logicalId && m.id === modelId); }
export function onNativeSubscriptionChange(listener: () => void): () => void { const state = bridge(); state.listeners.add(listener); return () => { state.listeners.delete(listener); }; }
export function subscriptionScope(providerId: NativeSubscriptionProviderId, selectedModelId: string): NativeSubscriptionScope {
+30
View File
@@ -0,0 +1,30 @@
/** Server-injected private web transport. The agent layer never imports a
* filesystem owner or server composition root. Desktop IPC remains separate. */
export type SubscriptionRuntimeChannel = "lh:chatgpt" | "lh:subscriptions";
export type SubscriptionRuntimePacket = Record<string, unknown>;
interface Runtime {
request?: <T>(channel: SubscriptionRuntimeChannel, op: string, fields: Record<string, unknown>) => Promise<T>;
listeners: Map<SubscriptionRuntimeChannel, Set<(packet: SubscriptionRuntimePacket) => void>>;
}
const KEY = Symbol.for("lh.webSubscriptionRuntime");
function runtime(): Runtime {
const global = globalThis as typeof globalThis & { [KEY]?: Runtime };
return global[KEY] ??= { listeners: new Map() };
}
export function configureWebSubscriptionRuntime(request: NonNullable<Runtime["request"]>): void { runtime().request = request; }
export function webSubscriptionsEnabled(): boolean {
return process.env.LH_DESKTOP_SERVER !== "1" && !process.env.LH_DESKTOP_API_SECRET && !!runtime().request;
}
export function onWebSubscriptionPacket(channel: SubscriptionRuntimeChannel, listener: (packet: SubscriptionRuntimePacket) => void): void {
const state = runtime(); const listeners = state.listeners.get(channel) ?? new Set();
listeners.add(listener); state.listeners.set(channel, listeners);
}
export function publishWebSubscriptionPacket(channel: SubscriptionRuntimeChannel, packet: SubscriptionRuntimePacket): void {
for (const listener of runtime().listeners.get(channel) ?? []) {
try { listener(packet); } catch { /* observer isolation */ }
}
}
export function webSubscriptionRequest<T>(channel: SubscriptionRuntimeChannel, op: string, fields: Record<string, unknown>): Promise<T> {
if (!webSubscriptionsEnabled()) return Promise.reject(new Error("The web subscription owner is unavailable."));
return runtime().request!<T>(channel, op, fields);
}
+26 -12
View File
@@ -15,10 +15,11 @@ import {
type TeacherToolContext,
} from "./tools.ts";
import { practiceTools, type PracticeToolContext } from "./tools/practice.ts";
import { healToolRecord } from "./tools/heal.ts";
import { healToolRecord, withArgumentHeal } from "./tools/heal.ts";
import { sendToUserTool } from "./tools/send-to-user.ts";
import { quizInputObject, quizInputProblems } from "../core/question-core.ts";
import { QUESTION_TOOLS, type EnabledTools } from "../core/pending.ts";
import { toLlmMessages, nativeInputCause } from "../core/native-messages.ts";
import { toLlmMessages, nativeInputCause, PRACTICE_PROBLEM_TOOL } from "../core/native-messages.ts";
import type { ChatGPTRequestCycle } from "./chatgpt-request-cycle.ts";
import { usageWasAdmitted } from "./subscription-request-cycle.ts";
@@ -145,22 +146,27 @@ export interface AgentBuildOptions {
* belt-and-suspenders HALF for the mixed batch (a question + other calls
* in one message) that terminate-only semantics would let run on — the
* learner cannot answer while a turn is in flight (per-chat 409), so a
* cleanly-posed question MUST always end the turn. maxTurns is gone
* cleanly-posed question MUST always end the turn. Practice's problem pose
* has the same wait-for-the-learner contract, including mixed output/problem
* batches; failed poses must still retry. maxTurns is gone
* (owner ruling 2026-09-25: no maximum-agent-turns net on the teaching
* turn).
*/
function makeQuestionStopper() {
function makeQuestionStopper(kind: AgentKind) {
return (context: {
message: { content: Array<{ type: string; id?: string; name?: string }> };
toolResults: Array<{ toolCallId: string; isError?: boolean }>;
}): boolean => {
const called = new Set(
context.message.content
.filter((c) => c.type === "toolCall" && c.name !== undefined && (QUESTION_TOOLS as readonly string[]).includes(c.name!))
.filter((c) => c.type === "toolCall" && c.name !== undefined && (
(QUESTION_TOOLS as readonly string[]).includes(c.name) ||
(kind === "practice" && c.name === PRACTICE_PROBLEM_TOOL)
))
.map((c) => c.id!),
);
if (called.size === 0) return false;
// Stop only when every question call of this turn resolved cleanly.
// Stop only when every question/problem pose resolved cleanly.
for (const result of context.toolResults) {
if (called.has(result.toolCallId) && result.isError) return false;
}
@@ -174,7 +180,7 @@ function baseAgentOptions(kind: AgentKind, opts: AgentBuildOptions, tools: Agent
// conversation-spec resolution as teaching); the cast keeps AgentKind
// ("practice" is not a ModelRole) honest about that.
const engine = opts.engine ?? piEngineFor(kind as "prober" | "teacher");
const stopper = makeQuestionStopper();
const stopper = makeQuestionStopper(kind);
const systemPrompt = opts.promptSwitches
? composeInstructions(kind, opts.promptSwitches)
: composedInstructions[kind]!;
@@ -190,7 +196,16 @@ function baseAgentOptions(kind: AgentKind, opts: AgentBuildOptions, tools: Agent
systemPrompt,
model: engine.model,
thinkingLevel: engine.level,
tools,
// Conditional registry inclusion, like the graph-tool gates: when
// closed, neither the tool nor its usage instructions reach the model.
// Match Claude across providers (including OpenRouter), not the API
// transport. Explicit test registries remain caller-owned.
tools: [
...tools,
...(opts.tools === undefined && engine.model.id.toLowerCase().includes("claude")
? [withArgumentHeal(sendToUserTool)]
: []),
],
messages: opts.messages ?? [],
},
streamFn: engine.streamFn,
@@ -312,10 +327,9 @@ export function bareQuestionTools(): Record<string, AgentTool<any>> {
* from turn 1 of a kind:"practice" session — no prober→teacher split,
* no plan tools, no question tools. Its ENTIRE registry is
* problem + conclude (tools/practice.ts); the doctrine prompt
* (prompts/practice/) carries the method. The question-stop rule does
* not apply (problem rides terminate: true natively —
* QUESTION_TOOLS does not include it), so baseAgentOptions's stopper is
* a no-op here; it stays wired for uniformity.
* (prompts/practice/) carries the method. A successful problem pose ends
* the run, natively for a problem-only batch and via the shared stop hook
* for a mixed batch (for example, send_to_user + problem).
*/
export function createPracticeAgent(ctx: PracticeToolContext, opts: AgentBuildOptions = {}) {
const tools = opts.tools ?? practiceTools(ctx);
+1 -1
View File
@@ -78,7 +78,7 @@ function dispatch(
input: ResearchInput | DemoInput | VisualInput | PdfInput,
): string {
const existing = ctx.jobStore.findJobByToolCallId(ctx.chatId, toolCallId);
const job = existing ?? ctx.jobStore.createJob({ kind, chatId: ctx.chatId, toolCallId });
const job = existing ?? ctx.jobStore.createJob({ kind, chatId: ctx.chatId, toolCallId, input });
if (!existing) ctx.spawn(job, input);
return `Dispatched (job ${job.id}). ${ack}`;
}
+3 -2
View File
@@ -42,8 +42,9 @@ function probeConcluded(messages: readonly LhMessage[]): boolean {
* live), so the agent sees the fixed PROBE_DONE_ACK_TEXT — never an
* echo. details = {ok:true} only.
*
* The first probe_done in the transcript is also the construction seam:
* from that message on, every request builds the TEACHER agent.
* The first concluded probe_done (non-failed call plus matching successful
* result) is also the construction seam: from that result on, every request
* builds the TEACHER agent. Bare calls and refused results keep probing.
*/
export function probeDoneTool(ctx: ProbeDoneContext): Record<string, AgentTool<any>> {
return {
+36
View File
@@ -0,0 +1,36 @@
import { Type } from "typebox";
import type { AgentTool } from "@earendil-works/pi-agent-core";
import { SEND_TO_USER_TOOL } from "../../core/native-messages.ts";
import { serverExecute } from "../tool-execute.ts";
const sendToUserInput = Type.Object({
message: Type.String({
minLength: 1,
pattern: "\\S",
description: "The complete content to display to the learner. Markdown and LaTeX are supported.",
}),
});
/** Claude's documented verbatim mid-turn output channel. Availability is
* model-gated at the shared conversation-agent construction boundary;
* the native call remains in history, and the view renders its message. */
export const sendToUserTool: AgentTool<typeof sendToUserInput> = {
name: SEND_TO_USER_TOOL,
label: SEND_TO_USER_TOOL,
description:
"## REQUIRED OUTPUT CHANNEL — ALWAYS USE `send_to_user`\n\n" +
"**ALL learner-facing messages MUST be sent using `send_to_user`.** This includes explanations, teaching, " +
"feedback, corrections, hints, introductions, progress messages, and final responses. " +
"There is no exception for short messages or the end of a turn.\n\n" +
"Ordinary response text may be converted into summarized thinking instead of reaching the learner. " +
"**Never rely on ordinary response text to communicate with the learner. Thinking is not delivery.**\n\n" +
"Put the complete message in `message`. Markdown and LaTeX are supported. " +
"Do not duplicate it in ordinary response text.\n\n" +
"Question content still belongs in the appropriate question tool; send any accompanying teaching or " +
"commentary through `send_to_user` before calling that tool.\n\n" +
"This tool does not pause or end your turn. Continue with other tools when needed. " +
"After delivering your final message, end the turn without additional ordinary response text.",
parameters: sendToUserInput,
executionMode: "sequential",
execute: serverExecute(SEND_TO_USER_TOOL, sendToUserInput, () => "Message received for display."),
};