provider-events: raw provider stream recorder (LH_PROVIDER_EVENTS=1)

pi-ai 1.0's onProviderStreamEvent, at the layer our incident class lives
(GLM arg truncation, DeepSeek id resets) — env-gated (owner choice:
no app surface, off by default; raw events carry full prompts), one
JSONL per chat under data/debug/provider-events/ with a 20MB cap and a
run-start marker for record correlation.

Wired at every agent construction: teacher/prober turns (AgentBuildOptions.
chatId), jobs via LoopContext (researcher, demo-builder through
runSubagent; visual-maker + pdf-reader through their streamOnce), and
the extraction pipeline (extractor + auditor keyed by source id).
practice deliberately unwired (sunk-cost module).
This commit is contained in:
amosblomqvist
2026-10-04 20:21:18 +03:00
parent d2d159d8ae
commit a2c1287415
12 changed files with 330 additions and 10 deletions
+14 -1
View File
@@ -2,6 +2,7 @@ import type { AssistantMessage, Message, Tool, ToolResultMessage, Usage } from "
import { normalizeContext } from "@earendil-works/pi-ai";
import { Type } from "typebox";
import { piEngineFor, type PiEngine, type ThinkingLevel } from "../models.ts";
import { providerEventObserver, type ProviderEventObserver } from "../provider-events.ts";
import type { VisualResult } from "../../core/contracts.ts";
import { getHeadless, type HeadlessApi } from "./headless.ts";
@@ -44,6 +45,8 @@ export interface VisualMakerOptions {
/** Money channel: one call per loop iteration (streamSimple), folded
* into JobRecord.usage. */
onUsage?: (usage: Usage) => void;
/** Debug: raw provider-event recording key (LH_PROVIDER_EVENTS=1). */
chatId?: string;
}
const INSTRUCTIONS = `You are a visual maker — a diagram author + renderer for precise, spatial figures. You
@@ -155,7 +158,15 @@ export async function runVisualMaker(input: VisualMakerInput, opts: VisualMakerO
for (let iteration = 0; iteration < maxIterations; iteration++) {
// 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);
const result = await streamOnce(
engine,
INSTRUCTIONS,
messages,
toolDeclarations,
reasoning,
opts.abortSignal,
opts.chatId ? providerEventObserver(opts.chatId, "job:visualMaker") : undefined,
);
// Money channel: each iteration is one model call.
opts.onUsage?.(result.usage);
messages = [...messages, result];
@@ -307,6 +318,7 @@ async function streamOnce(
tools: Tool[],
reasoning: ThinkingLevel | undefined,
abortSignal?: AbortSignal,
onProviderStreamEvent?: ProviderEventObserver,
): Promise<AssistantMessage> {
// 0.87.1: provider-facing contexts are TranscriptContexts — the prompt
// and tool declarations fold into a leading system message.
@@ -315,6 +327,7 @@ async function streamOnce(
reasoning: reasoning && reasoning !== "off" ? reasoning : undefined,
apiKey: process.env.OPENROUTER_API_KEY || undefined,
signal: abortSignal,
onProviderStreamEvent: onProviderStreamEvent as never,
} as never);
for await (const event of stream) {
if (event.type === "done") return event.message;