Owner decision 2026-09-22: 'it doesn't make sense to have any default
model configured anywhere; the application is bring your own keys.'
config.ts: MODELS deleted; ModelRole becomes an explicit 9-role union;
DEFAULT_THINKING_ORDER (medium, low, high, minimal, xhigh, max) bakes
the unset-level default — first entry the model supports; explicitly
set levels keep pi's native nearest-clamp.
core/settings-resolution: the factory layer dissolves — the chain is
role override → slot → NOTHING. resolveSettings(record) takes no
factory. Empty chains resolve to null/undefined; a thinkingLevel-only
slot is inert (stored, never run). resolveConversationSpec: chat.model
override over the strong slot, null when both empty. resolveRoleSpec
spans all nine roles for the job/extraction engines.
Refusal envelope (core/settings.ts): modelsNotConfigured(missing) —
{error, code: 'models_not_configured', missing: [slot tiers]} +
ModelsNotConfiguredError with .envelope. Per-need gates, each honest:
- chat-handler: universal turn gate (new + continuing turns) returns
503 before beginTurn — no liveness, no write-ahead persist, no graph
trigger before the gate fires.
- runner.runJob: unresolvable engine lands the job as error with the
envelope wording; no phantom loop, no result steer.
- extraction: refuseIfStagesUnconfigured throws before any stage
(turn-service info-skips so coverage retries next turn; the import
route surfaces per-file code+missing); labeler-less runs skip
cluster naming with a console line and still merge.
Write side: slot entries must carry a model — level-only slot writes
are 400s (absent slot key = clear). piEngineForSpec resolves unset
thinking levels via DEFAULT_THINKING_ORDER; piEngineFor now throws
honestly; thinkingLevelFor/modelIdFor deleted; teacher-agent, pi-run,
visual-maker, pdf-reader all read engine.level.
model-catalog: defaults/slots/rolePins null when the chain is empty
(contracts nullable) — no phantom ids in payloads. Stores drop
MODEL_PINS entirely.
Tests: pin-leaners converted to explicit injection (per-chat model
pins on scripted chats — exactly what the setup flow writes; faux
engines for agent-construction tests). New coverage: the 503 gate
envelope, the runner refusal, the extraction refusal + labeler skip.
Gates: typecheck, lint, 815/815, build.
133 lines
4.8 KiB
TypeScript
133 lines
4.8 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 { openRouterApiKey, 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,
|
|
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 };
|
|
} |