extraction: the spine stage — cross-stretch assembly between extract and audit

Per-batch pipeline is now extract → spine → audit → merge (owner order,
2026-09-30), the spine inside the serialized audit section so it reads
the graph exactly as the following audit will. Measured motive: 0 of 133
chains in the live corpus crossed a stretch boundary — no stage minted
cross-stretch chains.

- src/agent/subagents/spine.ts: SESSION/NOTES spine preambles + shared
  SPINE_CORE (span doctrine; extraction doctrines carried over), the
  submit_spine TypeBox schema, validateSpine (mechanical span law: every
  chain touches >=1 fresh endpoint AND >=1 existing digest endpoint;
  unknown ids, self-reference, and id collisions are loud), the digest
  prompt builder, and the runner (strong tier, retry-only).
- extraction.ts: 'assembling' phase, spine stage in the per-need gate,
  usage/billing stage, assemble() inside withAuditLock for both front
  doors; first stretch of a source skips the call (empty digest, no
  spend); the audit sees COMBINED propositions (chunk + connectives),
  never chains; merger receives the combined findings.
- core/graph.ts mergeFindings: chain endpoints resolve through the
  job-local map OR pass through verbatim as existing n_ ids (the spine's
  mixed namespace).
- EXTRACTION_CORE: steps-of-reasoning selection bar (examples, notation
  glosses, and restatements that no argument needs are not steps).
- Factory chunk budget 4096 -> 8192 (fewer seams, more local context).
- healStringifiedArrays moved to the shared submit seam (submit-agent.ts).
- Cost panel: Assembly row; usage records carry a spine stage (optional
  on legacy records).
- Tests +5: prompt pins, span-law matrix, digest rendering, seeded
  end-to-end flow (spanning chain + connective, verbatim n_ endpoint,
  spine billed as its own stage), non-spanning refused + retried.
