Swap the server engine to pi-ai/pi-agent-core (Band B+C of docs/pi-engine-swap.md)

The Vercel AI SDK survives only as Band D (useChat client + UIMessage
record types); @openrouter/ai-sdk-provider is uninstalled.

- src/agent/models.ts: PiEngine {model, streamFn} via createModels +
  openrouterProvider subpath; thinkingLevelFor maps config effort none->off
- src/agent/lh-tool.ts: neutral LhTool (zod authoring, optional execute,
  clientQuestion flag); toPiTool converts via z.toJSONSchema and re-runs
  zod inside execute so refinements ride the error-result retry path
- src/server/pi/record-adapter.ts: UIMessage records -> pi Messages
  (dangling tool calls get synthesized interrupted results)
- src/server/pi/ui-stream.ts: SSE bridge emitting AI-SDK-shaped chunks so
  the useChat client parses unchanged (continuation id rule, question
  tools never emit output, tool-input-error for bad authoring)
- teacher-agent.ts: stateless per-turn pi Agents; shouldStopAfterTurn
  stops only when every question-tool call resolved cleanly (errors retry)
- chat-handler.ts: buildAgent(outgoing) factory seam (stateless doctrine);
  assertValidUiMessages owns the retired SDK validation gate
- sub-agents via shared runSubagent (pi-run.ts); extraction stages stop
  after a clean submit; visual-maker keeps its hand-rolled loop over
  direct streamSimple calls; aborted model calls bill no usage
- tests: all model seams ported to pi-ai faux provider (scripted
  FauxResponseSteps, agent factories); handleChatRequest second param
  is now an agent factory

