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:
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user