This commit is contained in:
amosblomqvist
2026-09-30 16:38:45 +03:00
parent 5ed724c224
commit 52c02533d1
18 changed files with 787 additions and 87 deletions
+79 -38
View File
@@ -30,7 +30,17 @@ import { batchRuns, coverageOf, messageViewLines } from "../../core/coverage.ts"
import { teacherProseBlocks } from "../../core/coverage.ts";
import type { LhMessage } from "../../core/native-messages.ts";
import { auditorGraphTools } from "../tools/graph.ts";
import { runSubmitAgent } from "./submit-agent.ts";
import { runSubmitAgent, healStringifiedArrays } from "./submit-agent.ts";
import {
SPINE_INSTRUCTIONS,
NOTES_SPINE_INSTRUCTIONS,
SPINE_EMPTY,
buildSpinePrompt,
combineFindings,
runSpine,
spineHasDigest,
type SpineSubmit,
} from "./spine.ts";
/**
* The extraction pipeline (vision §1 construction, 2026-09-02 redesign;
@@ -199,32 +209,6 @@ const auditSchema = Type.Object({
type NotesFindings = SessionFindings;
type NotesAuditResult = AuditVerdicts;
// ── the weak-model string-heal (owner, 2026-09-28) ──
//
// OpenRouter log evidence: weak models under a big submit payload often
// emit the whole `propositions` array as ONE JSON-STRING instead of a
// native array ("propositions.0: must be object"). pi-agent-core runs
// `prepareArguments` BEFORE TypeBox validation — the sanctioned shim
// point (same doctrine as restoreMathEscapes at the display seam: heal
// weak-model wire behavior, don't punish it with a retry). Best-effort:
// heal only a string that parses to an array; anything else passes
// through untouched and fails validation exactly as before.
function healStringifiedArrays(raw: unknown): unknown {
if (typeof raw !== "object" || raw === null) return raw;
const out: Record<string, unknown> = { ...(raw as Record<string, unknown>) };
for (const key of ["propositions", "chains"]) {
const v = out[key];
if (typeof v !== "string" || v.trim().length === 0) continue;
try {
const parsed: unknown = JSON.parse(v);
if (Array.isArray(parsed)) out[key] = parsed;
} catch {
// not a clean stringify (e.g. truncated) — leave for validation
}
}
return out;
}
// ── the submit seam (owner, 2026-09-28; redesigned 2026-09-30) ──
//
// Every top-level field on each submit tool is REQUIRED — a payload
@@ -314,9 +298,11 @@ teacher, or this reading: not "the learner understands X" but X. A misconception
correction. Each statement is one self-contained reasoning step at full precision.
Extract the load-bearing ideas of the subject — what a reference on it would teach — not the full run of
the discourse. Every proposition must stand alone for a reader who never saw this source: names and
statements may not lean on the source's own scaffolding (claim numbering, axiom tags, arc labels); a
claim that only matters as part of how this document is organized is not knowledge — drop it.
the discourse. The graph stores propositions as STEPS OF REASONING: a proposition earns its place only
when the source's arguments run through it — examples, notation glosses, and restatements that no
argument needs are not steps. Every proposition must stand alone for a reader who never saw this source:
names and statements may not lean on the source's own scaffolding (claim numbering, axiom tags, arc
labels); a claim that only matters as part of how this document is organized is not knowledge — drop it.
SUBMIT with one submit_findings call: every proposition with a short name and a precise statement, and
every chain as premises + conclusion. Premises within one chain are used TOGETHER — independent arguments
@@ -460,7 +446,7 @@ function runAuditor(
* audit+merge critical section is queued behind another job's; the
* extractor is done but the auditor may not start until the graph is
* stable. */
export type ExtractionPhase = "queued" | "extracting" | "waiting" | "auditing" | "merging";
export type ExtractionPhase = "queued" | "extracting" | "waiting" | "assembling" | "auditing" | "merging";
/** One in-flight pipeline run. Module-level: one process, one registry.
* Exported for the status payload's consumer typing (the dock). */
@@ -487,6 +473,7 @@ export interface RunningExtraction {
* context-window blobs read this through /api/graph/status). */
usage: {
extractor?: ExtractionStageUsage;
spine?: ExtractionStageUsage;
auditor?: ExtractionStageUsage;
};
/** True-cancellation handle (the dock's abort button): one controller per
@@ -556,6 +543,7 @@ export interface ExtractionModels {
/** Test seam: a scripted engine per stage — checked FIRST, ahead of the
* engineFor chain (production wires no direct values). */
extractor?: PiEngine;
spine?: PiEngine;
auditor?: PiEngine;
/** Settings-resolved engine per role — the same slot treatment the
* job runner got, read FRESH per stage call (a settings PUT between
@@ -634,6 +622,7 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti
function refuseIfStagesUnready(): void {
const stages: readonly [ModelRole, PiEngine | undefined][] = [
["extractor", stageEngine("extractor", deps.extractor)],
["spine", stageEngine("spine", deps.spine)],
["auditor", stageEngine("auditor", deps.auditor)],
];
const missing = new Set<ModelSlotName>();
@@ -682,7 +671,7 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti
}
/** Accumulate one billed step into the run's registry entry (live). */
function recordStep(jobKey: string, stage: "extractor" | "auditor", usage: CallUsage, model: string): void {
function recordStep(jobKey: string, stage: "extractor" | "spine" | "auditor", usage: CallUsage, model: string): void {
const run = runningExtractions.get(jobKey);
if (!run) return; // not running (tests driving stages directly)
run.usage[stage] ??= { model, steps: [] };
@@ -748,6 +737,50 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti
return findings;
}
/** THE SPINE (owner, 2026-09-30): the assembly stage between extract
* and audit, INSIDE the serialized section (it reads the graph-so-far
* digest; its output rides the same audit + merge). Skipped without a
* call when the source owns nothing yet (first stretch — nothing to
* join). Identity stays the auditor's: the spine submits chains +
* connective propositions only, and the audit sees the COMBINED
* propositions — chunk findings plus connectives — never chains. */
async function assemble(
jobKey: string,
sourceId: string,
kind: "chat" | "notes",
findings: SessionFindings,
): Promise<SessionFindings> {
const signal = runningExtractions.get(jobKey)?.controller.signal;
signal?.throwIfAborted();
const graph = getGraph(graphStore);
if (!spineHasDigest(graph, sourceId)) {
console.info(`[extraction ${jobKey}] spine skipped — empty digest (first stretch)`);
return findings;
}
console.info(
`[extraction ${jobKey}] spine starting (${graph.nodes.length} graph nodes, ` +
`${findings.propositions.length} fresh propositions)`,
);
const t0 = performance.now();
const spine: SpineSubmit = await runSpine(
stageEngine("spine", deps.spine),
kind === "chat" ? SPINE_INSTRUCTIONS : NOTES_SPINE_INSTRUCTIONS,
buildSpinePrompt(graph, sourceId, findings),
new Set(findings.propositions.map((p) => p.id)),
new Set(graph.nodes.map((n) => n.id)),
signal,
(usage, model, step, finishReason, tools) => {
recordStep(jobKey, "spine", callUsage(usage), model);
console.info(`[extraction] spine step ${step}: ${finishReason}, tools: ${tools}`);
},
);
console.info(
`[extraction ${jobKey}] spine done in ${Math.round(performance.now() - t0)}ms — ` +
`${spine.chains.length} chain(s), ${spine.propositions.length} connective(s)`,
);
return spine === SPINE_EMPTY ? findings : combineFindings(findings, spine);
}
async function audit(jobKey: string, findings: SessionFindings): Promise<AuditVerdicts> {
const signal = runningExtractions.get(jobKey)?.controller.signal;
signal?.throwIfAborted(); // an abort during the extract→audit gap ends here
@@ -816,6 +849,7 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti
stats,
stages: {
extractor: run.usage.extractor ?? { model: "unknown", steps: [] },
spine: run.usage.spine ?? { model: "unknown", steps: [] },
auditor: run.usage.auditor ?? { model: "unknown", steps: [] },
},
};
@@ -935,10 +969,14 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti
// SERIALIZED AUDIT (owner 2026-09-28): audit + merge are the
// graph-mutating critical section — one job at a time,
// process-wide, so the auditor explores the graph as it
// stands AFTER every earlier job's merge.
// stands AFTER every earlier job's merge. The spine rides the
// same section (owner order, 2026-09-30): extract → spine →
// audit → merge.
return withAuditLock(jobKey, "chat", undefined, async () => {
setPhase(jobKey, "assembling", "chat", undefined, part);
const combined = await assemble(jobKey, chatId, "chat", findings);
setPhase(jobKey, "auditing", "chat", undefined, part);
const auditResult = await audit(jobKey, findings);
const auditResult = await audit(jobKey, combined);
setPhase(jobKey, "merging", "chat", undefined, part);
// SESSION-DELETION GUARD (wave 6): a chat deleted mid-run must never
// merge. The deletion route aborts this run first and runs its purge
@@ -955,7 +993,7 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti
// merges in), the stored fingerprint won't match its new
// one and it re-opens: honest by construction.
const foldedAt = new Date().toISOString();
const batchStats = mergeFindings(chatId, findings, auditResult, { chatStore, graphStore }, {
const batchStats = mergeFindings(chatId, combined, auditResult, { chatStore, graphStore }, {
foldedAt,
coverage: batch
.filter((slot) => messageViewLines(slot.message, slot.message.id).length > 0)
@@ -1088,14 +1126,17 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti
// parallel, but audit + merge one at a time, process-wide —
// the auditor explores the graph as it stands AFTER every
// earlier job's merge. Reacquired per chunk so runs
// interleave fairly.
// interleave fairly. The spine rides the same section
// (owner order, 2026-09-30): extract → spine → audit → merge.
return withAuditLock(jobKey, "notes", input.filePath, async () => {
setPhase(jobKey, "assembling", "notes", input.filePath, part);
const combined = await assemble(jobKey, id, "notes", findings);
setPhase(jobKey, "auditing", "notes", input.filePath, part);
const auditResult = await audit(jobKey, findings);
const auditResult = await audit(jobKey, combined);
setPhase(jobKey, "merging", "notes", input.filePath, part);
const merged = mergeFindings(
id,
findings,
combined,
auditResult,
{ chatStore, graphStore },
{ foldedAt: input.importedAt },
+261
View File
@@ -0,0 +1,261 @@
/**
* The spine stage (owner-approved design, 2026-09-30): the extraction
* pipeline's ASSEMBLY pass. Extraction walks a source in stretches, each
* stretch-blind — but the source's reasoning runs over the whole span,
* and the data showed it: 0 of 133 chains crossed a stretch boundary.
* The spine runs once per batch, INSIDE the serialized audit section,
* between the extract and the audit (owner-specified order):
*
* extract → [lock: spine → audit → merge] → next batch
*
* It sees the same-source digest (everything merged so far) plus the
* fresh stretch's findings, and mints ONLY what spans them: cross-stretch
* chains and the connective propositions a chain needs that no stretch
* stated on its own. The SPAN LAW is mechanical (validateSpine below):
* every chain must touch ≥1 fresh endpoint AND ≥1 existing endpoint —
* induction over the walk means no pair ever needs a second chance.
*
* Identity stays the AUDITOR's lane (owner ruling): the spine submits no
* links. Its connective propositions ride into the audit as ordinary
* findings — the auditor links them like any other — and its chains
* resolve through the merger's job-local map (endpoints may be existing
* graph ids verbatim; see mergeFindings' mixed-namespace resolution).
*
* Session and notes front doors share this stage; only the preamble
* differs (the extractor's law, verbatim).
*/
import type { Usage } from "@earendil-works/pi-ai";
import { Type, type Static } from "typebox";
import { runSubmitAgent, healStringifiedArrays } from "./submit-agent.ts";
import type { PiEngine } from "../models.ts";
import type { ExtractedChain, SessionFindings } from "../../core/contracts.ts";
import type { DerivedGraph } from "../../core/graph.ts";
// ─── the prompts ─────────────────────────────────────────────────────────────
export const SESSION_SPINE_PREAMBLE =
"You are the spine stage of a learning application's extraction pipeline. One source — a teaching " +
"session, walked in stretches of the teacher's prose in reading order — has just advanced by one " +
"stretch; you join what the new stretch established into the source's accumulated line of argument.";
export const NOTES_SPINE_PREAMBLE =
"You are the spine stage of a learning application's extraction pipeline. One source — the author's " +
"personal notes, walked in stretches in reading order — has just advanced by one stretch; you join " +
"what the new stretch established into the source's accumulated line of argument.";
export const SPINE_CORE = `THE GOAL: carry the source's line of argument ACROSS stretches. Extraction walks the source in
stretches; each stretch reports its own propositions and chains — but the source's reasoning runs over
the whole span, and no stretch sees more than its own part. You see everything extracted from this
source so far, plus what the latest stretch just established. Your deliverable: the chains that JOIN them.
Every chain you submit must SPAN: its premises and conclusion may not all come from the same stretch. A
chain inside one stretch is that stretch's own work; a chain the graph already holds exists. Direction is
whatever the reasoning runs — earlier into later, or later material completing an argument opened earlier.
The extraction doctrines bind you unchanged: premises JOINTLY entail the conclusion; entailment is the
only link that earns a chain; every proposition stands alone for a reader who never saw the source;
load-bearing connections only — when in doubt, omit.
Restatements: when the fresh stretch restates a proposition the graph already holds, build your chains on
the EXISTING id, not the restatement.
SUBMIT with one submit_spine call: the connecting chains, and only the connective propositions a chain
needs that no stretch stated on its own — a missing middle step, the general claim a stretch argued only
locally. Pass empty arrays when the stretch stands alone — never omit the fields. The tool's field
descriptions carry the details.`;
export const SPINE_INSTRUCTIONS = `${SESSION_SPINE_PREAMBLE}\n\n${SPINE_CORE}`;
export const NOTES_SPINE_INSTRUCTIONS = `${NOTES_SPINE_PREAMBLE}\n\n${SPINE_CORE}`;
// ─── the submit schema ───────────────────────────────────────────────────────
const spinePropositionProps = {
id: Type.String({ description: 'Local id: "s1", "s2", … — referenced by chains.' }),
name: Type.String({
description:
"A very short label — established terminology, the term the discipline itself uses. " +
"Not a sentence, needn't be unique or self-contained: this becomes the node's global label.",
}),
statement: Type.String({
description:
"One true, self-contained, world-facing reasoning step at full precision — the connective " +
"tissue a spanning chain needs that no stretch stated on its own.",
}),
aliases: Type.Optional(
Type.Array(Type.String(), {
description: "Verbatim phrasings the source used for the same proposition, if any.",
}),
),
};
const endpoint = (role: string) =>
Type.String({
description:
`The proposition id this ${role} — EITHER an existing proposition id from the digest, ` +
'verbatim ("n_…"), OR the local id of a proposition in this submission ("s1", …) or of the ' +
'fresh stretch\'s findings ("p1", …).',
});
const spineChainSchema = Type.Object({
premises: Type.Array(endpoint("premise uses"), {
minItems: 1,
description:
"Proposition ids used TOGETHER: they jointly entail the conclusion; independent reasons are separate chains.",
}),
conclusion: endpoint("conclusion is"),
});
const spineSchema = Type.Object({
propositions: Type.Array(Type.Object(spinePropositionProps), {
description:
"Connective propositions ONLY — steps a spanning chain needs that no stretch stated on " +
"its own. Usually empty.",
}),
chains: Type.Array(spineChainSchema, {
description:
"The cross-stretch chains — each must join material that arrived at different times " +
"(≥1 endpoint from the fresh stretch, ≥1 from before it). Pass [] when the stretch " +
"stands alone — never omit the field.",
}),
});
/** What the spine SUBMITS. fillSpine normalizes it; validateSpine checks
* the span laws; the pipeline combines it with the stretch's findings
* before the audit (the auditor sees chunk + connective propositions,
* propositions only — never chains, owner 2026-09-30). */
export type SpineSubmit = Static<typeof spineSchema>;
export const SPINE_EMPTY: SpineSubmit = { propositions: [], chains: [] };
export function fillSpine(raw: SpineSubmit): SpineSubmit {
return {
propositions: raw.propositions.map((p) => ({ ...p, aliases: p.aliases ?? [] })),
chains: raw.chains,
};
}
/** The span laws — the LOUD channel (error-back retry), mirroring
* validateFindingsChains. `chunkIds` = the fresh stretch's finding ids
* ("p1"…); `existingIds` = every node id already in the graph. A chain
* must touch at least one FRESH endpoint (chunk finding or this
* submission's connectives) and at least one EXISTING endpoint — the
* mechanical form of the owner's all-or-nothing span rule. Identical
* re-assertions are not errors: the merger dedupes them silently. */
export function validateSpine(value: SpineSubmit, chunkIds: ReadonlySet<string>, existingIds: ReadonlySet<string>): string | null {
const localIds = new Set(value.propositions.map((p) => p.id));
for (const p of value.propositions) {
if (chunkIds.has(p.id) || existingIds.has(p.id)) {
return `propositions: "${p.id}" collides with an id this submission does not own — connective ids are fresh ("s1", "s2", …).`;
}
}
const resolves = (id: string) => chunkIds.has(id) || localIds.has(id) || existingIds.has(id);
const isFresh = (id: string) => chunkIds.has(id) || localIds.has(id);
for (const chain of value.chains) {
if (chain.premises.length === 0) {
return "chains: premises must name at least one proposition.";
}
const endpoints = [...chain.premises, chain.conclusion];
const unknown = endpoints.find((id) => !resolves(id));
if (unknown !== undefined) {
return `chains: "${unknown}" is not an existing digest id, a fresh-stretch finding id, or one of this call's proposition ids.`;
}
if (chain.premises.includes(chain.conclusion)) {
return "chains: a conclusion cannot be its own premise.";
}
if (!endpoints.some(isFresh)) {
return `chains: [${endpoints.join(", ")}] is entirely existing material — a chain the graph already had its chance to hold. Every chain must join the fresh stretch to what came before.`;
}
if (!endpoints.some((id) => existingIds.has(id))) {
return `chains: [${endpoints.join(", ")}] lives entirely inside the fresh stretch — that is the stretch extractor's work, not yours. Every chain must join the fresh stretch to what came before.`;
}
}
return null;
}
// ─── the digest ──────────────────────────────────────────────────────────────
/** Render the same-source digest + fresh findings for the spine's prompt.
* Pure: a string fold over the derived graph. The digest carries ids
* verbatim ("n_…") — that is what the spine references in its chains. */
export function buildSpinePrompt(graph: DerivedGraph, sourceId: string, findings: SessionFindings): string {
const nameOf = new Map(graph.nodes.map((n) => [n.id, n.name]));
const ours = graph.nodes.filter((n) => n.provenance.some((pt) => pt.chatId === sourceId));
const ourChains = graph.chains.filter((c) => c.provenance.some((pt) => pt.chatId === sourceId));
const lines: string[] = [];
lines.push(
ours.length > 0
? `THE GRAPH SO FAR — this source's accumulated propositions (ids verbatim, referenced by your chains):`
: `THE GRAPH SO FAR — this source has no accumulated propositions yet (nothing to join).`,
);
for (const n of ours) lines.push(` ${n.id} | ${n.name} | ${n.statement.replace(/\s+/g, " ")}`);
if (ourChains.length > 0) {
lines.push(`ARGUMENTS ALREADY RECORDED (do not re-submit these):`);
for (const c of ourChains) {
const names = c.premises.map((p) => nameOf.get(p) ?? p).join(" ∧ ");
lines.push(` [${c.premises.join(" + ")} ⟹ ${c.conclusion}] ${names} ⟹ ${nameOf.get(c.conclusion) ?? c.conclusion}`);
}
}
lines.push(``);
lines.push(`THE FRESH STRETCH — what the extractor just reported (local ids "p1", …):`);
for (const p of findings.propositions) lines.push(` ${p.id} | ${p.name} | ${p.statement.replace(/\s+/g, " ")}`);
if (findings.chains.length > 0) {
lines.push(`ITS OWN CHAINS (this stretch's local work):`);
const freshName = new Map(findings.propositions.map((p) => [p.id, p.name]));
for (const c of findings.chains) {
const names = c.premises.map((p) => freshName.get(p) ?? p).join(" ∧ ");
lines.push(` [${c.premises.join(" + ")} ⟹ ${c.conclusion}] ${names} ⟹ ${freshName.get(c.conclusion) ?? c.conclusion}`);
}
}
lines.push(``);
lines.push(`Join the source's line of argument across these two moments.`);
return lines.join("\n");
}
/** Whether the spine has anything to see at all — the first stretch of a
* source has an empty digest, and the call is skipped (no spine run, no
* spend) until a source owns at least one merged proposition. */
export function spineHasDigest(graph: DerivedGraph, sourceId: string): boolean {
return graph.nodes.some((n) => n.provenance.some((pt) => pt.chatId === sourceId));
}
// ─── the runner ──────────────────────────────────────────────────────────────
/** The spine over one batch's findings — a single submit call, retry-only
* (no exploration tools; everything it needs is in the prompt). */
export function runSpine(
engine: PiEngine | undefined,
systemPrompt: string,
prompt: string,
chunkIds: ReadonlySet<string>,
existingIds: ReadonlySet<string>,
signal: AbortSignal | undefined,
onStep: (usage: Usage, model: string, step: number, finishReason: string, tools: string) => void,
): Promise<SpineSubmit> {
return runSubmitAgent<SpineSubmit>({
role: "spine",
toolName: "submit_spine",
toolDescription: "Submit the cross-stretch chains (and any connective propositions they need).",
parameters: spineSchema,
fill: fillSpine,
validate: (value) => validateSpine(value, chunkIds, existingIds),
systemPrompt,
prompt,
engine,
signal,
onStep,
neverSubmitted: "the spine never submitted its chains",
prepareArguments: healStringifiedArrays,
});
}
/** The combined findings the AUDIT + MERGE see: the stretch's propositions
* plus the spine's connectives (ONE findings set — the auditor links
* connectives like anything else), and both chain sets (endpoints may be
* existing graph ids — the merger resolves the mixed namespace). */
export function combineFindings(findings: SessionFindings, spine: SpineSubmit): SessionFindings {
return {
propositions: [...findings.propositions, ...spine.propositions.map((p) => ({ ...p, aliases: p.aliases ?? [] }))],
chains: [...findings.chains, ...(spine.chains as ExtractedChain[])],
};
}
+28 -3
View File
@@ -5,9 +5,6 @@
* schema-invalid call). The submitted value is captured from the execute
* seam, which only fires on a schema-valid call — the ??-fill (the old zod
* defaults) applies at the capture.
*
* Extraction passes its own weak-model argument heal (healStringifiedArrays)
* through `prepareArguments`; the default is identity pass-through.
*/
import type { Usage } from "@earendil-works/pi-ai";
@@ -18,6 +15,34 @@ import type { ModelRole } from "../../../config.ts";
import type { PiEngine } from "../models.ts";
import { runSubagent } from "./pi-run.ts";
// ── the weak-model string-heal (owner, 2026-09-28; moved here from
// extraction.ts with the spine stage, 2026-09-30 — the heal is wire-format
// medicine shared by every submit agent) ──
//
// OpenRouter log evidence: weak models under a big submit payload often
// emit a whole array as ONE JSON-STRING instead of a native array
// ("propositions.0: must be object"). pi-agent-core runs
// `prepareArguments` BEFORE TypeBox validation — the sanctioned shim
// point (same doctrine as restoreMathEscapes at the display seam: heal
// weak-model wire behavior, don't punish it with a retry). Best-effort:
// heal only a string that parses to an array; anything else passes
// through untouched and fails validation exactly as before.
export function healStringifiedArrays(raw: unknown): unknown {
if (typeof raw !== "object" || raw === null) return raw;
const out: Record<string, unknown> = { ...(raw as Record<string, unknown>) };
for (const key of ["propositions", "chains"]) {
const v = out[key];
if (typeof v !== "string" || v.trim().length === 0) continue;
try {
const parsed: unknown = JSON.parse(v);
if (Array.isArray(parsed)) out[key] = parsed;
} catch {
// not a clean stringify (e.g. truncated) — leave for validation
}
}
return out;
}
export async function runSubmitAgent<T>(opts: {
role: ModelRole;
toolName: string;