/** * Pure message/model helpers for the PI agent bridge (spec §2.2), split out from `pi-agent.ts` so they * are unit-testable offline. Every PI reference here is *type-only* (erased at runtime), so importing * this module pulls no PI/host/network code. */ import type { AgentEndEvent, ExtensionContext } from "@earendil-works/pi-coding-agent"; import type { AgentTurnError, AgentTurnErrorKind, ConversationMessage, SubmittedOutput, TokenUsage } from "../engine/types.ts"; /** The PI model registry (from the extension context) — referenced structurally for `find`. */ export type ModelRegistry = ExtensionContext["modelRegistry"]; /** The assistant/user message list carried by an `agent_end` event. */ export type AgentMessages = AgentEndEvent["messages"]; /** Resolve a `provider/modelId` string to a registry model; undefined if there is no slash or no hit. */ export declare function resolveModel(modelRegistry: Pick, modelString: string): ReturnType; /** The last assistant message's concatenated text content (`""` when there is no assistant message). */ export declare function lastAssistantText(messages: AgentMessages): string; /** * The last output-tool call in a conversation, or undefined if there was none. * * Last-write-wins: a model that submits twice has its final submission taken. Scanning backwards is * what makes that free. */ export declare function lastSubmittedOutput(messages: AgentMessages): SubmittedOutput | undefined; /** The last output-tool call within a single assistant message (a turn may carry several tool calls). */ export declare function submittedFromMessage(message: AgentMessages[number]): SubmittedOutput | undefined; /** * The failure the last assistant message records, if it records one. * * PI writes a refused request as an ordinary assistant message: `content: []`, zero usage, * `stopReason: "error"`, and the provider's raw body in `errorMessage`. The turn then ends normally, * which is what makes this worth extracting: nothing downstream can otherwise tell it apart from a model * that said nothing. Both host paths read the harness's own message objects, so one reader serves both. * * `stopReason` is the only signal used. Empty content is NOT one on its own — a turn that ends on a tool * call alone legitimately carries no text. */ export declare function lastAssistantError(messages: AgentMessages): AgentTurnError | undefined; /** {@link lastAssistantError} for a single message — the incremental reader's entry point. */ export declare function errorFromMessage(message: unknown): AgentTurnError | undefined; /** * Which failure a provider's error body describes. * * Matched on text because that is all there is: the body is passed through from whatever gateway served * the request, and none of them agree on a machine-readable code. The patterns cover the phrasings seen * in real transcripts — a named `ContextWindowExceededError` and a plain "the input is longer than the * model's context length". Anything unrecognized stays `provider-error`, the conservative answer: it * retries rather than declaring a run over, which is right for the socket closes, 503s and overload * responses that make up the rest. */ export declare function classifyAgentError(raw: string): AgentTurnErrorKind; /** The last assistant message's token usage (chat-completions `usage.totalTokens`), for token budgeting (spec §9.3). */ export declare function lastAssistantUsage(messages: AgentMessages): TokenUsage | undefined; /** * Parse a `pi --mode json` subprocess's NDJSON stdout into the same assistant/user message list an * interactive `agent_end` event carries (spec §2.2, background subagents — see pi-agent.ts), so * `lastAssistantText`/`lastAssistantUsage` above serve both paths unchanged instead of duplicating their * logic. Pure: no process/host access, so it is unit-testable offline against captured fixture lines — * PI's own `examples/extensions/subagent` reads the identical `{ type: "message_end", message }` shape * off a spawned `pi` process's stdout. Malformed JSON or an unrecognized event type is skipped rather * than failing the whole parse: a real transcript interleaves other event types (`tool_call`, * `turn_start`, …) this reader has no need of. */ export declare function parseNdjsonMessages(ndjson: string): AgentMessages; /** What the streaming reader recovers from a subagent's stdout — the {@link AgentTurn} fields a background step can supply. */ export interface ReadAssistantTurn { text: string; usage?: TokenUsage; submitted?: SubmittedOutput; error?: AgentTurnError; } /** An incremental reader over a subagent's NDJSON stdout — see {@link createAssistantTurnReader}. */ export interface AssistantTurnReader { /** Feed the next decoded stdout chunk. Complete lines are read and dropped; a partial one is held. */ push(chunk: string): void; /** Read whatever partial line is left and return the last assistant turn seen (empty if there was none). */ end(): ReadAssistantTurn; } /** * Read the last assistant turn out of a subagent's NDJSON stdout AS IT ARRIVES, keeping neither the * stream nor the conversation. * * A background step needs exactly two things from a whole subprocess conversation: the final assistant * text and its usage (`getConversation()` returns `[]` for these — a one-shot subagent is never resumed * with seeded history). Both of the obvious ways to get there cost the parent the child's whole output, * and both were measured doing it: * * 1. Parsing every message to take the last one. On the `write-compressor` task that killed its * container three runs running, the parent grew **324MB -> 1.91GB in about two minutes while * reading 367KB**, with the subagent already exited — parsed JS objects run an order of magnitude * or more above the text they came from. * 2. Holding the stdout string whole (what `pi.exec` hands back) and scanning its tail. Cheap in * objects, but the parent still pays one byte for every byte the child ever printed, and a chatty * subagent prints hundreds of MB. * * So this reads forwards, one line at a time, and retains only the most recent assistant `message_end` * it has decoded into `{ text, usage }` — the same two fields the caller will ask for. Steady-state cost * is one message's text plus the partial line straddling the current chunk boundary; the transient peak * adds one line and the object `JSON.parse` makes of it, which is unavoidable for anything that must * read that message at all. Nothing here scales with how long the conversation ran. * * Pure (no process/stream access — the caller owns the pipe), so it is unit-testable offline against * captured fixture lines, chunked at arbitrary boundaries. Malformed JSON and unrecognized event types * are skipped rather than failing the read, exactly as `parseNdjsonMessages` above skips them: a real * transcript interleaves `tool_call`/`turn_start`/… lines this reader has no need of, and a killed child * leaves a truncated final line. */ export declare function createAssistantTurnReader(): AssistantTurnReader; /** * Prepend a resumed session's stored prior conversation onto the messages PI is about to send the * model (spec §8.4) — the pure core of pi-agent.ts's `context`-event history seed. * * PI's `context` extension event is the one documented mechanism for injecting messages into an * outgoing LLM call (`docs/extensions.md`: "Fired before each LLM call... Injecting context from * external sources"; wired straight into `AgentLoopConfig.transformContext` in `core/sdk.ts`, which the * low-level agent loop applies just before `convertToLlm`). That is exactly what a resume needs after * the harness process itself restarted: the fresh process's PI session has none of the pre-restart * turns, so without this the model would see only the bare answer text with no memory of what it asked * or why (spec §8.4's "same agent loop resumes... context intact" would silently not hold). * * Returns `undefined` — a no-op — when there is nothing to seed, so the impure caller can skip * overriding PI's own messages entirely on the far more common fresh/no-history turn, and so a * present-but-empty `history` (never actually produced today, but not ruled out by the type) behaves * identically to an absent one rather than degenerating into "seed with an empty prefix". */ export declare function seedHistory(history: readonly ConversationMessage[] | undefined, messages: AgentMessages): AgentMessages | undefined; //# sourceMappingURL=pi-agent-messages.d.ts.map