Gates: tsc, lint, build, 305/305. Live GLM smoke (disposable driver,
deleted): prober streaming + persistence, question seam, turn-2
continuation same message id, per-turn real token usage - ALL PASS.
Data wiped at the pivot (chats/graph/jobs, .gitkeeps kept).
This commit is contained in:
amosblomqvist
2026-09-12 02:34:18 +03:00
parent 377d4b6548
commit 2d717fd932
27 changed files with 3557 additions and 769 deletions
+278
View File
@@ -0,0 +1,278 @@
# The pi engine swap — design record
Date: 2026-09-12. Status: PLANNED (facts verified, awaiting go).
Decision (user, 2026-09-12): swap the server agent engine (Band B) and the
UI-stream bridge (Band C) from the Vercel AI SDK to **@earendil-works/pi-ai**
(provider/catalog/streaming layer) + **@earendil-works/pi-agent-core**
(agent loop). The client (Band D: `useChat`, transport, UI kit) **stays** on
the AI SDK. The transcript contract (Band A: UIMessage-shaped records,
`TeacherUIMessage`) **stays** as-is. Model/thinking controls come after the
swap, and the architecture below is shaped so they fall out of pi's catalog
naturally.
Motivation: SDK ambiguity has cost us repeatedly (data-part conventions,
UIMessage merge rules, retry paths, continuation coalescing — all took
forensics to understand). pi's layers are minimal and the user knows them
deeply. After this swap every piece of the turn pipeline is either ours or
pi's, each fully understood.
Verified package facts (all three scouts + npm registry):
- `@earendil-works/pi-ai` + `pi-agent-core` latest **0.85.1**, MIT, ESM-only,
Node `>=22.19.0` (local dev runs 26.7 — fine; Electron wave must ship ≥22).
Plain registry installs, no vendoring. The old prototype
(/Volumes/T7/code/learning-system) ran 0.84.2 cleanly with the same wiring.
- pi-ai = providers/catalog/streaming only: `createModels()`,
`models.streamSimple`, `calculateCost`, `getSupportedThinkingLevels`,
`validateToolArguments`. **No agent loop, no zod, no React, no SSE.**
- pi-agent-core = the loop: `Agent` (steer/followUp/abort/subscribe),
`agentLoop`, `AgentTool` (execute + `terminate`), JSON-Schema validation
that accepts plain JSON Schema (zod `z.toJSONSchema` output works, with
coercion — verified in `validation.js:285`).
## 1. Engine architecture — stateless per-turn Agent, record is the truth
**The record stays the source of truth.** Every turn constructs a *fresh*
`Agent` from the persisted UIMessage record via our `convertToLlm` adapter,
runs it, persists the resulting parts back into the record, discards the
Agent. pi's `steer()`/`followUp()` queues are **in-memory** (plain arrays on
the instance) and die with the process — they are for a *held-live* agent
(pi-coding-agent holds one Agent per session lifetime). lh's architecture is
the opposite: HTTP request-scoped turns, durable wake pipeline, write-ahead
persistence, 409 one-turn guard. Those stay exactly as they are.
Concretely, per POST to `/api/chat`:
1. `beginTurn` / 409 guard / write-ahead persist (unchanged, `chat-handler.ts`).
2. `claimSteers` coalescing (unchanged — server wake messages are appended
into the record *before* the turn, which is what already happens).
3. Build `initialState.messages` = `recordToPiMessages(record)` (§4).
4. `new Agent({ initialState, streamFn, convertToLlm, getApiKey, tools })`.
5. Run to completion (loop runs because question tools terminate early,
see §2; otherwise stopReason `stop`/`length` ends it).
6. Bridge (§3) emits SSE chunks while it runs; events collected for usage.
7. `onFinish` equivalent: `persistTurn` (plan-block splice, unchanged),
`appendTurnUsage`, `triggerExtraction`, `endTurn` (all unchanged).
What we deliberately do **not** adopt:
- Held-live Agents across requests (loses durability guarantees, changes the
409/one-writer model).
- pi's in-loop steering injection (mid-turn steers) — today a wake waits for
the running turn to end (claimSteers at request boundary). In-loop
injection via `getSteeringMessages` is a possible future enhancement, noted
in §8; not in this swap.
- pi's JSONL session machinery (`harness/session/jsonl`) — our record store
already plays that role.
## 2. The quiz/ask pattern — the heart of the swap
Three shapes were found across pi itself and the prototype; lh needs a third
variant because its question tools are **client-answered** and turn-scoped:
- pi's own `ask_question` (interactive-subagents ext): execute writes a
sidecar file and returns; the *session layer* orchestrates the reply as a
future message. Fits a held-live subagent, not our turn model.
- The prototype's plan: **blocking execute** awaiting the learner, graded
verdict as the result. Fits a TUI, not HTTP — would hold a POST open for
the minutes a learner takes to answer.
**lh's variant (the design):** question tools (`quiz`, `ask_user_question`)
are `AgentTool`s whose `execute()` returns *immediately* with a synthetic
result:
```ts
execute: async () => ({
content: [{ type: "text",
text: "Question posed to the learner. Turn ends here; the answer arrives as this call's result next turn." }],
})
```
- **The stop mechanism is the `shouldStopAfterTurn` hook, not
`terminate`.** (Verified in `agent-loop.js`: terminate lives on the
*result*, fires only after the batch executes, and only when *every*
result in the batch sets it — so a quiz+research batch would NOT stop,
the model would keep teaching past the quiz. `terminate` is also shaped
for workflow-final tools, not pause-pending-input; we don't repurpose
it.) Our wrapper's hook: *did this turn's completed assistant message
contain a question-tool call? → return true → the loop stops before any
further model call.* Deterministic for every batch shape; server tools
called in the same batch (research etc.) still execute and stream their
results first, matching today's SDK behavior where a client tool call
ends the turn.
- The synthetic result exists only because pi's loop requires a result for
every executed call to settle into a clean terminal state **within that
turn**. It is *never persisted* — our record keeps the tool part with
**no output** until the client answers, exactly as today. The dead run's
in-memory context (placeholder included) is discarded; the learner's
answer attaches to the *record*, and the next turn's fresh Agent sees
the answered call as a normal tool result via the adapter (§4).
- The learner's answer (dock → `addToolOutput` → next POST carrying the
mutated transcript) re-enters via `recordToPiMessages`: the answered tool
part becomes a `ToolResultMessage` with the original `toolCallId`, so the
model sees the answer as the tool result. This is precisely what pi's
`continue()` documents ("last message must be a user or tool-result
message") — we just feed it through our own record instead of a held Agent.
- **Authoring validation stays zod, stays ours.** `z.toJSONSchema` output
becomes the tool's `parameters` (pi validates structurally + coerces), but
our `superRefine` cross-field rules (correctAnswer∈options — the
twice-fixed wording) don't survive JSON Schema. So the wrapper runs the
zod schema itself at call time, before execution: on failure it pushes the
error back the same way pi's own validation does — an error
`ToolResultMessage` the model sees and retries against (verified: that's
pi's native self-healing path, `prepareToolCall` catch →
`createErrorToolResult`). Identical semantics to today's
`InvalidToolInputError`.
- **Dangling question calls** (turn aborted mid-run leaving an unanswered
quiz, or an aborted LLM call — pi salvages the partial assistant message
with stopReason `aborted`): `recordToPiMessages` must synthesize a
`ToolResultMessage` ("The question was interrupted") for any toolCall
lacking a result *before* the LLM call, or providers reject the request.
This is a hard invariant of the adapter, covered by tests.
## 3. The bridge — pi AgentEvents → UIMessageChunk SSE
We own a ~200-line `src/server/ui-stream-bridge.ts` replacing
`createAgentUIStreamResponse`. Wire format (verified from SDK source):
- Response: `text/event-stream`, chunks as flat JSON
`data: {"type":"text-delta","id":"…","delta":"…"}\n\n`, terminated with
`data: [DONE]\n\n`. Vercel headers not required (client ignores them);
we send `content-type` + `cache-control: no-cache` anyway.
- Every chunk zod-validated client-side (looseObjects); malformed required
fields put the chat in error status. Our chunk construction is typed and
unit-tested against `uiMessageChunkSchema` shapes.
Event mapping (pi event → chunk(s)):
- `agent_start`/`turn_start` → `start` (see id rule) + `start-step`.
- `message_update` carrying pi text events: `text_start/delta/end` →
`text-start`/`text-delta`/`text-end` (stable id per block, e.g.
`${messageId}-t${n}`).
- thinking events → `reasoning-start`/`reasoning-delta`/`reasoning-end`.
- `toolcall_start/delta/end` → `tool-input-start`/`tool-input-delta`
(optional; safe to skip deltas and emit only `tool-input-available` on
end — verified clean path)/`tool-input-available` (part state
`input-available`, static name `tool-${toolName}`).
- `tool_execution_start/end` (server-executed tools only: research, demo,
visual, plan_init, probe_done) → `tool-output-available` with the
serialized result. **Never** emit outputs for question tools (client-owned;
emitting for an unknown callId throws client-side → error status).
- `tool_execution_end` carrying validation-error results → same
`tool-output-available` (error content), matching today's behavior.
- `message_end` → collect usage (§5); `turn_end` → `finish-step`.
- `agent_end` → `finish {finishReason}` — send it always (feeds `onFinish`
fidelity); `stop`/`length` map naturally.
- Errors mid-run: emit `error {errorText}` (does not end the stream; the
client throws it into error status), then close. Unmask
validation/unknown-tool messages like today.
- **Continuation id rule (the load-bearing one, from client source
`makeRequest`/`createStreamingUIMessageState`):** if the transcript's last
message is an *assistant* message, the client's streaming state starts as
a clone of it and the stream must continue that message — so `start`
carries `messageId` = last assistant message's id. Otherwise (fresh turn)
a fresh server-generated id. Violating this duplicates every part of the
prior assistant message (question card twice, duplicated toolCallIds).
- **Abort:** the client's Stop button aborts the fetch → Node `request.signal`
fires. The bridge subscribes: `agent.abort()`, salvage path persists the
partial (pi keeps the partial with stopReason `aborted`), same
isAborted persistence as today. Job/extraction aborts (`abortJob`,
`abortExtraction`) call `agent.abort()` on their registered runs — the
signal threads into `streamFn` and every `execute()`, verified.
## 4. The record adapter — `recordToPiMessages` / message shapes
`convertToLlm` (ours) converts record UIMessages → pi `Message[]`:
- user text message → `UserMessage {role:"user", content, timestamp}`.
Data parts (`data-plan-accepted`, `data-plan-block`) are dropped
(UI-only), exactly as `convertToModelMessages` does today.
- assistant message → `AssistantMessage {role:"assistant", content:[…]}` —
text parts → text blocks; reasoning parts → thinking blocks (+
`thinkingSignature` persisted in the record for Anthropic resume);
tool parts → `ToolCall {id, name, arguments, thoughtSignature?}`.
- answered tool part → after its assistant message, a
`ToolResultMessage {role:"toolResult", toolCallId, toolName, content,
isError?, timestamp}` built from the part's output. Question-tool outputs
become plain text content (the graded-verdict payload, as the model sees
it today).
- Unanswered question toolCall → synthesized interrupted-result (§2).
- `data-plan-accepted` on a user message → text "The plan is accepted —
begin teaching." rides the message (today: `data-plan-accepted` part +
the text part; the model only ever saw the text — identical).
- Prober/teacher seam unchanged: the teacher branch still keys on the
`tool-probe_done` part in the transcript; both agents are constructed per
turn from the same record.
The record format itself does **not** change this wave (Band A untouched);
thinkingSignature support is added to the part schema as one new optional
field (used only when a provider returns signed thinking).
## 5. Usage, cost, and the model catalog
- Usage: subscribe to `message_end`/`turn_end` events →
`AssistantMessage.usage {input, output, cacheRead, cacheWrite, reasoning?,
totalTokens, cost}` → mapped into our `CallUsage`/`TurnUsage` contracts
(field renames only: `reasoningTokens` → `reasoning` etc.). The stats bar
and activity dock are untouched.
- Cost: pi's `Usage.cost` comes pre-computed via `calculateCost` (tiered).
**This wave keeps `core/prices.ts`** (tested folds, synced OpenRouter
table) — no behavior change. Replacing prices.ts with pi's catalog cost
is a noted cleanup for the model-control wave, where the catalog becomes
load-bearing anyway.
- Catalog: `createModels()` + `openrouterProvider()`; our working set
(`z-ai/glm-5.3-flash`) is **registered programmatically** (id, provider,
api, cost/contextWindow/thinkingLevelMap from our data) because it's not
in pi's static catalog (verified; it is in the pi.dev overlay, but we
don't take a runtime pi.dev dependency). `getApiKey: provider → key` from
env (`OPENROUTER_API_KEY`) now; the wave after swaps the source to
`data/auth.json` without touching this seam.
## 6. Sub-agents
All five sub-agents (research runner, demo-builder, visual-maker, extractor,
auditor) port from hand-rolled `generateText`/ToolLoopAgent loops to
`new Agent` or `agentLoop` with the same tools+streamFn wiring. Abort
signals thread through (`execute`'s 3rd arg), usage folds from the same
events, `calculateCost` available for per-stage cost. Mechanical; largest
surface is the extractor's staged loop.
## 7. Test harness
`MockLanguageModelV4` (23 sites) is replaced by scripted pi-side fakes:
- Engine-level: a fake `streamFn` yielding scripted
`AssistantMessageEvent` sequences (text, tool calls, terminal) — the
async-iterator shape is simpler than the SDK's.
- Bridge-level: golden-chunk tests asserting our SSE output against the
verified chunk shapes (continuation id rule, terminate paths, dangling
synthesis, abort salvage).
- Adapter-level: `recordToPiMessages` round-trips (answers, data parts,
aborted quizzes, plan-accepted).
## 8. Waves, gates, and deferred items
One atomic wave (the engine can't ship half-swapped): deps + Next
`serverExternalPackages` for pi-ai/pi-agent-core → adapters (§4) → tools
port (§2, zod kept) → bridge (§3) → chat-handler rewiring → sub-agents (§6)
→ test port (§7) → gates (tsc, full suite, lint, build) → **data wiped at
the pivot** (doctrine; record format is unchanged, so the wipe is doctrine
not necessity) → live GLM smoke: full probe→assessment→handoff→plan→
verdict→teach flow plus abort paths (question mid-stream abort, job abort,
extraction abort), zero console errors, usage correct in the stats bar.
Deferred (explicitly): in-loop steering injection (mid-turn wakes);
prices.ts → pi catalog cost migration; client-band (Band D) ownership
decisions; multi-provider auth beyond OpenRouter (the `getApiKey`/catalog
seams above are the whole preparation); pi.dev overlay refresh.
## 9. Open flags carried into implementation
- Chunk `id`s for text/reasoning blocks must be unique per message and
stable across continuation turns (the client throws on a delta for an
inactive id).
- `tool-input-available` requires `input` as a JSON value; pi gives parsed
arguments on toolcall end.
- Step bookkeeping: pi `turn` ≈ SDK `step`; our per-step boundaries must
keep `step-start` parts where the plan-block splice and phase timelines
expect them (splice keys on tool-plan_init parts — unaffected).
- Next must not bundle pi-ai's provider deps (AWS/Google SDKs) into the
route bundles — `serverExternalPackages`.
- pi-agent-core is single-run per Agent (`prompt()` throws if active):
fresh-Agent-per-turn sidesteps it entirely (and matches our one-writer
turn guard).
+4 -1
View File
@@ -2,6 +2,9 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = {
// Framework-free core lives in src/ (plain TS, imported by routes and CLI alike).
// The pi engine must stay external: its providers are runtime-loaded Node
// code (undici streams, catalog data) that breaks when bundled into routes.
serverExternalPackages: ["@earendil-works/pi-ai", "@earendil-works/pi-agent-core"],
};
export default nextConfig;
export default nextConfig;
+1623 -14
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -14,8 +14,9 @@
},
"dependencies": {
"@ai-sdk/react": "^4.0.79",
"@earendil-works/pi-agent-core": "^0.85.1",
"@earendil-works/pi-ai": "^0.85.1",
"@mozilla/readability": "^0.6.0",
"@openrouter/ai-sdk-provider": "^3.0.0",
"@radix-ui/react-use-controllable-state": "^1.2.6",
"@rive-app/react-webgl2": "^4.32.1",
"@streamdown/cjk": "^1.0.3",
+109
View File
@@ -0,0 +1,109 @@
import { z } from "zod";
import type { AgentTool } from "@earendil-works/pi-agent-core";
/**
* lh's neutral tool definition + the converter to pi-agent-core's AgentTool
* (docs/pi-engine-swap.md §3). The agent-band swap must not re-risk the
* battle-tested zod authoring contracts (the quiz correctAnswer wording,
* the nodeId superRefines) — so tools are AUTHORED exactly as before
* (zod schemas, same execute signatures) in this shape, and converted
* once, centrally, here.
*
* What the converter owns (the semantics the Vercel SDK gave implicitly):
* - parameters: z.toJSONSchema feeds the model-facing declaration (pi
* structurally validates plain JSON Schema itself).
* - refinements: superRefine rules don't survive JSON Schema — the schema
* is re-run inside execute, and a failure THROWS. pi's loop catches
* execute throws and turns them into an error tool result the model
* sees and retries on — the exact InvalidToolInputError semantics.
* - client question tools (quiz / ask_user_question): execute returns a
* synthetic "question posed" placeholder that is NEVER persisted — the
* record keeps the part output-less until the client answers; the
* learner's answer re-enters the next turn as the tool result. The
* turn itself stops via shouldStopAfterTurn (teacher-agent.ts).
*/
export interface LhTool<TInput = any, TResult = any> {
name: string;
description: string;
/** The authoring contract (zod, refinements included). */
schema: z.ZodType<TInput>;
/**
* Server-side execution. Absent + clientQuestion = the client answers.
* Throws on validation failure — the loop converts to an error result.
*/
execute?: (input: TInput, ctx: { toolCallId: string }) => TResult | Promise<TResult>;
/** Client-answered question tool (quiz/ask): a call ends the turn. */
clientQuestion?: boolean;
}
/** Author a tool (the `tool({...})` shape, engine-neutral). */
export function lhTool<TInput, TResult = unknown>(def: LhTool<TInput, TResult>): LhTool<TInput, TResult> {
return def;
}
/** lh's port of the SDK's invalid-input message: what the model retried on. */
export class ToolInputError extends Error {
constructor(
public readonly toolName: string,
public readonly zodError: z.ZodError,
) {
super(
`Invalid tool input for tool "${toolName}":\n${zodError.issues
.map((issue) => {
const path = issue.path.length > 0 ? ` at ${issue.path.join(".")}` : "";
return `- ${issue.message}${path}`;
})
.join("\n")}`,
);
this.name = "ToolInputError";
}
}
/** Convert one lh tool to a pi AgentTool. */
export function toPiTool(def: LhTool<any, any>): AgentTool<any, any> {
return {
name: def.name,
label: def.name,
description: def.description,
parameters: z.toJSONSchema(def.schema as z.ZodType) as never,
execute: async (toolCallId, args) => {
// The authoring check: structural JSON Schema validation already
// ran in the loop's prepare step; this re-run adds the refinements
// (superRefine rules) the JSON Schema can't carry.
const parsed = def.schema.safeParse(args);
if (!parsed.success) throw new ToolInputError(def.name, parsed.error);
if (def.clientQuestion || !def.execute) {
// The synthetic placeholder — in-memory only; the record's tool
// part keeps no output until the learner answers.
return {
content: [{ type: "text", text: "Question posed — the turn ends here; the learner's graded answer arrives as this call's result." }],
details: null,
};
}
const result = await def.execute(parsed.data, { toolCallId });
return {
// The model's view: today's parity — string results pass verbatim,
// objects as JSON.
content: [{ type: "text", text: typeof result === "string" ? result : JSON.stringify(result) }],
details: result,
};
},
};
}
/** Convert a tool registry (name → LhTool) to pi's AgentTool[]. */
export function toPiTools(registry: Record<string, LhTool<any, any>>): AgentTool<any, any>[] {
return Object.entries(registry).map(([name, def]) => toPiTool({ ...def, name }));
}
/** Declaration-only pi Tools (name/description/parameters, no execute) —
* for hand-rolled loops that own tool execution themselves. */
export function piToolDeclarations(registry: Record<string, LhTool<any, any>>) {
return Object.entries(registry).map(([name, def]) => ({
name,
label: name,
description: def.description,
parameters: z.toJSONSchema(def.schema as z.ZodType) as never,
}));
}
+68 -12
View File
@@ -1,17 +1,73 @@
import { openrouter } from "@openrouter/ai-sdk-provider";
import type { LanguageModel } from "ai";
import {
createModels,
type Api,
type Model,
type Models,
type StreamFunction,
} from "@earendil-works/pi-ai";
import { openrouterProvider } from "@earendil-works/pi-ai/providers/openrouter";
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
import { MODELS, type ModelRole } from "../../config.ts";
/**
* The one factory every agent's model comes from. Model choice and thinking
* budget live in config.ts (MODELS) — the single tuning table; this module
* only wires them to OpenRouter. `effort: "none"` is OpenRouter's off-switch
* for reasoning tokens; higher levels let the model think longer (and bill
* accordingly).
* The one engine factory every agent's model comes from (the pi-ai swap,
* docs/pi-engine-swap.md). Model choice and thinking budget live in
* config.ts (MODELS) — the single tuning table; this module only wires them
* to pi-ai's OpenRouter provider.
*
* An "engine" is the model + the stream function the agent loop runs on —
* the two things a test fake replaces. Production engines resolve the model
* from pi-ai's static catalog (glm-5.3-flash and qwen3.8-max are both in
* 0.85.1's built-ins — no overlay fetch) and stream through the same Models
* collection.
*/
export function modelFor(role: ModelRole): LanguageModel {
const spec = MODELS[role];
return openrouter(spec.model, {
reasoning: { enabled: true, effort: spec.effort },
});
export interface PiEngine {
model: Model<Api>;
streamFn: StreamFunction;
}
export type { ThinkingLevel };
/** config's effort scale maps 1:1 onto pi's thinking levels (none→off). */
export function thinkingLevelFor(role: ModelRole): ThinkingLevel {
const effort = MODELS[role].effort as string;
return effort === "none" ? "off" : (effort as ThinkingLevel);
}
let collection: ReturnType<typeof createModels> | undefined;
/** The process-wide pi-ai Models collection (OpenRouter catalog). */
export function piModels() {
if (!collection) {
collection = createModels();
collection.setProvider(openrouterProvider());
}
return collection;
}
/** OpenRouter API key (BYOK); tests never read this — they inject engines. */
export function openRouterApiKey(): string | undefined {
return process.env.OPENROUTER_API_KEY || undefined;
}
/** The production engine for one agent role (config.ts MODELS). */
export function piEngineFor(role: ModelRole): PiEngine {
const models: Models = piModels();
const spec = MODELS[role];
const model = models.getModel("openrouter", spec.model);
if (!model) throw new Error(`OpenRouter catalog has no model ${spec.model} (role ${role})`);
const level = thinkingLevelFor(role);
return {
model,
// bound streamSimple: the agent loop forwards state.thinkingLevel as
// `reasoning` (undefined = off on pi-ai's scale); pin the role's level
// so a bare engine can never drift.
streamFn: (requestModel, context, options) =>
models.streamSimple(requestModel, context, {
...options,
reasoning: level === "off" ? undefined : level,
apiKey: options?.apiKey ?? openRouterApiKey(),
}),
};
}
+23 -22
View File
@@ -1,7 +1,9 @@
import { ToolLoopAgent, stepCountIs, tool, type LanguageModel, type ToolSet } from "ai";
import { z } from "zod";
import { modelFor } from "../models.ts";
import type { DemoResult, UsageStepLike } from "../../core/contracts.ts";
import type { Usage } from "@earendil-works/pi-ai";
import { lhTool, toPiTools, type LhTool } from "../lh-tool.ts";
import type { PiEngine } from "../models.ts";
import { runSubagent } from "./pi-run.ts";
import type { DemoResult } from "../../core/contracts.ts";
import { getHeadless, type DemoRunResult, type HeadlessApi } from "./headless.ts";
/**
@@ -11,7 +13,7 @@ import { getHeadless, type DemoRunResult, type HeadlessApi } from "./headless.ts
* fix → re-run until clean — before anything reaches the lesson.
*
* Shape: a ToolLoopAgent with two executable tools (write_demo / run_demo) —
* the model is injectable (tests use MockLanguageModelV4) and the headless
* the engine is injectable (tests use a scripted faux engine) and the headless
* API is injectable (tests use a fake; production uses system Chrome).
*
* Failure semantics (decided in the W6 brief): `tested: true` means "the
@@ -26,7 +28,8 @@ export interface DemoBuilderInput {
}
export interface DemoBuilderOptions {
model?: LanguageModel;
/** Test seam: scripted engine; production uses the demoBuilder role. */
engine?: PiEngine;
headless?: HeadlessApi;
/** Max agent steps (each write/run/fix is one step). Default 24 ≈ 6 cycles. */
maxSteps?: number;
@@ -35,7 +38,7 @@ export interface DemoBuilderOptions {
/** Kills the run mid-model-call (2026-09 real-kill abort semantics). */
abortSignal?: AbortSignal;
/** Money channel: one call per agent step, folded into JobRecord.usage. */
onUsage?: (usage: UsageStepLike) => void;
onUsage?: (usage: Usage) => void;
}
const INSTRUCTIONS = `You are a demo builder — a specialist sub-agent inside a learning application. You
@@ -78,13 +81,14 @@ export function createDemoBuilderTools(
state: BuilderState,
headless: HeadlessApi,
onActivity?: (line: string) => void,
): ToolSet {
): Record<string, LhTool<any, any>> {
return {
write_demo: tool({
write_demo: lhTool({
name: "write_demo",
description:
"Write the complete self-contained HTML document for the demo (overwrites any previous version). " +
"Always follow with run_demo to test it headlessly.",
inputSchema: z.object({
schema: z.object({
html: z.string().describe("The complete HTML document: <!doctype html>…</html>, all JS inline."),
}),
execute: async ({ html }) => {
@@ -94,11 +98,12 @@ export function createDemoBuilderTools(
return `Saved (${html.length} bytes). Now call run_demo to test it — never deliver untested.`;
},
}),
run_demo: tool({
run_demo: lhTool({
name: "run_demo",
description:
"Headlessly execute the current demo in Chrome and report console errors, uncaught errors, and failed requests. " +
"Optionally pass a probe expression to assert on page state.",
inputSchema: z.object({
schema: z.object({
probe: z
.string()
.optional()
@@ -174,12 +179,6 @@ export async function runDemoBuilder(input: DemoBuilderInput, opts: DemoBuilderO
}
const state: BuilderState = { html: null, testedHtml: null, lastResult: null };
const agent = new ToolLoopAgent({
model: opts.model ?? modelFor("demoBuilder"),
instructions: INSTRUCTIONS,
tools: createDemoBuilderTools(state, headless, opts.onActivity),
stopWhen: stepCountIs(opts.maxSteps ?? 24),
});
const prompt = [
`Demo title: ${title}`,
@@ -191,13 +190,15 @@ export async function runDemoBuilder(input: DemoBuilderInput, opts: DemoBuilderO
let finalText = "";
try {
const result = await agent.generate({
const result = await runSubagent(toPiTools(createDemoBuilderTools(state, headless, opts.onActivity)), {
role: "demoBuilder",
engine: opts.engine,
systemPrompt: INSTRUCTIONS,
prompt,
maxTurns: opts.maxSteps ?? 24,
abortSignal: opts.abortSignal,
onStepFinish: ({ usage }) => opts.onUsage?.(usage),
onToolExecutionStart: ({ toolCall }) => {
opts.onActivity?.(toolCall.toolName);
},
onUsage: (usage) => opts.onUsage?.(usage),
onToolStart: (toolName) => opts.onActivity?.(toolName),
});
finalText = result.text;
} catch (err) {
+114 -75
View File
@@ -1,6 +1,9 @@
import { ToolLoopAgent, stepCountIs, tool, type LanguageModel, type UIMessage } from "ai";
import type { UIMessage } from "ai";
import type { Usage } from "@earendil-works/pi-ai";
import { z } from "zod";
import { modelFor } from "../models.ts";
import { lhTool, toPiTools } from "../lh-tool.ts";
import type { PiEngine } from "../models.ts";
import { runSubagent } from "./pi-run.ts";
import type {
AuditResult,
CallUsage,
@@ -207,19 +210,50 @@ transcript). The auditor rejects anything unbacked. When in doubt, report; the a
Your entire deliverable is ONE submit_findings call.`;
function createExtractor(model: LanguageModel) {
return new ToolLoopAgent({
model,
instructions: EXTRACTOR_INSTRUCTIONS,
tools: {
submit_findings: tool({
/** One extractor run: the whole deliverable is ONE zod-validated submit
* call (a pi agent purely so the model can retry a schema-invalid call).
* Returns the submitted findings — captured from the execute seam, which
* only fires on a schema-valid call. */
async function runExtractor(
engine: PiEngine | undefined,
prompt: string,
signal: AbortSignal | undefined,
onStep: (usage: Usage, model: string, step: number, finishReason: string, tools: string) => void,
): Promise<SessionFindings> {
let submitted: SessionFindings | undefined;
const result = await runSubagent(
toPiTools({
submit_findings: lhTool({
name: "submit_findings",
description: "Submit the session findings.",
inputSchema: findingsSchema,
execute: async (findings) => findings,
schema: findingsSchema,
execute: async (findings) => {
submitted = findings;
return findings;
},
}),
}),
{
role: "extractor",
engine,
systemPrompt: EXTRACTOR_INSTRUCTIONS,
prompt,
maxTurns: 4,
abortSignal: signal,
onAssistantMessage: (message, step) =>
onStep(
message.usage,
message.model,
step,
message.stopReason,
message.content.filter((c) => c.type === "toolCall").map((c) => (c as { name: string }).name).join(", ") || "(none)",
),
shouldStop: (_message, toolResults) => toolResults.some((r) => r.toolName === "submit_findings" && !r.isError),
},
stopWhen: stepCountIs(4),
});
);
void result;
if (!submitted) throw new Error("the extractor never submitted findings");
return submitted;
}
// ─── the auditor ───────────────────────────────────────────────────────────
@@ -290,19 +324,47 @@ function graphView(nodes: GraphNode[]): string {
return lines.join("\n");
}
function createAuditor(model: LanguageModel) {
return new ToolLoopAgent({
model,
instructions: AUDITOR_INSTRUCTIONS,
tools: {
submit_review: tool({
/** One auditor run — same single-submit shape as the extractor. */
async function runAuditor(
engine: PiEngine | undefined,
prompt: string,
signal: AbortSignal | undefined,
onStep: (usage: Usage, model: string, step: number, finishReason: string, tools: string) => void,
): Promise<AuditResult> {
let submitted: AuditResult | undefined;
const result = await runSubagent(
toPiTools({
submit_review: lhTool({
name: "submit_review",
description: "Submit the verified findings subset (with identity verdicts) + the dropped list.",
inputSchema: auditSchema,
execute: async (review) => review,
schema: auditSchema,
execute: async (review) => {
submitted = review;
return review;
},
}),
}),
{
role: "auditor",
engine,
systemPrompt: AUDITOR_INSTRUCTIONS,
prompt,
maxTurns: 4,
abortSignal: signal,
onAssistantMessage: (message, step) =>
onStep(
message.usage,
message.model,
step,
message.stopReason,
message.content.filter((c) => c.type === "toolCall").map((c) => (c as { name: string }).name).join(", ") || "(none)",
),
shouldStop: (_message, toolResults) => toolResults.some((r) => r.toolName === "submit_review" && !r.isError),
},
stopWhen: stepCountIs(4),
});
);
void result;
if (!submitted) throw new Error("the auditor never submitted a review");
return submitted;
}
// ─── the pipeline status registry ─────────────────────────────────────────────
@@ -346,10 +408,9 @@ export function abortExtraction(chatId: string): boolean {
// ─── the pipeline ───────────────────────────────────────────────────────────
export interface ExtractionModels {
/** The extractor's model (tests inject a MockLanguageModelV4). */
extractor?: LanguageModel;
/** The auditor's model. */
auditor?: LanguageModel;
/** Test seam: a scripted engine per stage (production uses the roles). */
extractor?: PiEngine;
auditor?: PiEngine;
}
export interface ExtractionPipelineDeps extends GraphDeps, ExtractionModels {}
@@ -362,8 +423,6 @@ export interface ExtractionPipeline {
export function createExtractionPipeline(deps: ExtractionPipelineDeps): ExtractionPipeline {
const { chatStore, graphStore } = deps;
const extractorAgent = createExtractor(deps.extractor ?? modelFor("extractor"));
const auditorAgent = createAuditor(deps.auditor ?? modelFor("auditor"));
const inFlight = new Set<string>();
function setPhase(chatId: string, phase: ExtractionPhase): void {
@@ -385,22 +444,17 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti
run.usage[stage]!.steps.push(usage);
}
/** The SDK's per-step usage, flattened to the persisted CallUsage fields
/** pi's per-step usage, flattened to the persisted CallUsage fields
* (the money channel's capture shape, contracts/chat.ts). */
function callUsage(usage: {
inputTokens: number | undefined;
outputTokens: number | undefined;
inputTokenDetails?: { cacheReadTokens?: number; cacheWriteTokens?: number };
outputTokenDetails?: { reasoningTokens?: number };
}): CallUsage {
// Optional-chained: the SDK types the detail objects as required, but
// providers (and mocks) may leave them unset.
function callUsage(usage: Usage): CallUsage {
return {
inputTokens: usage.inputTokens,
outputTokens: usage.outputTokens,
cacheReadTokens: usage.inputTokenDetails?.cacheReadTokens,
cacheWriteTokens: usage.inputTokenDetails?.cacheWriteTokens,
reasoningTokens: usage.outputTokenDetails?.reasoningTokens,
inputTokens: usage.input,
outputTokens: usage.output,
cacheReadTokens: usage.cacheRead,
cacheWriteTokens: usage.cacheWrite,
// Optional in pi's Usage (providers that never emit reasoning
// tokens leave it unset) — persisted as 0, never dropped.
reasoningTokens: usage.reasoning ?? 0,
};
}
@@ -413,23 +467,17 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti
console.info(`[extraction ${chatId}] extractor starting (${view.split("\n").length} transcript lines)`);
const t0 = performance.now();
const extraction = await extractorAgent.generate({
prompt: `Transcript:\n\n${view}`,
abortSignal: signal,
onStepFinish: (step) => {
recordStep(chatId, "extractor", callUsage(step.usage), step.model.modelId);
console.info(
`[extraction] extractor step ${step.stepNumber}: ${step.finishReason}, tools: ${step.toolCalls.map((c) => c.toolName).join(", ") || "(none)"}`,
);
const findings = await runExtractor(
deps.extractor,
`Transcript:\n\n${view}`,
signal,
(usage, model, step, finishReason, tools) => {
recordStep(chatId, "extractor", callUsage(usage), model);
console.info(`[extraction] extractor step ${step}: ${finishReason}, tools: ${tools}`);
},
});
for (const part of extraction.content) {
if (part.type === "tool-result" && part.toolName === "submit_findings") {
console.info(`[extraction ${chatId}] extractor done in ${Math.round(performance.now() - t0)}ms`);
return part.output as SessionFindings;
}
}
throw new Error("the extractor never submitted findings");
);
console.info(`[extraction ${chatId}] extractor done in ${Math.round(performance.now() - t0)}ms`);
return findings;
}
async function audit(chatId: string, findings: SessionFindings): Promise<AuditResult> {
@@ -445,25 +493,16 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti
);
const t0 = performance.now();
const review = await auditorAgent.generate({
prompt:
`Transcript:\n\n${view}\n\nProposed findings:\n\n${JSON.stringify(findings, null, 2)}\n\n` +
const auditResult = await runAuditor(
deps.auditor,
`Transcript:\n\n${view}\n\nProposed findings:\n\n${JSON.stringify(findings, null, 2)}\n\n` +
`Current knowledge graph (${graph.nodes.length} nodes):\n\n${graphView(graph.nodes)}`,
abortSignal: signal,
onStepFinish: (step) => {
recordStep(chatId, "auditor", callUsage(step.usage), step.model.modelId);
console.info(
`[extraction] auditor step ${step.stepNumber}: ${step.finishReason}, tools: ${step.toolCalls.map((c) => c.toolName).join(", ") || "(none)"}`,
);
signal,
(usage, model, step, finishReason, tools) => {
recordStep(chatId, "auditor", callUsage(usage), model);
console.info(`[extraction] auditor step ${step}: ${finishReason}, tools: ${tools}`);
},
});
let auditResult: AuditResult | null = null;
for (const part of review.content) {
if (part.type === "tool-result" && part.toolName === "submit_review") {
auditResult = part.output as AuditResult;
}
}
if (!auditResult) throw new Error("the auditor never submitted a review");
);
if (auditResult.dropped.length > 0) {
console.info(
`[extraction ${chatId}] audit dropped ${auditResult.dropped.length}: ` +
+130
View File
@@ -0,0 +1,130 @@
import { Agent, type AgentEvent, type AgentMessage, type AgentTool } from "@earendil-works/pi-agent-core";
import type { AssistantMessage, ToolResultMessage, Usage } from "@earendil-works/pi-ai";
import { openRouterApiKey, piEngineFor, thinkingLevelFor, type PiEngine } from "../models.ts";
import type { ModelRole } from "../../../config.ts";
/**
* The shared sub-agent runner (docs/pi-engine-swap.md §6): one helper every
* background agent (researcher, demo-builder, visual-maker, extractor,
* auditor) runs on — a stateless pi Agent built per call, driven to
* completion, with the seams the job runner and the money channel need.
*
* Error semantics mirror the old ToolLoopAgent.generate: provider/loop
* failures THROW (pi encodes them in the stream; this helper surfaces
* them), and an abort throws an AbortError so every existing
* `signal.aborted` early-return keeps working unchanged.
*/
export interface SubagentRunOptions {
/** The role whose engine (model + thinking level) this run uses. */
role: ModelRole;
/** Test seam: a scripted engine replaces the role's production one. */
engine?: PiEngine;
systemPrompt: string;
/** Initial transcript (continuation semantics: ends user / toolResult). */
messages?: AgentMessage[];
/** Fresh-run prompt text (mutually exclusive with a continuation). */
prompt?: string;
/** Max model calls (the old stepCountIs safety net). */
maxTurns: number;
/** Abort mid-model-call (the real-kill semantics). */
abortSignal?: AbortSignal;
/** Money channel: one call per model request. */
onUsage?: (usage: Usage) => void;
/** Activity line per tool execution (job record progress). */
onToolStart?: (toolName: string, args: unknown) => void;
/** Per completed assistant message (extraction's step log). */
onAssistantMessage?: (message: AssistantMessage, step: number) => void;
/** Extra stop rule (extractor: stop after a clean submit). */
shouldStop?: (message: AssistantMessage, toolResults: ToolResultMessage[]) => boolean;
}
export interface SubagentRunResult {
/** The messages this run added (assistant + tool results). */
newMessages: AgentMessage[];
/** The final assistant text (the deliverable). */
text: string;
}
export function subagentEngine(role: ModelRole, override?: PiEngine): PiEngine {
return override ?? piEngineFor(role);
}
export async function runSubagent(tools: AgentTool<any, any>[], opts: SubagentRunOptions): Promise<SubagentRunResult> {
const engine = subagentEngine(opts.role, opts.engine);
let turns = 0;
const agent = new Agent({
initialState: {
systemPrompt: opts.systemPrompt,
model: engine.model,
thinkingLevel: thinkingLevelFor(opts.role),
tools,
messages: opts.messages ?? [],
},
streamFn: engine.streamFn,
getApiKey: () => openRouterApiKey(),
shouldStopAfterTurn: (context) => {
turns++;
return turns >= opts.maxTurns || opts.shouldStop?.(context.message, context.toolResults) === true;
},
});
const onAbort = () => agent.abort();
opts.abortSignal?.addEventListener("abort", onAbort, { once: true });
const before = opts.messages?.length ?? 0;
let step = 0;
let errorMessage: string | undefined;
let aborted = false;
try {
agent.subscribe((event: AgentEvent) => {
switch (event.type) {
case "message_end":
if ((event.message as { role?: string }).role === "assistant") {
const m = event.message as AssistantMessage;
if (m.stopReason === "aborted") {
// The call was cancelled — no completed step, no billed
// usage (the abort path asserts zero steps).
aborted = true;
break;
}
step++;
opts.onUsage?.(m.usage);
opts.onAssistantMessage?.(m, step);
if (m.stopReason === "error") errorMessage = m.errorMessage;
}
break;
case "tool_execution_start":
opts.onToolStart?.(event.toolName, event.args);
break;
}
});
if (opts.prompt !== undefined) await agent.prompt({
role: "user",
content: opts.prompt,
timestamp: Date.now(),
});
else await agent.continue();
} finally {
opts.abortSignal?.removeEventListener("abort", onAbort);
}
if (aborted || opts.abortSignal?.aborted) {
const err = new Error("aborted");
err.name = "AbortError";
throw err;
}
if (errorMessage !== undefined) throw new Error(errorMessage);
const newMessages = agent.state.messages.slice(before);
const assistants = newMessages.filter((m) => (m as { role?: string }).role === "assistant") as AssistantMessage[];
const last = assistants[assistants.length - 1];
const text = last
? last.content
.filter((c): c is { type: "text"; text: string } => c.type === "text")
.map((c) => c.text)
.join("")
: "";
return { newMessages, text };
}
+30 -11
View File
@@ -1,6 +1,9 @@
import { ToolLoopAgent, stepCountIs, type LanguageModel } from "ai";
import type { AgentTool } from "@earendil-works/pi-agent-core";
import type { Usage } from "@earendil-works/pi-ai";
import { webTools } from "../tools/web.ts";
import { modelFor } from "../models.ts";
import { toPiTools } from "../lh-tool.ts";
import type { PiEngine } from "../models.ts";
import { runSubagent } from "./pi-run.ts";
/**
* The researcher sub-agent (vision §2.5): a SEPARATE ToolLoopAgent instance
@@ -9,7 +12,7 @@ import { modelFor } from "../models.ts";
*
* System prompt adapted from the pi researcher agent
* (~/.pi/agent/extensions/interactive-subagents/agents/researcher.md).
* The model is injectable so tests run the loop with a MockLanguageModelV4.
* The engine is injectable so tests run the loop with a scripted faux engine.
*/
const INSTRUCTIONS = `You are a research specialist. You work for a teacher agent inside a learning
@@ -57,13 +60,29 @@ Numbered findings with inline source citations:
## Gaps
What couldn't be answered. Suggested next steps.`;
export function createResearcherAgent(model?: LanguageModel) {
return new ToolLoopAgent({
model: model ?? modelFor("researcher"),
instructions: INSTRUCTIONS,
tools: webTools,
stopWhen: stepCountIs(12),
});
export interface ResearcherRunOptions {
engine?: PiEngine;
abortSignal?: AbortSignal;
onUsage?: (usage: Usage) => void;
onToolStart?: (toolName: string, args: unknown) => void;
}
export type ResearcherAgent = ReturnType<typeof createResearcherAgent>;
/**
* Run the researcher to completion: isolated context, web tools only, final
* text = the brief. The engine is injectable so tests run the loop scripted.
*/
export async function runResearcherAgent(prompt: string, opts: ResearcherRunOptions = {}): Promise<string> {
const { text } = await runSubagent(toPiTools(webTools) as AgentTool<any, any>[], {
role: "researcher",
prompt,
engine: opts.engine,
systemPrompt: INSTRUCTIONS,
maxTurns: 12,
abortSignal: opts.abortSignal,
onUsage: opts.onUsage,
onToolStart: opts.onToolStart,
});
return text;
}
export type ResearcherAgent = ReturnType<typeof runResearcherAgent>;
+24 -25
View File
@@ -1,7 +1,9 @@
import { isToolUIPart, type LanguageModel, type UIMessage } from "ai";
import { isToolUIPart, type UIMessage } from "ai";
import type { PiEngine } from "../models.ts";
import {
accumulateJobUsage,
buildSubagentSteer,
piUsageToStep,
type DemoResult,
type DemoToolInput,
type JobKind,
@@ -18,7 +20,7 @@ import { registerJobAbort, unregisterJobAbort } from "./aborts.ts";
import { MODELS } from "../../../config.ts";
import type { ChatStore, JobStore } from "../../core/ports.ts";
import { runDemoBuilder } from "./demo-builder.ts";
import { createResearcherAgent } from "./researcher.ts";
import { runResearcherAgent } from "./researcher.ts";
import { runVisualMaker } from "./visual-maker.ts";
/**
@@ -42,13 +44,13 @@ import { runVisualMaker } from "./visual-maker.ts";
/**
* Test seam: the spawn wiring has no injection point at the tool layer, so
* tests swap in a MockLanguageModelV4 here to keep job tests off the network.
* tests swap in a scripted pi engine here to keep job tests off the network.
* Undefined in production. (The jobs spawn through the module-level KIND
* runners, which read these at call time.)
*/
export const researchModelOverride: { current?: LanguageModel } = {};
export const demoModelOverride: { current?: LanguageModel } = {};
export const visualModelOverride: { current?: LanguageModel } = {};
export const researchModelOverride: { current?: PiEngine } = {};
export const demoModelOverride: { current?: PiEngine } = {};
export const visualModelOverride: { current?: PiEngine } = {};
/** The dispatch-time tool input (from src/agent/tools/jobs.ts schemas). */
type JobToolInput = ResearchToolInput | DemoToolInput | VisualToolInput;
@@ -232,7 +234,7 @@ export function createJobRunner(deps: JobRunnerDeps): JobRunner {
async function runResearchJob(
jobId: string,
model: LanguageModel | undefined,
model: PiEngine | undefined,
passedInput: JobToolInput | undefined,
loop: LoopContext,
): Promise<void> {
@@ -253,28 +255,25 @@ export function createJobRunner(deps: JobRunnerDeps): JobRunner {
.join("\n\n");
const fetchedUrls: string[] = [];
const agent = createResearcherAgent(model);
try {
jobStore.updateJob(jobId, { activity: "researching" });
const result = await agent.generate({
prompt,
const brief = await runResearcherAgent(prompt, {
engine: model,
abortSignal: loop.abortSignal,
// Money channel: each agent step is one model call — fold its
// usage into the record as it completes (progress write).
onStepFinish: ({ usage }) => loop.onUsage(usage),
onToolExecutionStart: ({ toolCall }) => {
const input = toolCall.input as { query?: string; url?: string } | undefined;
if (toolCall.toolName === "web_fetch" && typeof input?.url === "string") {
onUsage: (usage) => loop.onUsage(piUsageToStep(usage)),
onToolStart: (toolName, args) => {
const input = args as { query?: string; url?: string } | undefined;
if (toolName === "web_fetch" && typeof input?.url === "string") {
fetchedUrls.push(input.url);
}
jobStore.updateJob(jobId, { activity: activityLine(toolCall.toolName, input) });
jobStore.updateJob(jobId, { activity: activityLine(toolName, input) });
},
});
const brief = result.text.trim();
if (!brief) throw new Error("the researcher produced no text");
const research: ResearchResult = { brief, sources: parseSources(brief, fetchedUrls) };
if (!brief.trim()) throw new Error("the researcher produced no text");
const research: ResearchResult = { brief: brief.trim(), sources: parseSources(brief.trim(), fetchedUrls) };
await finishJob(jobId, research);
} catch (err) {
// Real-kill abort (2026-09): the abort path flipped the record and
@@ -291,7 +290,7 @@ export function createJobRunner(deps: JobRunnerDeps): JobRunner {
async function runDemoJob(
jobId: string,
model: LanguageModel | undefined,
model: PiEngine | undefined,
passedInput: JobToolInput | undefined,
loop: LoopContext,
): Promise<void> {
@@ -308,9 +307,9 @@ export function createJobRunner(deps: JobRunnerDeps): JobRunner {
const result = await runDemoBuilder(
{ title, brief },
{
model,
engine: model,
abortSignal: loop.abortSignal,
onUsage: loop.onUsage,
onUsage: (usage) => loop.onUsage(piUsageToStep(usage)),
onActivity: (line) => jobStore.updateJob(jobId, { activity: line }),
},
);
@@ -327,7 +326,7 @@ export function createJobRunner(deps: JobRunnerDeps): JobRunner {
async function runVisualJob(
jobId: string,
model: LanguageModel | undefined,
model: PiEngine | undefined,
passedInput: JobToolInput | undefined,
loop: LoopContext,
): Promise<void> {
@@ -343,9 +342,9 @@ export function createJobRunner(deps: JobRunnerDeps): JobRunner {
const result = await runVisualMaker(
{ brief },
{
model,
engine: model,
abortSignal: loop.abortSignal,
onUsage: loop.onUsage,
onUsage: (usage) => loop.onUsage(piUsageToStep(usage)),
onActivity: (line) => jobStore.updateJob(jobId, { activity: line }),
},
);
+101 -48
View File
@@ -1,7 +1,8 @@
import { generateText, tool, type LanguageModel, type ModelMessage, type ToolResultPart } from "ai";
import type { AssistantMessage, Message, ToolResultMessage, Usage } from "@earendil-works/pi-ai";
import { z } from "zod";
import { modelFor } from "../models.ts";
import type { UsageStepLike, VisualResult } from "../../core/contracts.ts";
import { lhTool, piToolDeclarations, type LhTool } from "../lh-tool.ts";
import { piEngineFor, thinkingLevelFor, type PiEngine } from "../models.ts";
import type { VisualResult } from "../../core/contracts.ts";
import { getHeadless, type HeadlessApi } from "./headless.ts";
/**
@@ -31,7 +32,8 @@ export interface VisualMakerInput {
}
export interface VisualMakerOptions {
model?: LanguageModel;
/** Test seam: scripted engine; production uses the visualMaker role. */
engine?: PiEngine;
headless?: HeadlessApi;
/** Max model rounds (write/render cycles + final answer). Default 8. */
maxIterations?: number;
@@ -39,9 +41,9 @@ export interface VisualMakerOptions {
onActivity?: (line: string) => void;
/** Kills the run mid-model-call (2026-09 real-kill abort semantics). */
abortSignal?: AbortSignal;
/** Money channel: one call per loop iteration (generateText), folded
/** Money channel: one call per loop iteration (streamSimple), folded
* into JobRecord.usage. */
onUsage?: (usage: UsageStepLike) => void;
onUsage?: (usage: Usage) => void;
}
const INSTRUCTIONS = `You are a visual maker — a diagram author + renderer for precise, spatial figures. You
@@ -86,25 +88,28 @@ Your FINAL message: 1-2 sentences of notes for the teacher (what the figure show
NEVER the SVG source itself. If you genuinely cannot make a correct picture of the brief, say exactly
that in your final message and why.`;
export function createVisualTools() {
export function createVisualTools(): Record<string, LhTool<any, any>> {
return {
write_svg: tool({
write_svg: lhTool({
name: "write_svg",
description: "Write the complete SVG source for the figure (overwrites any previous version).",
inputSchema: z.object({
schema: z.object({
svg: z.string().describe('Complete <svg xmlns="…">…</svg> markup.'),
}),
}),
edit_svg: tool({
edit_svg: lhTool({
name: "edit_svg",
description:
"Replace one exact spot in the current SVG: old_text must appear EXACTLY ONCE and is replaced by new_text. Call write_svg first. For larger changes, write_svg the whole document.",
inputSchema: z.object({
schema: z.object({
old_text: z.string().describe("The exact text to replace — must match exactly once in the current SVG."),
new_text: z.string().describe("The replacement text."),
}),
}),
}),
render_svg: tool({
render_svg: lhTool({
name: "render_svg",
description: "Render the current SVG to a PNG and show it to you for inspection. No arguments.",
inputSchema: z.object({}),
schema: z.object({}),
}),
};
}
@@ -125,6 +130,9 @@ export async function runVisualMaker(input: VisualMakerInput, opts: VisualMakerO
}
const tools = createVisualTools();
const toolDeclarations = piToolDeclarations(tools);
const engine = opts.engine ?? piEngineFor("visualMaker");
const reasoning = thinkingLevelFor("visualMaker");
const prompt = [
"Brief for ONE precise figure (from the teacher, verbatim — it is everything you know):",
brief,
@@ -132,7 +140,7 @@ export async function runVisualMaker(input: VisualMakerInput, opts: VisualMakerO
"Author the SVG, render it, look at it, iterate until correct, then finish with your notes.",
].join("\n");
let messages: ModelMessage[] = [{ role: "user", content: prompt }];
let messages: Message[] = [{ role: "user", content: prompt, timestamp: Date.now() }];
let currentSvg: string | null = null;
/** The svg shown in the most recent render (null = never rendered). */
let renderedSvg: string | null = null;
@@ -140,20 +148,20 @@ export async function runVisualMaker(input: VisualMakerInput, opts: VisualMakerO
try {
for (let iteration = 0; iteration < maxIterations; iteration++) {
const result = await generateText({
model: opts.model ?? modelFor("visualMaker"),
system: INSTRUCTIONS,
messages,
tools,
abortSignal: opts.abortSignal,
});
// One model call per iteration (streamSimple, no agent loop — this
// loop owns tool execution and the image-in-the-loop injection).
const result = await streamOnce(engine, INSTRUCTIONS, messages, toolDeclarations, reasoning, opts.abortSignal);
// Money channel: each iteration is one model call.
opts.onUsage?.(result.usage);
messages = [...messages, ...result.response.messages];
messages = [...messages, result];
const toolCalls = result.toolCalls;
const toolCalls = result.content.filter((c): c is Extract<typeof c, { type: "toolCall" }> => c.type === "toolCall");
if (toolCalls.length === 0) {
finalText = result.text.trim();
finalText = result.content
.filter((c): c is { type: "text"; text: string } => c.type === "text")
.map((c) => c.text)
.join("")
.trim();
// Doctrine gate: never deliver an unlooked SVG. If the current SVG
// changed since its last render (or was never rendered), show it now
// and make the model look before accepting the finish.
@@ -169,41 +177,48 @@ export async function runVisualMaker(input: VisualMakerInput, opts: VisualMakerO
renderedSvg = currentSvg;
messages = [
...messages,
{ role: "user", content: [{ type: "file", data: png, mediaType: "image/png" }, { type: "text", text: UNLOOKED_FINISH_MESSAGE }] },
{
role: "user",
content: [
{ type: "text", text: UNLOOKED_FINISH_MESSAGE },
{ type: "image", data: png.toString("base64"), mimeType: "image/png" },
],
timestamp: Date.now(),
},
];
continue;
}
return finish(currentSvg, finalText);
}
const toolResults: ToolResultPart[] = [];
const toolResults: ToolResultMessage[] = [];
let renderPng: Buffer | null = null; // appended as the image message AFTER the tool results
for (const call of toolCalls) {
if (call.toolName === "write_svg") {
const svg = (call.input as { svg?: unknown }).svg;
if (call.name === "write_svg") {
const svg = (call.arguments as { svg?: unknown }).svg;
if (typeof svg !== "string" || !svg.includes("<svg")) {
toolResults.push(toolText(call.toolCallId, call.toolName, "ERROR: expected a complete <svg>…</svg> document in the `svg` field."));
toolResults.push(toolText(call.id, call.name, "ERROR: expected a complete <svg>…</svg> document in the `svg` field."));
continue;
}
currentSvg = svg;
opts.onActivity?.(`write_svg: ${(svg.length / 1024).toFixed(1)} KB`);
toolResults.push(toolText(call.toolCallId, call.toolName, `Saved (${svg.length} bytes). Call render_svg to look at it.`));
} else if (call.toolName === "edit_svg") {
const edit = call.input as { old_text?: unknown; new_text?: unknown };
toolResults.push(toolText(call.id, call.name, `Saved (${svg.length} bytes). Call render_svg to look at it.`));
} else if (call.name === "edit_svg") {
const edit = call.arguments as { old_text?: unknown; new_text?: unknown };
if (!currentSvg) {
toolResults.push(toolText(call.toolCallId, call.toolName, "ERROR: nothing to edit — call write_svg first."));
toolResults.push(toolText(call.id, call.name, "ERROR: nothing to edit — call write_svg first."));
continue;
}
if (typeof edit.old_text !== "string" || typeof edit.new_text !== "string" || !edit.old_text) {
toolResults.push(toolText(call.toolCallId, call.toolName, "ERROR: expected old_text (non-empty) and new_text strings."));
toolResults.push(toolText(call.id, call.name, "ERROR: expected old_text (non-empty) and new_text strings."));
continue;
}
const occurrences = currentSvg.split(edit.old_text).length - 1;
if (occurrences === 0) {
toolResults.push(
toolText(
call.toolCallId,
call.toolName,
call.id,
call.name,
`ERROR: old_text not found. Closest line for reference:\n${snippetAround(currentSvg, edit.old_text)}`,
),
);
@@ -211,16 +226,16 @@ export async function runVisualMaker(input: VisualMakerInput, opts: VisualMakerO
}
if (occurrences > 1) {
toolResults.push(
toolText(call.toolCallId, call.toolName, `ERROR: old_text appears ${occurrences} times — it must match EXACTLY ONCE. Include more surrounding context to make it unique.`),
toolText(call.id, call.name, `ERROR: old_text appears ${occurrences} times — it must match EXACTLY ONCE. Include more surrounding context to make it unique.`),
);
continue;
}
currentSvg = currentSvg.replace(edit.old_text, edit.new_text);
opts.onActivity?.("edit_svg: applied");
toolResults.push(toolText(call.toolCallId, call.toolName, "OK — replaced 1 occurrence. Call render_svg to look at the edited SVG."));
} else if (call.toolName === "render_svg") {
toolResults.push(toolText(call.id, call.name, "OK — replaced 1 occurrence. Call render_svg to look at the edited SVG."));
} else if (call.name === "render_svg") {
if (!currentSvg) {
toolResults.push(toolText(call.toolCallId, call.toolName, "ERROR: nothing to render — call write_svg first."));
toolResults.push(toolText(call.id, call.name, "ERROR: nothing to render — call write_svg first."));
continue;
}
try {
@@ -228,18 +243,18 @@ export async function runVisualMaker(input: VisualMakerInput, opts: VisualMakerO
renderPng = png;
renderedSvg = currentSvg;
opts.onActivity?.("render_svg: inspecting render");
toolResults.push(toolText(call.toolCallId, call.toolName, "Rendered — the PNG follows in the next message. Look at it."));
toolResults.push(toolText(call.id, call.name, "Rendered — the PNG follows in the next message. Look at it."));
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
opts.onActivity?.("render_svg: render error");
toolResults.push(toolText(call.toolCallId, call.toolName, `RENDER ERROR (the SVG may be malformed): ${msg}. Fix it with write_svg and render again.`));
toolResults.push(toolText(call.id, call.name, `RENDER ERROR (the SVG may be malformed): ${msg}. Fix it with write_svg and render again.`));
}
} else {
toolResults.push(toolText(call.toolCallId, call.toolName, `ERROR: unknown tool "${call.toolName}". Use write_svg, edit_svg, or render_svg.`));
toolResults.push(toolText(call.id, call.name, `ERROR: unknown tool "${call.name}". Use write_svg, edit_svg, or render_svg.`));
}
}
// Message order: assistant tool-calls → tool results → render image.
messages = [...messages, { role: "tool", content: toolResults }];
messages = [...messages, ...toolResults];
if (renderPng) {
messages = [
...messages,
@@ -247,8 +262,9 @@ export async function runVisualMaker(input: VisualMakerInput, opts: VisualMakerO
role: "user",
content: [
{ type: "text", text: LOOK_MESSAGE },
{ type: "file", data: renderPng, mediaType: "image/png" },
{ type: "image", data: renderPng.toString("base64"), mimeType: "image/png" },
],
timestamp: Date.now(),
},
];
}
@@ -265,8 +281,45 @@ export async function runVisualMaker(input: VisualMakerInput, opts: VisualMakerO
return { svg: "", notes: `FAILED: iteration limit (${maxIterations}) reached without a verified SVG.` };
}
function toolText(toolCallId: string, toolName: string, value: string): ToolResultPart {
return { type: "tool-result", toolCallId, toolName, output: { type: "text", value } };
function toolText(toolCallId: string, toolName: string, value: string): ToolResultMessage {
return {
role: "toolResult",
toolCallId,
toolName,
content: [{ type: "text", text: value }],
isError: value.startsWith("ERROR:") || value.startsWith("RENDER ERROR"),
timestamp: Date.now(),
};
}
/** One raw model call (no agent loop): drain the event stream to the final
* AssistantMessage; provider errors and aborts throw so the loop's catch
* path keeps its old generateText semantics. */
async function streamOnce(
engine: PiEngine,
systemPrompt: string,
messages: Message[],
tools: ReturnType<typeof piToolDeclarations>,
reasoning: ReturnType<typeof thinkingLevelFor>,
abortSignal?: AbortSignal,
): Promise<AssistantMessage> {
const stream = engine.streamFn(engine.model, { systemPrompt, messages, tools }, {
reasoning: reasoning === "off" ? undefined : reasoning,
apiKey: process.env.OPENROUTER_API_KEY || undefined,
signal: abortSignal,
} as never);
for await (const event of stream) {
if (event.type === "done") return event.message;
if (event.type === "error") {
if (event.reason === "aborted") {
const err = new Error("aborted");
err.name = "AbortError";
throw err;
}
throw new Error(event.error.errorMessage ?? "the model call failed");
}
}
throw new Error("the model stream ended without a result");
}
/** A 3-line numbered context window around the closest fuzzy match for a
+106 -47
View File
@@ -1,8 +1,11 @@
import * as fs from "node:fs";
import * as path from "node:path";
import { ToolLoopAgent, stepCountIs, tool, type LanguageModel, type LanguageModelUsage, type ToolSet } from "ai";
import { Agent } from "@earendil-works/pi-agent-core";
import type { AgentEvent, AgentMessage, AgentTool } from "@earendil-works/pi-agent-core";
import type { Usage } from "@earendil-works/pi-ai";
import { PATHS } from "../../config.ts";
import { modelFor } from "./models.ts";
import { MODELS } from "../../config.ts";
import { piEngineFor, thinkingLevelFor, openRouterApiKey, type PiEngine } from "./models.ts";
import {
askUserQuestionTool,
createProberTools,
@@ -10,17 +13,20 @@ import {
type ProbeDoneContext,
type TeacherToolContext,
} from "./tools.ts";
import { toPiTools, type LhTool } from "./lh-tool.ts";
import { quizInputObject, quizInputProblems } from "../core/question-core.ts";
import { QUESTION_TOOLS } from "../core/pending.ts";
/**
* The two teaching-side agents (docs/probe-teach-split.md): the PROBER and
* the TEACHER. Same harness (agent loop, tool calling, streaming,
* multi-step — the few lines ToolLoopAgent provides), same transcript, but
* different prompts and different tool sets; the chat-handler constructs
* one per request off the transcript's seam (probe_done = teacher).
* the TEACHER. Same transcript, different prompts and tool sets; the
* chat-handler constructs ONE per request off the transcript's seam
* (probe_done = teacher) — the STATELESS per-turn engine (docs/
* pi-engine-swap.md §2): the persisted record is the source of truth, the
* agent is rebuilt from it every POST and discarded at turn end.
*
* Models are injectable so tests can pass a MockLanguageModelV4 and drive
* deterministic scripted lessons (see tests/chat-flow.test.ts).
* Engines are injectable so tests can pass a scripted faux engine and drive
* deterministic lessons (see tests/chat-flow.test.ts).
*
* System prompts are COMPOSED per agent: system-prompt.md (the shared base:
* philosophy, accuracy, quiz construction, question tools, formatting) +
@@ -62,9 +68,81 @@ const composedInstructions: Record<AgentKind, string> = {
// Money channel (wave 4): the chat-handler passes a per-turn collector;
// every model call's usage lands in the chat record's turns log
// (ChatStore.appendTurnUsage). The step carries far more than usage —
// the narrow param keeps the seam honest about what we consume.
export type StepUsageCollector = (step: { usage: LanguageModelUsage }) => void;
// (ChatStore.appendTurnUsage). The narrow param keeps the seam honest.
export type StepUsageCollector = (usage: Usage) => void;
/** Per-request construction options (the handler wires all of these). */
export interface AgentBuildOptions {
/** Model + stream function — tests inject a scripted faux engine. */
engine?: PiEngine;
/** The initial transcript, as pi messages (recordToPiMessages). */
messages?: AgentMessage[];
/** Money channel: one call per model request. */
onUsage?: StepUsageCollector;
/** Safety net (the old stepCountIs). Default 20 turns. */
maxTurns?: number;
/** Tests may pass a bare tool registry to drive deterministic flows. */
tools?: Record<string, LhTool<any, any>>;
}
/**
* The turn's stop rule (docs/pi-engine-swap.md §2):
* - a question tool call (quiz / ask_user_question) that RESOLVED — its
* synthetic "question posed" result is not an error — ends the run: the
* learner's answer arrives as the next POST. A call that FAILED authoring
* validation (error result) must NOT stop the run — the model retries
* within the same turn, exactly the InvalidToolInputError semantics.
* - the maxTurns safety net replaces stepCountIs(20).
*/
function makeStopper(maxTurns: number) {
let turns = 0;
return (context: {
message: { content: Array<{ type: string; id?: string; name?: string }> };
toolResults: Array<{ toolCallId: string; isError?: boolean }>;
}): boolean => {
turns++;
if (turns >= maxTurns) return true;
const called = new Set(
context.message.content
.filter((c) => c.type === "toolCall" && c.name !== undefined && (QUESTION_TOOLS as readonly string[]).includes(c.name!))
.map((c) => c.id!),
);
if (called.size === 0) return false;
// Stop only when every question call of this turn resolved cleanly.
for (const result of context.toolResults) {
if (called.has(result.toolCallId) && result.isError) return false;
}
return true;
};
}
function baseAgentOptions(kind: AgentKind, opts: AgentBuildOptions, tools: AgentTool<any, any>[]) {
const engine = opts.engine ?? piEngineFor(kind);
const stopper = makeStopper(opts.maxTurns ?? 20);
const agent = new Agent({
initialState: {
systemPrompt: composedInstructions[kind],
model: engine.model,
thinkingLevel: thinkingLevelFor(kind),
tools,
messages: opts.messages ?? [],
},
streamFn: engine.streamFn,
getApiKey: () => openRouterApiKey(),
shouldStopAfterTurn: stopper,
});
if (opts.onUsage) {
agent.subscribe((event: AgentEvent) => {
if (event.type === "message_end" && event.message.role === "assistant" && "usage" in event.message) {
// A cancelled call is not a billed step (partial/estimated
// usage on an aborted message is not money spent).
if (event.message.stopReason === "aborted") return;
opts.onUsage!(event.message.usage);
}
});
}
return agent;
}
/**
* The PROBER (agent A): quiz (no nodeId) + ask + probe_done, lean on
@@ -73,19 +151,8 @@ export type StepUsageCollector = (step: { usage: LanguageModelUsage }) => void;
* handoff wake (see tools/probe.ts) and is the transcript seam the
* handler keys teacher construction on.
*/
export function createProberAgent(ctx: ProbeDoneContext, model?: LanguageModel, onStepFinish?: StepUsageCollector) {
return new ToolLoopAgent({
// Model choice + thinking budget live in config.ts (MODELS.prober).
// Reasoning tokens stream as reasoning parts into the UI's collapsible block.
model: model ?? modelFor("prober"),
instructions: composedInstructions.prober,
tools: createProberTools(ctx),
// Question tools are client-side (a call ends the server turn);
// probe_done resolves immediately (it queues the handoff and acks).
// stepCountIs is just the safety net.
stopWhen: stepCountIs(20),
...(onStepFinish ? { onStepFinish } : {}),
});
export function createProberAgent(ctx: ProbeDoneContext, opts: AgentBuildOptions = {}) {
return baseAgentOptions("prober", opts, toPiTools(createProberTools(ctx)));
}
/**
@@ -99,44 +166,36 @@ export function createTeacherAgent(
// wiring): without ctx the tools fall back to the validation-free
// question set. The handler ALWAYS passes ctx (the composition root).
ctx?: TeacherToolContext,
model?: LanguageModel,
onStepFinish?: StepUsageCollector,
// Tests may pass a bare tools set to drive deterministic flows; when
// present it overrides the ctx-composed registry.
tools?: ToolSet,
opts: AgentBuildOptions = {},
) {
return new ToolLoopAgent({
// Model choice + thinking budget live in config.ts (MODELS.teacher).
model: model ?? modelFor("teacher"),
instructions: composedInstructions.teacher,
// Tools are per-request (they close over the injected ctx — chat-
// handler passes the composition root's stores + spawn seam).
tools: tools ?? (ctx ? createTeacherTools(ctx) : bareQuestionTools()),
// Question tools are client-side (a call ends the server turn);
// job tools resolve immediately (fire-and-forget ack). stepCountIs is
// just the safety net.
stopWhen: stepCountIs(20),
...(onStepFinish ? { onStepFinish } : {}),
});
const tools = opts.tools ?? (ctx ? createTeacherTools(ctx) : bareQuestionTools());
return baseAgentOptions("teacher", opts, toPiTools(tools));
}
/** The validation-free teach question set (tests, bare agents): quiz with
* the shared authoring refinement but NO per-session nodeId check (no
* record to read), plus ask. */
export function bareQuestionTools(): ToolSet {
export function bareQuestionTools(): Record<string, LhTool<any, any>> {
return {
quiz: tool({
quiz: {
name: "quiz",
description: "Ask the learner a graded question.",
inputSchema: quizInputObject.superRefine((input, add) => {
clientQuestion: true,
schema: quizInputObject.superRefine((input, add) => {
for (const problem of quizInputProblems(input)) {
add.addIssue({ code: "custom", message: problem });
}
}),
}),
},
ask_user_question: askUserQuestionTool,
};
}
export type ProberAgent = ReturnType<typeof createProberAgent>;
export type TeacherAgent = ReturnType<typeof createTeacherAgent>;
export type TeachingAgent = ProberAgent | TeacherAgent;
export type TeachingAgent = ProberAgent | TeacherAgent;
/** The model id a role's engine resolves to (observability). */
export function modelIdFor(kind: AgentKind): string {
return MODELS[kind].model;
}
+17 -11
View File
@@ -1,4 +1,4 @@
import { tool, type ToolSet } from "ai";
import { lhTool, type LhTool } from "./lh-tool.ts";
import {
askInputSchema,
probeQuizInputSchema,
@@ -33,17 +33,21 @@ export type { ProbeDoneContext } from "./tools/probe.ts";
* only.
*/
export const askUserQuestionTool = tool({
export const askUserQuestionTool: LhTool<any, any> = {
name: "ask_user_question",
clientQuestion: true,
description:
"Ask the learner a single question and pause until they answer. NOT graded — there is no correct answer. " +
"Use when requirements are ambiguous, preferences are needed, a decision would materially affect the lesson, " +
"or you need an open-ended response. Ask exactly one question per tool call; after calling, end your turn and wait.",
inputSchema: askInputSchema,
});
schema: askInputSchema,
};
// ─── the prober's question tools (no nodeId — no map exists yet) ─────────────
export const probeQuizTool = tool({
export const probeQuizTool: LhTool<any, any> = {
name: "quiz",
clientQuestion: true,
description:
"Ask the learner a GRADED question with a known correct answer, then instantly grade and give feedback. " +
"Unlike ask_user_question (which collects preferences/decisions with no right answer), quiz always has a " +
@@ -55,11 +59,11 @@ export const probeQuizTool = tool({
"free-text note to ANY answer; it reaches you only when non-empty. No free-text answers — for non-graded " +
"questions use ask_user_question instead. Ask exactly one question per tool call; after calling, end your " +
"turn and wait for the graded result.",
inputSchema: probeQuizInputSchema,
});
schema: probeQuizInputSchema,
};
/** The prober's tool set: probe quiz (no nodeId) + ask + probe_done. */
export function createProberTools(ctx: ProbeDoneContext): ToolSet {
export function createProberTools(ctx: ProbeDoneContext): Record<string, LhTool<any, any>> {
return {
quiz: probeQuizTool,
ask_user_question: askUserQuestionTool,
@@ -96,7 +100,9 @@ export function teachQuizToolFor(ctx: Pick<PlanToolContext, "chatId" | "chatStor
});
}
});
return tool({
return lhTool({
name: "quiz",
clientQuestion: true,
description:
"Ask the learner a GRADED question with a known correct answer, then instantly grade and give feedback. " +
"Unlike ask_user_question (which collects preferences/decisions with no right answer), quiz always has a " +
@@ -108,7 +114,7 @@ export function teachQuizToolFor(ctx: Pick<PlanToolContext, "chatId" | "chatStor
"An always-present optional note field lets the learner attach a free-text note to ANY answer; it reaches you " +
"only when non-empty. No free-text answers — for non-graded questions use ask_user_question instead. " +
"Ask exactly one question per tool call; after calling, end your turn and wait for the graded result.",
inputSchema,
schema: inputSchema,
});
}
@@ -126,7 +132,7 @@ export interface TeacherToolContext extends PlanToolContext {
* Per-request teacher tool registry: teach questions (validated nodeId) +
* plan tools + job tools, all closing over the ctx.
*/
export function createTeacherTools(ctx: TeacherToolContext): ToolSet {
export function createTeacherTools(ctx: TeacherToolContext): Record<string, LhTool<any, any>> {
return {
quiz: teachQuizToolFor(ctx),
ask_user_question: askUserQuestionTool,
+11 -8
View File
@@ -1,5 +1,5 @@
import { tool, type ToolSet } from "ai";
import { z } from "zod";
import { lhTool, type LhTool } from "../lh-tool.ts";
import type { JobRecord } from "../../core/contracts.ts";
import type { JobStore } from "../../core/ports.ts";
@@ -103,21 +103,24 @@ const VISUAL_DESCRIPTION =
* context (chatId + jobStore + the spawn seam), so concurrent chats never
* share state. chat-handler wires it each turn; tests wire their own.
*/
export function jobTools(ctx: JobToolContext): ToolSet {
export function jobTools(ctx: JobToolContext): Record<string, LhTool<any, any>> {
return {
research: tool({
research: lhTool({
name: "research",
description: RESEARCH_DESCRIPTION,
inputSchema: researchInputSchema,
schema: researchInputSchema,
execute: async (input, { toolCallId }) => dispatch(ctx, "research", toolCallId, RESEARCH_ACK, input),
}),
demo: tool({
demo: lhTool({
name: "demo",
description: DEMO_DESCRIPTION,
inputSchema: demoInputSchema,
schema: demoInputSchema,
execute: async (input, { toolCallId }) => dispatch(ctx, "demo", toolCallId, DEMO_ACK, input),
}),
visual: tool({
visual: lhTool({
name: "visual",
description: VISUAL_DESCRIPTION,
inputSchema: visualInputSchema,
schema: visualInputSchema,
execute: async (input, { toolCallId }) => dispatch(ctx, "visual", toolCallId, VISUAL_ACK, input),
}),
};
+5 -4
View File
@@ -1,5 +1,5 @@
import { tool, type ToolSet } from "ai";
import { z } from "zod";
import { lhTool, type LhTool } from "../lh-tool.ts";
import type { PlanToolResult, PlanTree } from "../../core/contracts.ts";
import type { ChatStore } from "../../core/ports.ts";
import { createTree, emptyTree, PlanTreeError } from "../../core/plan-tree.ts";
@@ -37,16 +37,17 @@ export interface PlanToolContext {
* latest state straight from the transcript's tool outputs (the plan
* window and the plan blocks).
*/
export function planTools(ctx: PlanToolContext): ToolSet {
export function planTools(ctx: PlanToolContext): Record<string, LhTool<any, any>> {
return {
plan_init: tool({
plan_init: lhTool({
name: "plan_init",
description:
"Write or REWRITE this session's plan map — the dependency graph the learner follows live in the plan window. " +
"Call once right after the probe concludes, to present the map; re-call whenever the map is wrong (the new block replaces the old tree). " +
"A mermaid graph block (any direction): one node per line (`slug[Short Title]` — slugs are permanent lowercase ids: a-z, 0-9, hyphens/underscores), " +
"`-->` edges meaning 'from must be understood before to' (chains fine). 5–15 nodes: a map, not a syllabus — one reasoning step per node, compress. " +
"Every check you fire names the node it assesses via `nodeId`; where the lesson stands derives from those tags.",
inputSchema: z.object({
schema: z.object({
mermaid: z
.string()
.min(1)
+5 -4
View File
@@ -1,5 +1,5 @@
import { tool, type ToolSet } from "ai";
import { z } from "zod";
import { lhTool, type LhTool } from "../lh-tool.ts";
import type { ProbeDoneInput, ProbeDoneResult } from "../../core/contracts.ts";
import type { UIMessage } from "ai";
import { findPendingToolCall } from "../../core/pending.ts";
@@ -44,9 +44,10 @@ function probeConcluded(messages: UIMessage[]): boolean {
* The first probe_done in the transcript is also the construction seam:
* from that message on, every request builds the TEACHER agent.
*/
export function probeDoneTool(ctx: ProbeDoneContext): ToolSet {
export function probeDoneTool(ctx: ProbeDoneContext): Record<string, LhTool<any, any>> {
return {
probe_done: tool({
probe_done: lhTool({
name: "probe_done",
description:
"Conclude the probing phase and hand off to the teaching phase. Call this ONCE, when the probe is " +
"complete: every strand the lesson depends on is bracketed (a floor he reliably gets right AND a " +
@@ -55,7 +56,7 @@ export function probeDoneTool(ctx: ProbeDoneContext): ToolSet {
"(floor, ceiling, misconceptions, what to build from). It streams to the learner live and becomes " +
"the handoff artifact the teacher plans from. Refused while a question is still open, and after " +
"the probe is already concluded. After it succeeds, say your closing sentence and end your turn.",
inputSchema: z.object({
schema: z.object({
assessment: z
.object({
strands: z
+7 -5
View File
@@ -1,5 +1,5 @@
import { tool } from "ai";
import { z } from "zod";
import { lhTool } from "../lh-tool.ts";
import { Readability } from "@mozilla/readability";
import { parseHTML } from "linkedom";
import TurndownService from "turndown";
@@ -137,11 +137,12 @@ async function fetchAndExtract(url: string): Promise<string> {
// ── tool registry ────────────────────────────────────────────────────────────
export const webTools = {
web_search: tool({
web_search: lhTool({
name: "web_search",
description:
"Search the web via Google Custom Search. One search per call — use one call per search angle. " +
"Returns an array of results with title, url, and snippet.",
inputSchema: z.object({
schema: z.object({
query: z.string().describe("The search query (a normal search string, phrases included)"),
count: z.number().int().min(1).max(10).optional().describe("Number of results (default 5, max 10)"),
}),
@@ -161,11 +162,12 @@ export const webTools = {
},
}),
web_fetch: tool({
web_fetch: lhTool({
name: "web_fetch",
description:
"Fetch a web page and extract its readable content as markdown (Readability). " +
"Handles HTML pages and plain text; JS-rendered pages fall back to a reader service.",
inputSchema: z.object({
schema: z.object({
url: z.string().describe("The URL to fetch"),
}),
execute: async ({ url }): Promise<string> => {
+18
View File
@@ -68,6 +68,24 @@ export interface UsageStepLike {
outputTokenDetails?: { reasoningTokens?: number } | undefined;
}
/** pi-ai's Usage shape (input/output/cacheRead/cacheWrite/reasoning?) →
* UsageStepLike. Structural — no pi import here; the engine band maps at
* its boundaries. */
export function piUsageToStep(usage: {
input: number;
output: number;
cacheRead: number;
cacheWrite: number;
reasoning?: number;
}): UsageStepLike {
return {
inputTokens: usage.input,
outputTokens: usage.output,
inputTokenDetails: { cacheReadTokens: usage.cacheRead, cacheWriteTokens: usage.cacheWrite },
outputTokenDetails: usage.reasoning !== undefined ? { reasoningTokens: usage.reasoning } : undefined,
};
}
/** Fold one model call's usage into the run's running total (pure). A
* field neither the total nor the step reported stays undefined; once
* reported, later unreported calls contribute 0. The `steps` counter is
+84 -51
View File
@@ -1,18 +1,21 @@
import { createAgentUIStreamResponse, InvalidToolInputError, NoSuchToolError, type LanguageModelUsage, type UIMessage } from "ai";
import type { UIMessage } from "ai";
import type { Usage } from "@earendil-works/pi-ai";
import { createProberAgent, createTeacherAgent, type TeachingAgent } from "../agent/teacher-agent.ts";
import { createJobRunner } from "../agent/subagents/runner.ts";
import { createExtractionPipeline } from "../agent/subagents/extraction.ts";
import { MODELS } from "../../config.ts";
import { claimSteers, deliverJobResult, setJobRespawn, setTurnRunner } from "./jobs.ts";
import { recordToPiMessages } from "./pi/record-adapter.ts";
import { createPiTurnResponse } from "./pi/ui-stream.ts";
import { stores } from "./stores.ts";
import { beginTurn, endTurn } from "./turn-liveness.ts";
import { persistTurn, triggerExtraction, withMessageIds } from "./turn-service.ts";
import type { CallUsage } from "../core/contracts.ts";
/**
* One chat turn: run the teacher agent over the client's message list and
* One chat turn: run the teaching agent over the client's message list and
* stream the UI message stream back. Framework-free (plain Request/Response)
* so tests can drive it directly with a scripted mock model — the Next route
* so tests can drive it directly with a scripted faux engine — the Next route
* handler in app/api/chat is a one-line wrapper.
*
* This module is the TURN COMPOSITION ROOT: it wires the storage ports, the
@@ -21,6 +24,11 @@ import type { CallUsage } from "../core/contracts.ts";
* THE SAME COMPOSITION serves both entry points: client requests and the
* wake pipeline's server-side steers (src/server/jobs.ts) — one code path
* for write-ahead persistence, turn liveness, and the turn services.
*
* Engine note (docs/pi-engine-swap.md): the agent is STATELESS per turn —
* built fresh from the persisted record (recordToPiMessages), run by the
* bridge (pi/ui-stream.ts), discarded at turn end. The record is the only
* source of truth.
*/
const jobRunner = createJobRunner({
@@ -48,7 +56,27 @@ setTurnRunner(async (chatId, messages) => {
});
setJobRespawn((jobId) => void jobRunner.runJob(jobId));
export async function handleChatRequest(req: Request, agent?: TeachingAgent): Promise<Response> {
/** The message-shape gate the SDK's UIMessageValidator owned: a malformed
* body must fail BEFORE the stream exists — the path where no onFinish will
* ever run (the catch clears the turn registry so the chat never locks). */
function assertValidUiMessages(messages: UIMessage[]): void {
for (const m of messages) {
if (m.role !== "user" && m.role !== "assistant" && m.role !== "system") {
throw new Error(`invalid message role: ${String((m as { role?: unknown }).role)}`);
}
if (!Array.isArray(m.parts)) throw new Error("message has no parts array");
for (const p of m.parts) {
if (p.type === "text" && typeof (p as { text?: unknown }).text !== "string") {
throw new Error("text part has no text");
}
}
}
}
export async function handleChatRequest(
req: Request,
buildAgent?: (outgoing: UIMessage[]) => TeachingAgent,
): Promise<Response> {
let body: { chatId?: unknown; messages?: unknown };
try {
body = await req.json();
@@ -95,19 +123,20 @@ export async function handleChatRequest(req: Request, agent?: TeachingAgent): Pr
}
// MONEY CHANNEL (wave 4): one collector per request, fed by the agent's
// step callback. Every model call's usage lands in the chat record's
// message_end events. Every model call's usage lands in the chat record's
// append-only turns log at onFinish — idempotent per turn id, so a
// replayed persist appends once while continuation turns (which share a
// response message id — the SDK coalesces them) each keep their usage.
// response message id) each keep their usage.
const turnUsageId = crypto.randomUUID();
const steps: CallUsage[] = [];
const collectUsage = (step: { usage: LanguageModelUsage }) => {
const collectUsage = (usage: Usage) => {
steps.push({
inputTokens: step.usage.inputTokens,
outputTokens: step.usage.outputTokens,
cacheReadTokens: step.usage.inputTokenDetails?.cacheReadTokens,
cacheWriteTokens: step.usage.inputTokenDetails?.cacheWriteTokens,
reasoningTokens: step.usage.outputTokenDetails?.reasoningTokens,
inputTokens: usage.input,
outputTokens: usage.output,
cacheReadTokens: usage.cacheRead,
cacheWriteTokens: usage.cacheWrite,
// Optional in pi's Usage — persisted as 0, never dropped.
reasoningTokens: usage.reasoning ?? 0,
});
};
@@ -119,49 +148,53 @@ export async function handleChatRequest(req: Request, agent?: TeachingAgent): Pr
// steers, usage, extraction) — only the agent differs.
const teaching = outgoing.some((m) => m.parts.some((p) => p.type === "tool-probe_done"));
const modelId = teaching ? MODELS.teacher.model : MODELS.prober.model;
const constructed: TeachingAgent = teaching
? createTeacherAgent(
{
chatId,
chatStore: stores.chatStore,
jobStore: stores.jobStore,
spawn: (job, input) => void jobRunner.runJob(job.id, input),
},
undefined,
collectUsage,
)
: createProberAgent(
{
chatId,
chatStore: stores.chatStore,
deliver: (steer) => void deliverJobResult(steer),
},
undefined,
collectUsage,
);
// Tests inject a factory: the agent is stateless per turn (docs/
// pi-engine-swap.md §2), so each request builds a fresh one off the
// transcript — the factory mirrors that seam exactly.
const constructed: TeachingAgent = buildAgent
? buildAgent(outgoing)
: teaching
? createTeacherAgent(
{
chatId,
chatStore: stores.chatStore,
jobStore: stores.jobStore,
spawn: (job, input) => void jobRunner.runJob(job.id, input),
},
{ messages: recordToPiMessages(outgoing), onUsage: collectUsage },
)
: createProberAgent(
{
chatId,
chatStore: stores.chatStore,
deliver: (steer) => void deliverJobResult(steer),
},
{ messages: recordToPiMessages(outgoing), onUsage: collectUsage },
);
// A test-injected agent gets no usage collector (tests assert the
// transcript, not the money channel); when it's ours, usage flows through
// the factory's subscription (wired above via onUsage).
try {
return await createAgentUIStreamResponse({
agent: agent ?? constructed,
uiMessages: outgoing,
// Tool-call validation failures are messages OUR schemas authored (the
// quiz authoring contract, unknown tools) — safe and useful in the
// transcript: they are exactly what the teacher saw and retried on. The
// tool loop delivers them as plain strings (what the model received) or
// as the original error object; expose both, and keep everything else
// (provider/transport internals) masked, as the SDK defaults.
onError: (error) => {
if (typeof error === "string") return error;
if (InvalidToolInputError.isInstance(error) || NoSuchToolError.isInstance(error)) return error.message;
return "An error occurred.";
},
onFinish: ({ messages, isAborted }) => {
assertValidUiMessages(outgoing);
return createPiTurnResponse({
agent: constructed,
uiMessages: outgoing,
signal: req.signal,
// Stream-level failures are provider/transport internals — masked,
// like the SDK defaults. Tool-input validation messages never
// route through here: they ride the error-result path and reach the
// transcript directly (the retry context the model saw).
onError: (error) => {
console.error(`[chat ${chatId}] turn error:`, error);
return "An error occurred.";
},
onFinish: (messages, isAborted) => {
persistTurn(stores.chatStore, chatId, messages as UIMessage[], isAborted);
// usage log append (idempotent per turn id) — the stats bar
// refolds this on the client after the turn settles. messageId
// comes from the id-FILLED list: onFinish messages carry no ids
// (withMessageIds fills copies in persistTurn, not these); the
// re-fill here is idempotent, so the key is stable.
// comes from the id-FILLED list: the bridge's response message
// already carries its id; the re-fill here is idempotent.
const filled = withMessageIds(messages as UIMessage[]);
const response = [...filled].reverse().find((m) => m.role === "assistant");
if (response && steps.length > 0) {
@@ -186,4 +219,4 @@ export async function handleChatRequest(req: Request, agent?: TeachingAgent): Pr
console.error(`[chat ${chatId}] turn failed to start:`, err);
return Response.json({ error: "Failed to start the turn" }, { status: 500 });
}
}
}
+163
View File
@@ -0,0 +1,163 @@
import type { UIMessage } from "ai";
import type { AgentMessage } from "@earendil-works/pi-agent-core";
import type { AssistantMessage, Message, ToolResultMessage } from "@earendil-works/pi-ai";
/**
* The record → pi-ai transcript adapter (docs/pi-engine-swap.md §4). The
* persisted UIMessage record IS the source of truth; every turn builds a
* fresh agent from it (the stateless per-turn decision). This replaces the
* SDK's convertToModelMessages with an owned, explicit mapping:
*
* - user text parts → UserMessage text content; data parts are dropped
* (the plan-accept seam rides its text twin — the client always sends
* "The plan is accepted — begin teaching." alongside the marker).
* - assistant text/reasoning/tool parts → one AssistantMessage whose
* content blocks mirror the part order; tool parts become ToolCall
* blocks (output-error parts keep their rawInput).
* - answered tool parts (output-available) → ToolResultMessage after the
* assistant message, JSON-encoded like the model saw it live.
* - errored tool parts (output-error) → an isError ToolResultMessage
* carrying the validation message (the model's retry context).
* - DANGLING calls (input-available, never answered — an aborted turn)
* → a synthesized interrupted ToolResultMessage. THE HARD INVARIANT:
* an assistant message with tool calls must be followed by tool
* results, or providers reject the request outright.
*/
const INTERRUPTED = "The question was interrupted — the learner never answered; continue without holding a result for it.";
function textOf(part: { text?: string }): string {
return typeof part.text === "string" ? part.text : "";
}
/** Encode a tool output the way the model saw it live (string verbatim, object JSON). */
function toolResultText(output: unknown): string {
return typeof output === "string" ? output : JSON.stringify(output);
}
function zeroUsage() {
return {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
};
}
export function recordToPiMessages(messages: UIMessage[]): Message[] {
const out: AgentMessage[] = [];
for (const message of messages) {
if (message.role === "user") {
const texts = message.parts
.filter((p) => p.type === "text")
.map((p) => textOf(p as { text?: string }))
.filter((t) => t.length > 0);
if (texts.length === 0) continue; // pure data-part marker, no text
out.push({
role: "user",
content: texts.map((text) => ({ type: "text" as const, text })),
timestamp: Date.now(),
});
} else if (message.role === "assistant") {
const content: AssistantMessage["content"] = [];
const results: ToolResultMessage[] = [];
let answered = false;
for (const part of message.parts) {
if (part.type === "text") {
const text = textOf(part);
if (text) content.push({ type: "text", text });
} else if (part.type === "reasoning") {
const text = textOf(part);
if (text) content.push({ type: "thinking", thinking: text });
} else if (part.type === "step-start") {
// presentation-only; the pi loop re-derives turn boundaries
} else if (part.type.startsWith("tool-")) {
const tool = part.type.slice("tool-".length);
const p = part as unknown as {
toolCallId: string;
state: string;
input?: unknown;
rawInput?: unknown;
output?: unknown;
errorText?: string;
};
// The call block (any state — the model authored it).
if (p.state === "input-available" || p.state === "input-streaming" || p.state === "output-available") {
content.push({
type: "toolCall",
id: p.toolCallId,
name: tool,
arguments: (p.input ?? {}) as Record<string, unknown>,
});
} else if (p.state === "output-error") {
content.push({
type: "toolCall",
id: p.toolCallId,
name: tool,
arguments: (p.rawInput ?? p.input ?? {}) as Record<string, unknown>,
});
}
// The result, if any.
if (p.state === "output-available") {
results.push({
role: "toolResult",
toolCallId: p.toolCallId,
toolName: tool,
content: [{ type: "text", text: toolResultText(p.output) }],
isError: false,
timestamp: Date.now(),
});
answered = true;
} else if (p.state === "output-error") {
results.push({
role: "toolResult",
toolCallId: p.toolCallId,
toolName: tool,
content: [{ type: "text", text: p.errorText ?? "The tool call failed validation." }],
isError: true,
timestamp: Date.now(),
});
answered = true;
}
}
// data-* parts (plan blocks, artifacts, markers) never reach the model.
}
if (content.length === 0) continue; // presentation-only message
out.push({
role: "assistant",
content,
api: "openai-completions",
provider: "openrouter",
model: "replayed",
usage: zeroUsage(),
stopReason: content[content.length - 1]!.type === "toolCall" ? "toolUse" : "stop",
timestamp: Date.now(),
});
// Dangling calls: synthesize the interrupted result so providers
// never see an unanswered tool call (the hard invariant).
if (!answered) {
for (const block of content) {
if (block.type === "toolCall" && !results.some((r) => r.toolCallId === block.id)) {
results.push({
role: "toolResult",
toolCallId: block.id,
toolName: block.name,
content: [{ type: "text", text: INTERRUPTED }],
isError: false,
timestamp: Date.now(),
});
}
}
}
out.push(...results);
}
// role "system" never appears in records (system prompt is composed separately).
}
return out as Message[];
}
+346
View File
@@ -0,0 +1,346 @@
import type { Agent, AgentEvent } from "@earendil-works/pi-agent-core";
import type { UIMessage } from "ai";
import { QUESTION_TOOLS } from "../../core/pending.ts";
/**
* The UI-stream bridge (docs/pi-engine-swap.md §5): runs one agent turn and
* re-emits it as an AI-SDK-shaped UIMessageStream SSE response — the wire
* protocol `useChat` (Band D, unchanged) parses:
*
* data: {"type":"text-delta", …}\n\n … data: [DONE]
*
* Load-bearing protocol rules (verified against ai@7's client):
* - The `start` chunk's messageId MUST be the transcript's last message id
* when it is an assistant message (the continuation id rule) — a fresh id
* there duplicates every prior part client-side.
* - Question tools (quiz / ask_user_question): emit tool-input-available and
* NEVER tool-output-available (the client throws on an output for a call it
* owns). The synthetic "question posed" result lives in memory only; the
* record's part stays output-less until the learner answers.
* - Errored calls (authoring validation) surface as tool-input-error — the
* exact part shape the SDK produced for InvalidToolInputError (the
* output-error state the chat flow asserts).
* - The persisted message is assembled from the SAME parts the client
* assembles (the processUIMessageStream shapes), so every downstream
* consumer (phases, plan blocks, pending, extraction) is unchanged.
*/
/** SSE chunk — the flat JSON line shape the client's zod schema accepts. */
type Chunk = Record<string, unknown> & { type: string };
export interface PiTurnSpec {
/** The per-turn agent (stateless engine — built from the record). */
agent: Agent;
/** The outgoing request messages (write-ahead persisted already). */
uiMessages: UIMessage[];
/** Request abort signal — aborts the agent mid-run. */
signal?: AbortSignal;
/** The handler's error-unmasking policy (validation messages stay readable). */
onError?: (error: unknown) => string;
/** The canonical end-of-turn seam: persist + usage + extraction + endTurn. */
onFinish: (messages: UIMessage[], isAborted: boolean) => void;
}
/** Map a pi stop reason to the finish chunk's enum. */
function finishReasonOf(stopReason: string | undefined): string {
switch (stopReason) {
case "stop":
return "stop";
case "length":
return "length";
case "toolUse":
return "tool-calls";
case "error":
return "error";
default:
return "other";
}
}
function resultErrorText(result: unknown): string {
const content = (result as { content?: Array<{ type: string; text?: string }> } | undefined)?.content;
const text = content?.find((c) => c.type === "text")?.text;
return text ?? "The tool call failed.";
}
/** Loose part shapes the bridge assembles (the client's part shapes). */
interface TextPart {
type: "text";
text: string;
state: string;
}
interface ReasoningPart {
type: "reasoning";
id: string;
text: string;
state: string;
}
interface ToolPart {
type: string;
toolCallId: string;
state: string;
input?: unknown;
rawInput?: unknown;
output?: unknown;
errorText?: string;
}
export function createPiTurnResponse(spec: PiTurnSpec): Response {
const { agent, uiMessages, onFinish } = spec;
// The streaming target: continuation (same id, parts appended to a copy
// of the existing message) or a fresh assistant message.
const last = uiMessages[uiMessages.length - 1];
const continuing = last?.role === "assistant";
const messageId =
(continuing && last.id) || `msg_${Date.now().toString(36)}_${crypto.randomUUID().slice(0, 8)}`;
const baseParts: UIMessage["parts"] = continuing ? [...last.parts] : [];
const parts: UIMessage["parts"] = [...baseParts];
// Stable, collision-free part ids across continuation runs (reasoning
// parts persist their id; text ids only need per-run uniqueness).
let textN = baseParts.filter((p) => p.type === "text").length;
let reasoningN = baseParts.filter((p) => p.type === "reasoning").length;
const textIds = new Map<number, string>(); // contentIndex → chunk id
const reasoningIds = new Map<number, string>();
const toolInputs = new Map<string, { toolName: string; input: unknown }>();
/** The streaming text/reasoning parts of THIS run, keyed by chunk id. */
const activeParts = new Map<string, TextPart | ReasoningPart>();
/** Tool parts created this run, keyed by toolCallId. */
const newToolParts = new Map<string, ToolPart>();
let settled = false;
let sawAbort = false;
let finishReason: string | undefined;
let errorMessage: string | undefined;
const stream = new ReadableStream<Uint8Array>({
start(controller) {
const encoder = new TextEncoder();
// Swallow enqueue on a cancelled stream (the client went away): the
// run must still settle — onFinish persists the partial turn.
const send = (chunk: Chunk) => {
if (settled) return;
try {
controller.enqueue(encoder.encode(`data: ${JSON.stringify(chunk)}\n\n`));
} catch {
/* reader cancelled — stop emitting, keep running */
}
};
const close = () => {
if (settled) return;
settled = true;
try {
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
controller.close();
} catch {
/* already cancelled */
}
};
send({ type: "start", messageId });
const onAbort = () => {
sawAbort = true;
agent.abort();
};
spec.signal?.addEventListener("abort", onAbort, { once: true });
const settleWith = () => {
const assembled: UIMessage = { id: messageId, role: "assistant", parts };
// The SDK's onFinish shape: the input copy stays, the extended
// response copy appends (persistTurn's withMessageIds dedupe
// keeps the last occurrence).
onFinish([...uiMessages, assembled], sawAbort || spec.signal?.aborted === true);
};
const handle = (event: AgentEvent) => {
switch (event.type) {
case "turn_start":
send({ type: "start-step" });
parts.push({ type: "step-start" });
break;
case "message_update": {
const streamEvent = event.assistantMessageEvent;
switch (streamEvent.type) {
case "text_start": {
const id = `${messageId}-t${textN++}`;
textIds.set(streamEvent.contentIndex, id);
const part: TextPart = { type: "text", text: "", state: "streaming" };
activeParts.set(id, part);
parts.push(part as UIMessage["parts"][number]);
send({ type: "text-start", id });
break;
}
case "text_delta": {
const id = textIds.get(streamEvent.contentIndex);
const part = id !== undefined ? (activeParts.get(id) as TextPart | undefined) : undefined;
if (part) part.text += streamEvent.delta;
if (id !== undefined) send({ type: "text-delta", id, delta: streamEvent.delta });
break;
}
case "text_end": {
const id = textIds.get(streamEvent.contentIndex);
const part = id !== undefined ? (activeParts.get(id) as TextPart | undefined) : undefined;
if (part) part.state = "done";
activeParts.delete(id ?? "");
if (id !== undefined) send({ type: "text-end", id });
break;
}
case "thinking_start": {
const id = `${messageId}-r${reasoningN++}`;
reasoningIds.set(streamEvent.contentIndex, id);
const part: ReasoningPart = { type: "reasoning", id, text: "", state: "streaming" };
activeParts.set(id, part);
parts.push(part as UIMessage["parts"][number]);
send({ type: "reasoning-start", id });
break;
}
case "thinking_delta": {
const id = reasoningIds.get(streamEvent.contentIndex);
const part = id !== undefined ? (activeParts.get(id) as ReasoningPart | undefined) : undefined;
if (part) part.text += streamEvent.delta;
if (id !== undefined) send({ type: "reasoning-delta", id, delta: streamEvent.delta });
break;
}
case "thinking_end": {
const id = reasoningIds.get(streamEvent.contentIndex);
const part = id !== undefined ? (activeParts.get(id) as ReasoningPart | undefined) : undefined;
if (part) part.state = "done";
activeParts.delete(id ?? "");
if (id !== undefined) send({ type: "reasoning-end", id });
break;
}
case "toolcall_start": {
const block = (event.message as { content: any[] }).content[streamEvent.contentIndex];
if (block?.type !== "toolCall") break;
toolInputs.set(block.id, { toolName: block.name, input: undefined });
const part: ToolPart = {
type: `tool-${block.name}`,
toolCallId: block.id,
state: "input-streaming",
input: undefined,
};
newToolParts.set(block.id, part);
parts.push(part as unknown as UIMessage["parts"][number]);
send({ type: "tool-input-start", toolCallId: block.id, toolName: block.name });
break;
}
case "toolcall_delta": {
const block = (event.message as { content: any[] }).content[streamEvent.contentIndex];
if (block?.type !== "toolCall") break;
send({
type: "tool-input-delta",
toolCallId: block.id,
inputTextDelta: streamEvent.delta,
});
break;
}
case "toolcall_end": {
const toolCall = streamEvent.toolCall;
toolInputs.set(toolCall.id, { toolName: toolCall.name, input: toolCall.arguments });
const part = newToolParts.get(toolCall.id);
if (part) {
part.state = "input-available";
part.input = toolCall.arguments;
}
send({
type: "tool-input-available",
toolCallId: toolCall.id,
toolName: toolCall.name,
input: toolCall.arguments,
});
break;
}
}
break;
}
case "tool_execution_end": {
const known = toolInputs.get(event.toolCallId);
const toolName = event.toolName || known?.toolName || "";
const input = known?.input;
const part = newToolParts.get(event.toolCallId);
if (event.isError) {
const errorText = resultErrorText(event.result);
if (part) {
part.state = "output-error";
part.rawInput = input;
part.errorText = errorText;
}
send({
type: "tool-input-error",
toolCallId: event.toolCallId,
toolName,
input,
errorText,
});
} else if ((QUESTION_TOOLS as readonly string[]).includes(toolName)) {
// Client-owned: the answer arrives via addToolOutput;
// the synthetic placeholder result never reaches wire
// or record.
break;
} else {
const output = (event.result as { details?: unknown } | undefined)?.details;
if (part) {
part.state = "output-available";
part.output = output;
}
send({ type: "tool-output-available", toolCallId: event.toolCallId, output });
}
break;
}
case "turn_end":
send({ type: "finish-step" });
break;
case "message_end":
if (event.message && (event.message as { role?: string }).role === "assistant") {
const m = event.message as { stopReason?: string; errorMessage?: string };
finishReason = m.stopReason;
if (m.stopReason === "aborted") sawAbort = true;
if (m.stopReason === "error") errorMessage = m.errorMessage;
}
break;
case "agent_end": {
spec.signal?.removeEventListener("abort", onAbort);
if (errorMessage !== undefined) {
send({ type: "error", errorText: spec.onError?.(errorMessage) ?? errorMessage });
}
send({ type: "finish", finishReason: finishReasonOf(finishReason) });
close();
settleWith();
break;
}
}
};
agent.subscribe(handle);
void (async () => {
try {
await agent.continue();
} catch (err) {
// A loop-level failure (not a provider error — those ride the
// stream as stopReason "error"): surface, persist, close.
spec.signal?.removeEventListener("abort", onAbort);
send({ type: "error", errorText: spec.onError?.(err) ?? "An error occurred." });
send({ type: "finish", finishReason: "error" });
close();
settleWith();
}
})();
},
cancel() {
// The client went away (reload/abort): kill the model call. The
// run still settles — agent_end fires and onFinish persists.
agent.abort();
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache, no-transform",
"X-Accel-Buffering": "no",
},
});
}
+98 -124
View File
@@ -1,28 +1,38 @@
/**
* Scripted integration test — the full teacher loop with zero tokens:
*
* learner opens chat → mock model calls `quiz` → server streams + persists
* learner opens chat → faux model calls `quiz` → server streams + persists
* → client grades and sends the answer back as the tool output
* → mock model reacts → transcript persisted.
* → faux model reacts → transcript persisted.
*
* Drives handleChatRequest directly (plain Request/Response — no Next server
* needed) with a MockLanguageModelV4. This is the AI SDK equivalent of the
* old pi dispatch contract test: the quiz bridge is gone, the tool part IS
* the pending state, and the persisted transcript is the resume record.
* needed) with a scripted pi engine (pi-ai's faux provider, docs/
* pi-engine-swap.md §7): the quiz bridge is gone, the tool part IS the
* pending state, and the persisted transcript is the resume record.
*/
import assert from "node:assert/strict";
import * as fs from "node:fs";
import * as path from "node:path";
import { after, describe, it } from "node:test";
import { MockLanguageModelV4, simulateReadableStream } from "ai/test";
import type { LanguageModelV4StreamPart } from "@ai-sdk/provider";
import {
createFauxCore,
fauxAssistantMessage,
fauxText,
fauxThinking,
fauxToolCall,
type FauxResponseStep,
} from "@earendil-works/pi-ai";
import { createTeacherAgent } from "../src/agent/teacher-agent.ts";
import { handleChatRequest } from "../src/server/chat-handler.ts";
import { isTurnRunning } from "../src/server/turn-liveness.ts";
import { recordToPiMessages } from "../src/server/pi/record-adapter.ts";
import { chatStore } from "../src/store/chats.ts";
import { buildAnswer, coerceCorrectAnswer, normalizeOptions, resolveCorrect, type QuestionData } from "../src/core/question-core.ts";
import { findPendingToolCall } from "../src/core/pending.ts";
import type { PiEngine } from "../src/agent/models.ts";
import type { TeachingAgent } from "../src/agent/teacher-agent.ts";
import type { UIMessage } from "ai";
import { PATHS } from "../config.ts";
const QUIZ_INPUT = {
@@ -36,52 +46,41 @@ const QUIZ_INPUT = {
explanation: "Mercury is the innermost planet.",
};
function usage() {
return {
inputTokens: { total: 10, noCache: 10, cacheRead: 0, cacheWrite: 0 },
outputTokens: { total: 5, text: 5, reasoning: 0 },
};
/** Turn 1: a bit of reasoning, then the quiz tool call. Turn 2: plain text.
* Each response factory records the prompt it received (the transcript the
* model saw) — the old doStream capture, engine-shaped. */
function scriptedTeacher(steps: (calls: string[][]) => FauxResponseStep[]): {
engine: PiEngine;
calls: string[][];
} {
const calls: string[][] = [];
const faux = createFauxCore({});
faux.setResponses(steps(calls));
return { engine: { model: faux.getModel(), streamFn: faux.streamSimple }, calls };
}
/** Turn 1: a bit of reasoning, then the quiz tool call. Turn 2: plain text. */
function scriptedModel(calls: string[][]) {
let turn = 0;
return new MockLanguageModelV4({
doStream: async (options) => {
const promptTexts = options.prompt.map((m) => JSON.stringify(m.content));
calls.push(promptTexts);
turn++;
const chunks: LanguageModelV4StreamPart[] =
turn === 1
? [
{ type: "stream-start", warnings: [] },
{ type: "reasoning-start", id: "r1" },
{ type: "reasoning-delta", id: "r1", delta: "Probe the basics first." },
{ type: "reasoning-end", id: "r1" },
// Same shape real providers use: input streams as deltas,
// then the completed call. (This is what makes the client's
// 'Writing a question…' indicator possible.)
{ type: "tool-input-start", id: "call-1", toolName: "quiz" },
{ type: "tool-input-delta", id: "call-1", delta: JSON.stringify(QUIZ_INPUT) },
{ type: "tool-input-end", id: "call-1" },
{
type: "tool-call",
toolCallId: "call-1",
toolName: "quiz",
input: JSON.stringify(QUIZ_INPUT),
},
{ type: "finish", finishReason: { unified: "tool-calls", raw: "tool_calls" }, usage: usage() },
]
: [
{ type: "stream-start", warnings: [] },
{ type: "text-start", id: "t1" },
{ type: "text-delta", id: "t1", delta: "Right — Mercury it is." },
{ type: "text-end", id: "t1" },
{ type: "finish", finishReason: { unified: "stop", raw: "stop" }, usage: usage() },
];
return { stream: simulateReadableStream({ chunks, initialDelayInMs: 0, chunkDelayInMs: 0 }) };
/** The default two-turn lesson: probe quiz, then react to the answer. */
function defaultLesson(calls: string[][]): FauxResponseStep[] {
return [
(context) => {
calls.push(context.messages.map((m) => JSON.stringify((m as { content: unknown }).content)));
return fauxAssistantMessage(
[fauxThinking("Probe the basics first."), fauxToolCall("quiz", QUIZ_INPUT, { id: "call-1" })],
{ stopReason: "toolUse" },
);
},
});
(context) => {
calls.push(context.messages.map((m) => JSON.stringify((m as { content: unknown }).content)));
return fauxAssistantMessage([fauxText("Right — Mercury it is.")]);
},
];
}
/** The handler seam (stateless per turn): each request builds a fresh agent
* off the transcript it is about to run — the factory tests inject. */
function agentFactory(engine: PiEngine) {
return (outgoing: UIMessage[]): TeachingAgent =>
createTeacherAgent(undefined, { engine, messages: recordToPiMessages(outgoing) });
}
async function drain(res: Response): Promise<string> {
@@ -112,14 +111,13 @@ after(() => {
describe("teacher chat flow (scripted model)", () => {
it("quiz tool call persists as a pending part; the answer continues the lesson", async () => {
const calls: string[][] = [];
const agent = createTeacherAgent(undefined, scriptedModel(calls));
const { engine, calls } = scriptedTeacher(defaultLesson);
const chat = chatStore.createChat("Test: solar system");
createdChats.push(chat.id);
// ── Turn 1: learner opens with a topic; teacher probes with a quiz ──
const firstUser = { id: "u1", role: "user", parts: [{ type: "text", text: "teach me the solar system" }] };
await drain(await handleChatRequest(post(chat.id, [firstUser]), agent));
await drain(await handleChatRequest(post(chat.id, [firstUser]), agentFactory(engine)));
const saved1 = chatStore.getChat(chat.id)!;
assert.equal(saved1.messages.length, 2, "user message + assistant response persisted");
@@ -128,12 +126,12 @@ describe("teacher chat flow (scripted model)", () => {
const reasoningPart = assistantMsg.parts.find((p) => p.type === "reasoning");
assert.ok(reasoningPart && reasoningPart.type === "reasoning", "reasoning part streamed + persisted");
assert.match(reasoningPart.text, /Probe the basics/);
assert.match((reasoningPart as { text?: string }).text!, /Probe the basics/);
const quizPart = assistantMsg.parts.find((p) => p.type === "tool-quiz");
assert.ok(quizPart && quizPart.type === "tool-quiz", "quiz tool part persisted");
assert.equal(quizPart.state, "input-available", "unanswered quiz = pending state (resume record)");
assert.deepEqual(quizPart.input, QUIZ_INPUT);
assert.equal((quizPart as { state?: string }).state, "input-available", "unanswered quiz = pending state (resume record)");
assert.deepEqual((quizPart as { input?: unknown }).input, QUIZ_INPUT);
// The pending-question gate sees it.
const pending = findPendingToolCall(saved1.messages);
@@ -166,7 +164,7 @@ describe("teacher chat flow (scripted model)", () => {
}
: m,
);
await drain(await handleChatRequest(post(chat.id, messages2), agent));
await drain(await handleChatRequest(post(chat.id, messages2), agentFactory(engine)));
// ── The teacher reacts — as a CONTINUATION of the same assistant message
// (the last client message is the assistant message carrying the tool
@@ -176,14 +174,14 @@ describe("teacher chat flow (scripted model)", () => {
const finalParts = saved2.messages[1]!.parts;
const textPart = finalParts.find((p) => p.type === "text");
assert.ok(textPart && textPart.type === "text", "reaction text appended after the tool part");
assert.match(textPart.text, /Mercury it is/);
assert.match((textPart as { text?: string }).text!, /Mercury it is/);
// The answered quiz part is still there, now with its output.
const answeredQuiz = finalParts.find((p) => p.type === "tool-quiz");
assert.ok(answeredQuiz && answeredQuiz.type === "tool-quiz" && answeredQuiz.state === "output-available");
assert.ok(answeredQuiz && answeredQuiz.type === "tool-quiz" && (answeredQuiz as { state?: string }).state === "output-available");
// The model actually received the graded answer as a tool result.
assert.equal(calls.length, 2, "model saw exactly two prompts");
const toolResultSent = calls[1]!.some((c) => c.includes("tool-result") || c.includes("Mercury"));
const toolResultSent = calls[1]!.some((c) => c.includes("toolResult") || c.includes("Mercury"));
assert.ok(toolResultSent, "continuation prompt carries the tool result to the model");
// No pending question remains.
@@ -200,11 +198,11 @@ describe("teacher chat flow (scripted model)", () => {
// 'input-streaming', which the stream processor sets from the
// tool-input-start chunk. If a transport change ever stopped emitting
// it, the indicator would silently die — pin the protocol.
const agent = createTeacherAgent(undefined, scriptedModel([]));
const { engine } = scriptedTeacher(defaultLesson);
const chat = chatStore.createChat("Test: protocol pin");
createdChats.push(chat.id);
const firstUser = { id: "u1", role: "user", parts: [{ type: "text", text: "quiz me" }] };
const body = await drain(await handleChatRequest(post(chat.id, [firstUser]), agent));
const body = await drain(await handleChatRequest(post(chat.id, [firstUser]), agentFactory(engine)));
const startIdx = body.indexOf('"type":"tool-input-start"');
const availIdx = body.indexOf('"type":"tool-input-available"');
assert.ok(startIdx !== -1, "tool-input-start chunk present in the stream");
@@ -213,20 +211,19 @@ describe("teacher chat flow (scripted model)", () => {
});
it("persists even when the stream is aborted (resume keeps partial state)", async () => {
const calls: string[][] = [];
const agent = createTeacherAgent(undefined, scriptedModel(calls));
const { engine } = scriptedTeacher(defaultLesson);
const chat = chatStore.createChat("Test: abort");
createdChats.push(chat.id);
const req = post(chat.id, [{ id: "u1", role: "user", parts: [{ type: "text", text: "quiz me" }] }]);
const res = await handleChatRequest(req, agent);
const res = await handleChatRequest(req, agentFactory(engine));
assert.equal(res.status, 200);
// Read one chunk then cancel — simulates the browser closing mid-stream.
const reader = res.body!.getReader();
await reader.read();
await reader.cancel();
// Give onFinish a tick to fire.
await new Promise((r) => setTimeout(r, 50));
// Give the aborted run a tick to settle (agent_end → onFinish).
await new Promise((r) => setTimeout(r, 200));
const saved = chatStore.getChat(chat.id)!;
assert.ok(saved.messages.length >= 1, "partial transcript persisted");
});
@@ -234,8 +231,8 @@ describe("teacher chat flow (scripted model)", () => {
it("an ungradeable quiz is rejected at the call boundary, the error reaches the model, and the model retries", async () => {
// The authoring contract (question-core superRefine): a quiz whose
// correctAnswer matches no option value must NEVER reach the dock —
// the SDK validates client-side tool inputs against the schema, marks
// the call invalid, and returns the validation error to the teacher as
// toPiTool re-runs the zod schema at the call boundary, marks the
// call invalid, and returns the validation error to the teacher as
// a tool result. The loop re-prompts the model, which retries in the
// same turn. This pins that whole mechanism (the live Nietzsche bug).
const BAD_QUIZ_INPUT = {
@@ -245,49 +242,28 @@ describe("teacher chat flow (scripted model)", () => {
explanation: "Genealogy diagnoses, it does not convert.",
};
const calls: string[][] = [];
let step = 0;
const retryModel = new MockLanguageModelV4({
doStream: async (options) => {
calls.push(options.prompt.map((m) => JSON.stringify(m.content)));
step++;
const chunks: LanguageModelV4StreamPart[] =
step === 1
? [
{ type: "stream-start", warnings: [] },
{ type: "tool-input-start", id: "call-bad", toolName: "quiz" },
{ type: "tool-input-delta", id: "call-bad", delta: JSON.stringify(BAD_QUIZ_INPUT) },
{ type: "tool-input-end", id: "call-bad" },
{
type: "tool-call",
toolCallId: "call-bad",
toolName: "quiz",
input: JSON.stringify(BAD_QUIZ_INPUT),
},
{ type: "finish", finishReason: { unified: "tool-calls", raw: "tool_calls" }, usage: usage() },
]
: [
{ type: "stream-start", warnings: [] },
{ type: "tool-input-start", id: "call-good", toolName: "quiz" },
{ type: "tool-input-delta", id: "call-good", delta: JSON.stringify(QUIZ_INPUT) },
{ type: "tool-input-end", id: "call-good" },
{
type: "tool-call",
toolCallId: "call-good",
toolName: "quiz",
input: JSON.stringify(QUIZ_INPUT),
},
{ type: "finish", finishReason: { unified: "tool-calls", raw: "tool_calls" }, usage: usage() },
];
return { stream: simulateReadableStream({ chunks, initialDelayInMs: 0, chunkDelayInMs: 0 }) };
const steps = (calls: string[][]): FauxResponseStep[] => [
(context) => {
calls.push(context.messages.map((m) => JSON.stringify((m as { content: unknown }).content)));
return fauxAssistantMessage(
[fauxToolCall("quiz", BAD_QUIZ_INPUT, { id: "call-bad" })],
{ stopReason: "toolUse" },
);
},
});
(context) => {
calls.push(context.messages.map((m) => JSON.stringify((m as { content: unknown }).content)));
return fauxAssistantMessage(
[fauxToolCall("quiz", QUIZ_INPUT, { id: "call-good" })],
{ stopReason: "toolUse" },
);
},
];
const { engine, calls } = scriptedTeacher(steps);
const agent = createTeacherAgent(undefined, retryModel);
const chat = chatStore.createChat("Test: bad quiz retry");
createdChats.push(chat.id);
const firstUser = { id: "u1", role: "user", parts: [{ type: "text", text: "quiz me" }] };
await drain(await handleChatRequest(post(chat.id, [firstUser]), agent));
await drain(await handleChatRequest(post(chat.id, [firstUser]), agentFactory(engine)));
// The model was re-prompted (the error did not end the turn)…
assert.equal(calls.length, 2, "the invalid call triggered a second model prompt");
@@ -304,8 +280,8 @@ describe("teacher chat flow (scripted model)", () => {
const assistantMsg = saved.messages[1]!;
const quizParts = assistantMsg.parts.filter((p) => p.type === "tool-quiz");
const failed = quizParts.find(
(p) => p.type === "tool-quiz" && (p as { state?: string }).state === "output-error",
);
(p) => p.type === "tool-quiz" && (p as { state?: string }).state === "output-error",
);
assert.ok(failed, "the invalid call persisted as an error-state part");
assert.match(
String((failed as { errorText?: string }).errorText),
@@ -330,36 +306,34 @@ describe("chat store", () => {
describe("mid-turn navigation (turn liveness + write-ahead)", () => {
it("one turn per chat: a concurrent request is 409'd; the next one after finish is accepted", async () => {
const calls: string[][] = [];
const agent = createTeacherAgent(undefined, scriptedModel(calls));
const { engine } = scriptedTeacher(defaultLesson);
const chat = chatStore.createChat("Test: turn guard");
createdChats.push(chat.id);
const firstUser = { id: "u1", role: "user", parts: [{ type: "text", text: "teach me the solar system" }] };
const res1 = await handleChatRequest(post(chat.id, [firstUser]), agent); // NOT drained → in flight
const res1 = await handleChatRequest(post(chat.id, [firstUser]), agentFactory(engine)); // NOT drained → in flight
assert.equal(isTurnRunning(chat.id), true, "turn registered as in flight");
// A stale client (remount racing a live turn) tries to start a second turn.
const res2 = await handleChatRequest(post(chat.id, [firstUser]), agent);
const res2 = await handleChatRequest(post(chat.id, [firstUser]), agentFactory(engine));
assert.equal(res2.status, 409, "concurrent turn rejected — last-writer-wins persist would clobber it");
await drain(res1); // finish → onFinish → endTurn
assert.equal(isTurnRunning(chat.id), false, "registry cleared at finish");
const res3 = await handleChatRequest(post(chat.id, [firstUser]), agent);
const res3 = await handleChatRequest(post(chat.id, [firstUser]), agentFactory(engine));
assert.equal(res3.status, 200, "the next turn is accepted after the previous finished");
await drain(res3);
});
it("write-ahead: the learner's answer is durable BEFORE the turn finishes", async () => {
const calls: string[][] = [];
const agent = createTeacherAgent(undefined, scriptedModel(calls));
const { engine } = scriptedTeacher(defaultLesson);
const chat = chatStore.createChat("Test: write-ahead");
createdChats.push(chat.id);
// Turn 1: the teacher asks the quiz (pending part in the record).
const firstUser = { id: "u1", role: "user", parts: [{ type: "text", text: "teach me the solar system" }] };
await drain(await handleChatRequest(post(chat.id, [firstUser]), agent));
await drain(await handleChatRequest(post(chat.id, [firstUser]), agentFactory(engine)));
const saved1 = chatStore.getChat(chat.id)!;
const assistantMsg = saved1.messages[1]!;
@@ -384,17 +358,17 @@ describe("mid-turn navigation (turn liveness + write-ahead)", () => {
p.type === "tool-quiz" ? { ...p, state: "output-available" as const, output: answer } : p,
),
}
: m,
: m,
);
// NOT drained: the teacher turn is streaming, but the answer is already
// on disk — a remount (or crash) mid-turn sees the answered quiz, never
// the stale pre-answer question.
const res = await handleChatRequest(post(chat.id, messages2), agent);
const res = await handleChatRequest(post(chat.id, messages2), agentFactory(engine));
const midTurn = chatStore.getChat(chat.id)!;
const midQuiz = midTurn.messages[1]!.parts.find((p) => p.type === "tool-quiz");
assert.ok(midQuiz && midQuiz.type === "tool-quiz");
assert.equal(midQuiz.state, "output-available", "write-ahead persisted the answer before finish");
assert.equal((midQuiz as { state?: string }).state, "output-available", "write-ahead persisted the answer before finish");
await drain(res); // finish → persist the full turn → endTurn
const saved2 = chatStore.getChat(chat.id)!;
@@ -405,19 +379,19 @@ describe("mid-turn navigation (turn liveness + write-ahead)", () => {
it("a turn that fails to start (malformed messages) clears the registry — no lockout", async () => {
const chat = chatStore.createChat("Test: turn failed to start");
createdChats.push(chat.id);
// A text part with no text: fails UIMessage validation before the stream
// A text part with no text: fails message validation before the stream
// exists — the path where no onFinish will ever run.
const bad = [{ id: "m1", role: "user", parts: [{ type: "text" }] }];
const res = await handleChatRequest(post(chat.id, bad), createTeacherAgent(undefined, scriptedModel([])));
const res = await handleChatRequest(post(chat.id, bad), agentFactory(scriptedTeacher(defaultLesson).engine));
assert.equal(res.status, 500, "turn-start failure is a 500, not a hang");
assert.equal(isTurnRunning(chat.id), false, "registry cleared when the stream never started");
// And the chat is NOT locked: the next (valid) turn is accepted.
const ok = await handleChatRequest(
post(chat.id, [{ id: "m1", role: "user", parts: [{ type: "text", text: "teach me" }] }]),
createTeacherAgent(undefined, scriptedModel([])),
agentFactory(scriptedTeacher(defaultLesson).engine),
);
assert.equal(ok.status, 200);
await drain(ok);
});
});
});
+46 -79
View File
@@ -27,8 +27,7 @@ import assert from "node:assert/strict";
import * as fs from "node:fs";
import * as path from "node:path";
import { after, describe, it } from "node:test";
import { MockLanguageModelV4, simulateReadableStream } from "ai/test";
import type { LanguageModelV4StreamPart } from "@ai-sdk/provider";
import { createFauxCore, fauxAssistantMessage, fauxToolCall, type FauxResponseStep } from "@earendil-works/pi-ai";
import { renderToStaticMarkup } from "react-dom/server";
import { PATHS } from "../config.ts";
import {
@@ -54,7 +53,10 @@ import { createTeacherTools } from "../src/agent/tools.ts";
import { chatStore } from "../src/store/chats.ts";
import { jobStore } from "../src/store/jobs.ts";
import { handleChatRequest } from "../src/server/chat-handler.ts";
import { createTeacherAgent } from "../src/agent/teacher-agent.ts";
import { createTeacherAgent, type TeachingAgent } from "../src/agent/teacher-agent.ts";
import { recordToPiMessages } from "../src/server/pi/record-adapter.ts";
import type { PiEngine } from "../src/agent/models.ts";
import type { UIMessage } from "ai";
/**
* A fake deliver seam + a runner wired like the chat-handler's, per test:
@@ -104,37 +106,22 @@ after(() => {
visualModelOverride.current = undefined;
});
function usage() {
return {
inputTokens: { total: 10, noCache: 10, cacheRead: 0, cacheWrite: 0 },
outputTokens: { total: 5, text: 5, reasoning: 0 },
};
}
/** A model that answers a scripted sequence of doGenerate turns (tool calls or final text). */
function scriptedModel(turns: Array<{ toolCalls?: Array<{ toolCallId: string; toolName: string; input: unknown }>; text?: string }>): MockLanguageModelV4 {
let turn = -1;
return new MockLanguageModelV4({
doGenerate: async () => {
turn++;
const step = turns[turn];
if (!step) throw new Error(`scripted model exhausted after ${turns.length} turns`);
if (step.toolCalls) {
return {
content: step.toolCalls.map((c) => ({ type: "tool-call" as const, toolCallId: c.toolCallId, toolName: c.toolName, input: JSON.stringify(c.input) })),
finishReason: { unified: "tool-calls" as const, raw: "tool-calls" },
usage: usage(),
warnings: [],
};
}
return {
content: [{ type: "text" as const, text: step.text ?? "" }],
finishReason: { unified: "stop" as const, raw: "stop" },
usage: usage(),
warnings: [],
};
},
/** A scripted pi engine (faux provider): the response queue replaces the old
* doGenerate turn counter — one queued response per model call (tool-call
* turn or final text), consumed in order. */
function scriptedModel(turns: Array<{ toolCalls?: Array<{ toolCallId: string; toolName: string; input: unknown }>; text?: string }>): PiEngine {
const steps: FauxResponseStep[] = turns.map((step) => {
if (step.toolCalls) {
return fauxAssistantMessage(
step.toolCalls.map((c) => fauxToolCall(c.toolName, c.input as Record<string, unknown>, { id: c.toolCallId })),
{ stopReason: "toolUse" },
);
}
return fauxAssistantMessage(step.text ?? "");
});
const faux = createFauxCore({ tokenSize: { min: 64, max: 64 } });
faux.setResponses(steps);
return { model: faux.getModel(), streamFn: faux.streamSimple };
}
// ─── fake headless ───────────────────────────────────────────────────────────
@@ -424,7 +411,7 @@ describe("demo builder (fake model + fake headless)", () => {
const result = await runDemoBuilder(
{ title: "Damped oscillator", brief: "A mass on a spring with a damping slider." },
{ model, headless, onActivity: (line) => activities.push(line) },
{ engine: model, headless, onActivity: (line) => activities.push(line) },
);
assert.equal(result.title, "Damped oscillator");
@@ -442,7 +429,7 @@ describe("demo builder (fake model + fake headless)", () => {
{ toolCalls: [{ toolCallId: "w1", toolName: "write_demo", input: { html: GOOD_HTML } }] },
{ text: "Done." },
]);
const result = await runDemoBuilder({ title: "T", brief: "b" }, { model, headless });
const result = await runDemoBuilder({ title: "T", brief: "b" }, { engine: model, headless });
assert.equal(result.html, GOOD_HTML);
assert.equal(result.tested, true, "tested by the final verification gate");
assert.equal(headless.demoRuns.length, 1, "the gate ran the demo once itself");
@@ -454,7 +441,7 @@ describe("demo builder (fake model + fake headless)", () => {
{ toolCalls: [{ toolCallId: "w1", toolName: "write_demo", input: { html: BROKEN_HTML } }] },
{ text: "It works, trust me." },
]);
const result = await runDemoBuilder({ title: "T", brief: "b" }, { model, headless });
const result = await runDemoBuilder({ title: "T", brief: "b" }, { engine: model, headless });
assert.equal(result.html, "");
assert.equal(result.tested, true, "the loop ran");
assert.match(result.notes ?? "", /^FAILED:/);
@@ -463,14 +450,14 @@ describe("demo builder (fake model + fake headless)", () => {
it("fails fast with a clear message when no Chrome is available (never skips testing)", async () => {
const model = scriptedModel([{ text: "should never be reached" }]);
const result = await runDemoBuilder({ title: "T", brief: "b" }, { model, headless: noChromeHeadless() });
const result = await runDemoBuilder({ title: "T", brief: "b" }, { engine: model, headless: noChromeHeadless() });
assert.equal(result.html, "");
assert.match(result.notes ?? "", /^FAILED: .*Chrome/);
});
it("fails when the model never writes a demo", async () => {
const model = scriptedModel([{ text: "I cannot build this." }]);
const result = await runDemoBuilder({ title: "T", brief: "b" }, { model, headless: fakeHeadless() });
const result = await runDemoBuilder({ title: "T", brief: "b" }, { engine: model, headless: fakeHeadless() });
assert.equal(result.html, "");
assert.match(result.notes ?? "", /^FAILED: the builder never wrote a demo/);
});
@@ -489,7 +476,7 @@ describe("visual maker (fake model + fake headless)", () => {
{ text: "A number line from -3 to 3 with the root marked at 0." },
]);
const activities: string[] = [];
const result = await runVisualMaker({ brief: "Number line, root at 0." }, { model, headless, onActivity: (l) => activities.push(l) });
const result = await runVisualMaker({ brief: "Number line, root at 0." }, { engine: model, headless, onActivity: (l) => activities.push(l) });
assert.equal(result.svg, SVG_1);
assert.equal(result.notes, "A number line from -3 to 3 with the root marked at 0.");
@@ -504,7 +491,7 @@ describe("visual maker (fake model + fake headless)", () => {
{ text: "Done." }, // premature finish — no render yet
{ text: "Looked at it — correct." }, // after the forced look
]);
const result = await runVisualMaker({ brief: "b" }, { model, headless });
const result = await runVisualMaker({ brief: "b" }, { engine: model, headless });
assert.equal(result.svg, SVG_1, "delivered after the forced look");
assert.equal(result.notes, "Looked at it — correct.");
assert.equal(headless.svgRenders.length, 1, "the gate rendered the svg once itself");
@@ -522,7 +509,7 @@ describe("visual maker (fake model + fake headless)", () => {
{ toolCalls: [{ toolCallId: "r2", toolName: "render_svg", input: {} }] },
{ text: "Moved the start of the line." },
]);
const result = await runVisualMaker({ brief: "b" }, { model, headless });
const result = await runVisualMaker({ brief: "b" }, { engine: model, headless });
assert.equal(result.svg, SVG_1.replace('<line x1="10"', '<line x1="20"'), "the edit was applied to the delivered svg");
assert.equal(result.notes, "Moved the start of the line.");
@@ -536,7 +523,7 @@ describe("visual maker (fake model + fake headless)", () => {
{ toolCalls: [{ toolCallId: "e0", toolName: "edit_svg", input: { old_text: "svg", new_text: "x" } }] },
{ text: "Cannot proceed." },
]);
const result = await runVisualMaker({ brief: "b" }, { model, headless });
const result = await runVisualMaker({ brief: "b" }, { engine: model, headless });
assert.equal(result.svg, "", "the failed edit produced no deliverable");
assert.match(result.notes ?? "", /never wrote an SVG/);
});
@@ -550,20 +537,20 @@ describe("visual maker (fake model + fake headless)", () => {
{ toolCalls: [{ toolCallId: "r2", toolName: "render_svg", input: {} }] },
{ text: "Fixed and verified." },
]);
const result = await runVisualMaker({ brief: "b" }, { model, headless });
const result = await runVisualMaker({ brief: "b" }, { engine: model, headless });
assert.equal(result.svg, SVG_1);
assert.equal(result.notes, "Fixed and verified.");
});
it("fails when the model never writes an svg", async () => {
const model = scriptedModel([{ text: "This idea is purely relational — belongs in mermaid." }]);
const result = await runVisualMaker({ brief: "b" }, { model, headless: fakeHeadless() });
const result = await runVisualMaker({ brief: "b" }, { engine: model, headless: fakeHeadless() });
assert.equal(result.svg, "");
assert.match(result.notes ?? "", /^FAILED: the maker never wrote an SVG/);
});
it("fails with a clear message when no Chrome is available", async () => {
const result = await runVisualMaker({ brief: "b" }, { model: scriptedModel([{ text: "x" }]), headless: noChromeHeadless() });
const result = await runVisualMaker({ brief: "b" }, { engine: scriptedModel([{ text: "x" }]), headless: noChromeHeadless() });
assert.equal(result.svg, "");
assert.match(result.notes ?? "", /^FAILED: .*Chrome/);
});
@@ -685,30 +672,10 @@ describe("demo/visual job runners", () => {
createdChats.push(chat.id);
// A teacher that calls demo on turn 1, then continues in the SAME turn.
let turn = 0;
const teacher = new MockLanguageModelV4({
doStream: async () => {
turn++;
const chunks: LanguageModelV4StreamPart[] =
turn === 1
? [
{ type: "stream-start", warnings: [] },
{ type: "tool-input-start", id: "call-d2", toolName: "demo" },
{ type: "tool-input-delta", id: "call-d2", delta: JSON.stringify(DEMO_INPUT) },
{ type: "tool-input-end", id: "call-d2" },
{ type: "tool-call", toolCallId: "call-d2", toolName: "demo", input: JSON.stringify(DEMO_INPUT) },
{ type: "finish", finishReason: { unified: "tool-calls", raw: "tool_calls" }, usage: usage() },
]
: [
{ type: "stream-start", warnings: [] },
{ type: "text-start", id: "t2" },
{ type: "text-delta", id: "t2", delta: "Here it is." },
{ type: "text-end", id: "t2" },
{ type: "finish", finishReason: { unified: "stop", raw: "stop" }, usage: usage() },
];
return { stream: simulateReadableStream({ chunks, initialDelayInMs: 0, chunkDelayInMs: 0 }) };
},
});
const teacher = scriptedModel([
{ toolCalls: [{ toolCallId: "call-d2", toolName: "demo", input: DEMO_INPUT }] },
{ text: "Here it is." },
]);
// The overrides must be live BEFORE the request — dispatch fires the
// runner mid-stream, inside the tool execute.
@@ -731,17 +698,17 @@ describe("demo/visual job runners", () => {
messages: [{ id: "u1", role: "user", parts: [{ type: "text", text: "teach me compounding" }] }],
}),
}),
createTeacherAgent(
undefined,
teacher,
undefined,
createTeacherTools({
chatId: chat.id,
chatStore,
jobStore,
spawn: (job, input) => void runner.runJob(job.id, input),
(outgoing: UIMessage[]): TeachingAgent =>
createTeacherAgent(undefined, {
engine: teacher,
messages: recordToPiMessages(outgoing),
tools: createTeacherTools({
chatId: chat.id,
chatStore,
jobStore,
spawn: (job, input) => void runner.runJob(job.id, input),
}),
}),
),
),
);
+63 -86
View File
@@ -5,7 +5,7 @@
*
* transcriptView — the compact view both LLM stages see (pure):
* message-id-tagged verbatim text + quiz lines, NO tree
* pipeline — MockLanguageModelV4 drives both agents: findings out,
* pipeline — scripted faux engines drive both agents: findings out,
* audit verdict (with linkTo identity) in, deterministic
* merge applied; failure paths checkpoint nothing;
* single-flight per chat
@@ -22,8 +22,9 @@
import assert from "node:assert/strict";
import * as fs from "node:fs";
import { after, describe, it } from "node:test";
import { MockLanguageModelV4 } from "ai/test";
import { createFauxCore, fauxAssistantMessage, fauxToolCall, type FauxResponseStep } from "@earendil-works/pi-ai";
import type { UIMessage } from "ai";
import type { PiEngine } from "../src/agent/models.ts";
import type {
AuditResult,
GraphEdge,
@@ -42,11 +43,14 @@ import { PATHS } from "../config.ts";
const createdChats: string[] = [];
function usage() {
return {
inputTokens: { total: 10, noCache: 10, cacheRead: 0, cacheWrite: 0 },
outputTokens: { total: 5, text: 5, reasoning: 0 },
};
/** A scripted pi engine (faux provider, docs/pi-engine-swap.md §7): the
* response queue replaces the old doGenerate turn counter. Usage is
* ESTIMATED by faux from the prompt/response text (deterministic, > 0) —
* the tests assert structure and positivity, not flat mock numbers. */
function fauxEngine(steps: FauxResponseStep[]): PiEngine {
const faux = createFauxCore({});
faux.setResponses(steps);
return { model: faux.getModel(), streamFn: faux.streamSimple };
}
/** An in-memory GraphStore (core/ports.ts) — this suite never touches data/graph. */
@@ -163,68 +167,46 @@ const FINDINGS: SessionFindings = {
attribution: { quiz: { "probe-1": "p1", "check-1": "p2" } },
};
/** A model whose ONE tool call is `name` with the given input; then text.
* `onCall` (optional) fires at the moment of the first generate — the
* observation point for mid-run assertions. */
function submitModel(name: string, input: unknown, onCall?: () => void): MockLanguageModelV4 {
let turn = 0;
return new MockLanguageModelV4({
doGenerate: async () => {
turn++;
if (turn === 1) {
onCall?.();
return {
content: [
{
type: "tool-call" as const,
toolCallId: `${name}-call`,
toolName: name,
input: JSON.stringify(input),
},
],
finishReason: { unified: "tool-calls" as const, raw: "tool-calls" },
usage: usage(),
warnings: [],
};
}
return {
content: [{ type: "text" as const, text: "submitted" }],
finishReason: { unified: "stop" as const, raw: "stop" },
usage: usage(),
warnings: [],
};
/** An engine whose ONE response is the `name` tool call with the given
* input — the whole deliverable; the pipeline stops after a clean submit
* (shouldStop), so no trailing text step exists anymore. `onCall` fires at
* the moment of the model call — the observation point for mid-run
* assertions. */
function submitModel(name: string, input: unknown, onCall?: () => void): PiEngine {
return fauxEngine([
(context) => {
onCall?.();
void context;
return fauxAssistantMessage(
[fauxToolCall(name, input as Record<string, unknown>, { id: `${name}-call` })],
{ stopReason: "toolUse" },
);
},
});
]);
}
/** A model that hangs until its abort signal fires, then rejects —
* mimicking a cancelled provider request (the abort path). Like a real
* provider, an ALREADY-aborted signal rejects immediately (the abort
* can win the race before doGenerate attaches its listener). */
function hangingModel(): MockLanguageModelV4 {
const abortReject = (reject: (reason: Error) => void) =>
reject(Object.assign(new Error("The operation was aborted"), { name: "AbortError" }));
return new MockLanguageModelV4({
doGenerate: async (options): Promise<never> => {
await new Promise<never>((_, reject) => {
if (options.abortSignal?.aborted) abortReject(reject);
else options.abortSignal?.addEventListener("abort", () => abortReject(reject));
});
throw new Error("unreachable");
},
});
/** An engine whose model call never resolves until its abort signal fires
* — mimicking a cancelled provider request (the abort path). Like a real
* provider, an ALREADY-aborted signal resolves immediately (the abort can
* win the race before the call attaches its listener); the faux stream
* then sees the aborted signal and ends the message as aborted. */
function hangingModel(): PiEngine {
return fauxEngine([
(_context, options) =>
new Promise((resolve) => {
const sig = options?.signal;
if (sig?.aborted) {
resolve(fauxAssistantMessage("late"));
return;
}
sig?.addEventListener("abort", () => resolve(fauxAssistantMessage("late")), { once: true });
}),
]);
}
/** A model that never calls a tool (the failure path). */
function silentModel(): MockLanguageModelV4 {
return new MockLanguageModelV4({
doGenerate: async () => ({
content: [{ type: "text" as const, text: "I refuse to use tools" }],
finishReason: { unified: "stop" as const, raw: "stop" },
usage: usage(),
warnings: [],
}),
});
/** An engine that never calls a tool (the failure path). */
function silentModel(): PiEngine {
return fauxEngine([fauxAssistantMessage("I refuse to use tools"), fauxAssistantMessage("…")]);
}
// ─── transcriptView (pure) ───────────────────────────────────────────────────
@@ -427,9 +409,11 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => {
});
await pipeline.runExtraction(chatId);
// Live: mid-audit the extractor's usage was fully accumulated
// (the mock bills 10 input / 5 output per step, flat per the SDK).
assert.deepEqual(usageSeen, [10]);
// Live: mid-audit the extractor's usage was fully accumulated (faux
// estimates tokens from the prompt/response text — deterministic,
// positive, no cache).
assert.equal(usageSeen.length, 1);
assert.ok(typeof usageSeen[0] === "number" && usageSeen[0]! > 0, "the extractor's step billed input tokens");
// Persisted: the run appended ONE usage entry to the chat's
// extractions log — per-stage model + steps, with merge stats.
@@ -438,16 +422,14 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => {
const run = record.extractions![0]!;
assert.equal(run.stats?.nodes, 0); // linked, not created
assert.equal(run.stats?.events, 3);
assert.equal(run.stages.extractor.model, "mock-model-id"); // the mock's model id
assert.equal(run.stages.extractor.steps.length, 2); // tool-call step + text step
assert.deepEqual(run.stages.extractor.steps[0], {
inputTokens: 10,
outputTokens: 5,
cacheReadTokens: 0,
cacheWriteTokens: 0,
reasoningTokens: 0,
});
assert.equal(run.stages.auditor.steps.length, 2);
assert.equal(run.stages.extractor.model, "faux-1"); // the scripted engine's model id
assert.equal(run.stages.extractor.steps.length, 1); // the submit call — the run STOPS after a clean submit
const step = run.stages.extractor.steps[0]!;
assert.ok(step.inputTokens! > 0 && step.outputTokens! > 0, "estimated usage captured");
assert.equal(step.cacheReadTokens, 0);
assert.equal(step.cacheWriteTokens, 0);
assert.equal(step.reasoningTokens, 0);
assert.equal(run.stages.auditor.steps.length, 1);
assert.ok(!Number.isNaN(Date.parse(run.at)));
});
@@ -497,18 +479,13 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => {
release = resolve;
});
let calls = 0;
const gatedExtractor = new MockLanguageModelV4({
doGenerate: async () => {
const gatedExtractor = fauxEngine([
async () => {
calls++;
if (calls === 1) await gate;
return {
content: [{ type: "text" as const, text: "…" }],
finishReason: { unified: "stop" as const, raw: "stop" },
usage: usage(),
warnings: [],
};
return fauxAssistantMessage("…");
},
});
]);
const pipeline = createExtractionPipeline({
chatStore,
+80 -139
View File
@@ -28,8 +28,7 @@ import * as fs from "node:fs";
import * as path from "node:path";
import { after, describe, it } from "node:test";
import type { UIMessage } from "ai";
import { MockLanguageModelV4, simulateReadableStream } from "ai/test";
import type { LanguageModelV4StreamPart } from "@ai-sdk/provider";
import { createFauxCore, fauxAssistantMessage, fauxText, fauxToolCall, type AssistantMessage, type FauxResponseStep } from "@earendil-works/pi-ai";
import {
accumulateJobUsage,
buildAbortSteer,
@@ -45,16 +44,18 @@ import {
buildProbeHandoffSteer,
type ProbeHandoffSteerMessage,
} from "../src/core/contracts.ts";
import { composeInstructions, createTeacherAgent } from "../src/agent/teacher-agent.ts";
import { composeInstructions, createTeacherAgent, type TeachingAgent } from "../src/agent/teacher-agent.ts";
import { createTeacherTools } from "../src/agent/tools.ts";
import { webTools } from "../src/agent/tools/web.ts";
import { handleChatRequest } from "../src/server/chat-handler.ts";
import { recordToPiMessages } from "../src/server/pi/record-adapter.ts";
import { abortJob, claimSteers, deliverJobResult, setTurnRunner } from "../src/server/jobs.ts";
import { beginTurn, endTurn } from "../src/server/turn-liveness.ts";
import { persistTurn, withMessageIds } from "../src/server/turn-service.ts";
import { chatStore } from "../src/store/chats.ts";
import { jobStore } from "../src/store/jobs.ts";
import { createJobRunner, researchModelOverride } from "../src/agent/subagents/runner.ts";
import { createResearcherAgent } from "../src/agent/subagents/researcher.ts";
import type { PiEngine } from "../src/agent/models.ts";
import { PATHS } from "../config.ts";
const JOBS_DIR = path.join(PATHS.data, "jobs");
@@ -88,11 +89,30 @@ after(() => {
researchModelOverride.current = undefined;
});
function usage() {
return {
inputTokens: { total: 10, noCache: 10, cacheRead: 0, cacheWrite: 0 },
outputTokens: { total: 5, text: 5, reasoning: 0 },
};
/** A scripted pi engine (faux provider): the response queue replaces the
* old doStream/doGenerate turn counters. tokenSize is set so every test
* string streams as ONE delta — the SSE-body assertions ("Back with the
* brief.") read contiguous text. */
function fauxEngine(steps: FauxResponseStep[]): PiEngine {
const faux = createFauxCore({ tokenSize: { min: 64, max: 64 } });
faux.setResponses(steps);
return { model: faux.getModel(), streamFn: faux.streamSimple };
}
/** A scripted response factory whose model call never resolves until its
* abort signal fires (a cancelled provider request); an already-aborted
* signal resolves at once — the faux stream then ends the message aborted. */
function abortableFactory(): FauxResponseStep {
const factory = (_context: unknown, options: { signal?: AbortSignal } | undefined) =>
new Promise<AssistantMessage>((resolve) => {
const sig = options?.signal;
if (sig?.aborted) {
resolve(fauxAssistantMessage("late"));
return;
}
sig?.addEventListener("abort", () => resolve(fauxAssistantMessage("late")), { once: true });
});
return factory as unknown as FauxResponseStep;
}
async function drain(res: Response): Promise<string> {
@@ -295,34 +315,14 @@ const RESEARCH_INPUT = {
};
/** A teacher that calls research (text → call), then plain text thereafter. */
function researchCallingTeacher(): MockLanguageModelV4 {
let turn = 0;
return new MockLanguageModelV4({
doStream: async () => {
turn++;
const chunks: LanguageModelV4StreamPart[] =
turn === 1
? [
{ type: "stream-start", warnings: [] },
{ type: "text-start", id: "t1" },
{ type: "text-delta", id: "t1", delta: "Let me check the current state of the library." },
{ type: "text-end", id: "t1" },
{ type: "tool-input-start", id: "call-r1", toolName: "research" },
{ type: "tool-input-delta", id: "call-r1", delta: JSON.stringify(RESEARCH_INPUT) },
{ type: "tool-input-end", id: "call-r1" },
{ type: "tool-call", toolCallId: "call-r1", toolName: "research", input: JSON.stringify(RESEARCH_INPUT) },
{ type: "finish", finishReason: { unified: "tool-calls", raw: "tool_calls" }, usage: usage() },
]
: [
{ type: "stream-start", warnings: [] },
{ type: "text-start", id: "t2" },
{ type: "text-delta", id: "t2", delta: "Back with the brief." },
{ type: "text-end", id: "t2" },
{ type: "finish", finishReason: { unified: "stop", raw: "stop" }, usage: usage() },
];
return { stream: simulateReadableStream({ chunks, initialDelayInMs: 0, chunkDelayInMs: 0 }) };
},
});
function researchCallingTeacher(): PiEngine {
return fauxEngine([
fauxAssistantMessage(
[fauxText("Let me check the current state of the library."), fauxToolCall("research", RESEARCH_INPUT, { id: "call-r1" })],
{ stopReason: "toolUse" },
),
fauxAssistantMessage([fauxText("Back with the brief.")]),
]);
}
describe("dispatch (async protocol §2)", () => {
@@ -332,17 +332,15 @@ describe("dispatch (async protocol §2)", () => {
const delivery = fakeDeliver();
const testRunner = createJobRunner({ chatStore, jobStore, deliver: delivery.deliver });
const agent = createTeacherAgent(
undefined,
researchCallingTeacher(),
undefined,
createTeacherTools({
chatId: chat.id,
chatStore,
jobStore,
spawn: (job, input) => void testRunner.runJob(job.id, input),
}),
);
const engine = researchCallingTeacher();
const tools = createTeacherTools({
chatId: chat.id,
chatStore,
jobStore,
spawn: (job, input) => void testRunner.runJob(job.id, input),
});
const agent = (outgoing: UIMessage[]): TeachingAgent =>
createTeacherAgent(undefined, { engine, tools, messages: recordToPiMessages(outgoing) });
const restoreFetch = stubFetch();
researchModelOverride.current = researcherModel();
@@ -394,38 +392,15 @@ sigma.js v3 is current.
## Sources
- sigma.js docs (https://www.sigmajs.org/docs)`;
/**
* The researcher loop runs via agent.generate (non-streaming) — the mock
* therefore scripts doGenerate. Turn 1: a web_search tool call; turn 2: the
* final text brief.
*/
function researcherModel(): MockLanguageModelV4 {
let turn = 0;
return new MockLanguageModelV4({
doGenerate: async () => {
turn++;
return turn === 1
? {
content: [
{
type: "tool-call" as const,
toolCallId: "ws1",
toolName: "web_search",
input: JSON.stringify({ query: "sigma.js latest version" }),
},
],
finishReason: { unified: "tool-calls" as const, raw: "tool-calls" },
usage: usage(),
warnings: [],
}
: {
content: [{ type: "text" as const, text: BRIEF }],
finishReason: { unified: "stop" as const, raw: "stop" },
usage: usage(),
warnings: [],
};
},
});
/** The researcher loop's scripted engine. Turn 1: a web_search tool call;
* turn 2: the final text brief. */
function researcherModel(): PiEngine {
return fauxEngine([
fauxAssistantMessage([fauxToolCall("web_search", { query: "sigma.js latest version" }, { id: "ws1" })], {
stopReason: "toolUse",
}),
fauxAssistantMessage([fauxText(BRIEF)]),
]);
}
function stubFetch() {
@@ -516,11 +491,11 @@ describe("research runner (async protocol §3)", () => {
const delivery = fakeDeliver();
const runner = createJobRunner({ chatStore, jobStore, deliver: delivery.deliver });
researchModelOverride.current = new MockLanguageModelV4({
doGenerate: async () => {
researchModelOverride.current = fauxEngine([
() => {
throw new Error("model exploded");
},
});
]);
try {
await runner.runJob(job.id);
} finally {
@@ -578,11 +553,12 @@ describe("research runner (async protocol §3)", () => {
// ─── the researcher agent construction (model resolution seam) ──────────────
describe("researcher agent", () => {
it("builds with the web tool set (no network touched)", () => {
const agent = createResearcherAgent(researcherModel());
assert.ok(agent.tools.web_search, "web_search available");
assert.ok(agent.tools.web_fetch, "web_fetch available");
assert.equal(Object.keys(agent.tools).length, 2, "ONLY the web tools — no question/plan tools");
it("runs with the web tool set (no network touched)", () => {
// The researcher is a runSubagent call over the webTools registry —
// the tool set is what the loop sees.
assert.ok(webTools.web_search, "web_search available");
assert.ok(webTools.web_fetch, "web_fetch available");
assert.equal(Object.keys(webTools).length, 2, "ONLY the web tools — no question/plan tools");
// reset any accidental global override
researchModelOverride.current = undefined;
});
@@ -950,57 +926,20 @@ describe("prompt composition", () => {
// ─── abort (2026-09 semantics: real kill, cancelled status, teacher told) ───
describe("abort (src/server/jobs.ts abortJob)", () => {
/** A hanging model that only settles when its abort signal fires — the
* real-kill path: the in-flight call itself is killed. */
function hangingModel(): MockLanguageModelV4 {
return new MockLanguageModelV4({
doGenerate: ({ abortSignal }) =>
new Promise((_resolve, reject) => {
// an ALREADY-aborted signal never fires the event — real
// providers reject an aborted fetch at once, the mock must too
if (abortSignal?.aborted) {
reject(new DOMException("The operation was aborted", "AbortError"));
return;
}
abortSignal?.addEventListener("abort", () => {
reject(new DOMException("The operation was aborted", "AbortError"));
});
}),
});
/** A hanging model whose call never resolves until its abort signal
* fires — the real-kill path: the in-flight call itself is killed. */
function hangingModel(): PiEngine {
return fauxEngine([abortableFactory()]);
}
/** One step (with usage) then a hang — proves usage-so-far survives the kill. */
function oneStepThenHangModel(): MockLanguageModelV4 {
let turn = 0;
return new MockLanguageModelV4({
doGenerate: ({ abortSignal }) => {
turn++;
if (turn === 1) {
return Promise.resolve({
content: [
{
type: "tool-call" as const,
toolCallId: "ws1",
toolName: "web_search",
input: JSON.stringify({ query: "anything" }),
},
],
finishReason: { unified: "tool-calls" as const, raw: "tool-calls" },
usage: usage(),
warnings: [],
});
}
return new Promise((_resolve, reject) => {
if (abortSignal?.aborted) {
reject(new DOMException("The operation was aborted", "AbortError"));
return;
}
abortSignal?.addEventListener("abort", () => {
reject(new DOMException("The operation was aborted", "AbortError"));
});
});
},
});
/** One step (billed) then a hang — proves usage-so-far survives the kill. */
function oneStepThenHangModel(): PiEngine {
return fauxEngine([
fauxAssistantMessage([fauxToolCall("web_search", { query: "anything" }, { id: "ws1" })], {
stopReason: "toolUse",
}),
abortableFactory(),
]);
}
function newChatWithResearchJob(label: string, toolCallId: string): { chatId: string; jobId: string } {
@@ -1082,8 +1021,10 @@ describe("abort (src/server/jobs.ts abortJob)", () => {
assert.equal(job.error, "Aborted by learner");
assert.equal(job.usage!.steps, 1, "usage-so-far persisted by the runner's finally");
assert.equal(job.usage!.model, "z-ai/glm-5.3-flash", "the config role's OpenRouter id");
assert.equal(job.usage!.inputTokens, 10);
assert.equal(job.usage!.outputTokens, 5);
// faux estimates tokens from the prompt/response text — the
// pinned fact is that the ONE completed step's spend survived.
assert.ok(job.usage!.inputTokens! > 0, "input tokens billed");
assert.ok(job.usage!.outputTokens! > 0, "output tokens billed");
} finally {
researchModelOverride.current = undefined;
}
+2 -2
View File
@@ -50,10 +50,10 @@ const validQuiz = {
};
interface QuizToolSpec {
inputSchema: { safeParse: (v: unknown) => { success: boolean; error?: { issues: Array<{ message: string }> } } };
schema: { safeParse: (v: unknown) => { success: boolean; error?: { issues: Array<{ message: string }> } } };
}
/** teachQuizToolFor returns the single quiz tool; createProberTools a registry. */
const schemaOf = (toolLike: unknown) => (toolLike as QuizToolSpec).inputSchema;
const schemaOf = (toolLike: unknown) => (toolLike as QuizToolSpec).schema;
const quizSchemaOf = (tools: Record<string, unknown>) => schemaOf(tools.quiz);
describe("teachQuizToolFor: the dynamic plan-nodeId validation (position signal)", () => {