Files
learnharness/src/agent/subagents/pi-run.ts
T
amosblomqvist 876185eb6e chore: pi family 0.85.1 -> 0.87.1 (finishTurn, transcript contexts, typebox 1.3.27)
Mechanical fallout only; semantic deltas flagged in the wave report:
- shouldStopAfterTurn -> finishTurn ({action:'end'} wrap; loop order verified
  equivalent for Wave C stranding)
- transcript rework: seeded leading SystemMessage filtered at both
  state.messages read seams (ui-stream settleWith, pi-run slice); prompt
  and tool declarations now ride the transcript
- streamOnce sites normalizeContext() (Context -> TranscriptContext)
- AgentToolResult details / ToolCall.arguments JsonValue narrowing (casts)
- AUTH_PROVIDER_KEYS regenerated (meta added); glm-5.3-flash price pin
  follows the 0.87.1 catalog (0.15/0.5/0.05)
- vanish probe: 0/12 vs 2/6 baseline; live smoke green on PORT=3004
2026-09-26 21:45:51 +03:00

141 lines
5.2 KiB
TypeScript

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 { piEngineFor, 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 {
// BYOK (owner, 2026-09-22): no factory pins — the override is the only
// real path (the runner injects settings-resolved engines); a missed
// injection hits the honest thrower in models.ts instead of a guess.
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: engine.level,
tools,
messages: opts.messages ?? [],
},
streamFn: engine.streamFn,
// 0.87.1: shouldStopAfterTurn → finishTurn (same call point; "end"
// exits before the steering poll, exactly the old boolean-true).
finishTurn: (turn) => {
turns++;
return turns >= opts.maxTurns || opts.shouldStop?.(turn.message, turn.toolResults) === true
? { action: "end" }
: undefined;
},
});
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);
// 0.87.1: drop the seeded leading SystemMessage (transcript rework —
// see ui-stream's settleWith) before slicing past the input count, or
// the seed displaces the slice and the last INPUT message leaks in.
const newMessages = agent.state.messages
.filter((m) => (m as { role?: string }).role !== "system")
.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 };
}