Wave A: pi-native tool layer — delete LhTool/toPiTool, TypeBox everywhere, terminate on questions

- src/agent/lh-tool.ts deleted: the zod-wrapped LhTool + toPiTool bridge
  (z.toJSONSchema per call, clientQuestion flag, hand-rolled result mapping)
  is gone. Tools are pi-agent-core native AgentTools now.
- New src/agent/tool-execute.ts owns what the wrapper added: the abort guard
  (runWithAbortGuard: run signal + 30s per-call timeout + 250ms grace,
  'Operation aborted' / timeout wording preserved), ToolInputError (the
  retryable '- problem at path' format), result mapping (string verbatim /
  object JSON / image parts, details rides the record's tool part output),
  and questionResult() — placeholder + terminate:true (owner Option A:
  belt of terminate + slim makeQuestionStopper with the clean-question
  rule; maxTurns deleted from the teaching turn entirely per owner).
- All 11 tool files + tools.ts registries converted to TypeBox parameters
  (typebox@1.3.7, pinned to pi-agent-core's copy). zod refinements
  (quizInputProblems, askOptionProblems, live-nodeId) moved from schemas
  into execute — the model sees the same retryable error shape.
- question-core + contracts/probe TypeBox-authored (single source shared
  with the UI); tool-parts parse seam runs the boundary's full chain
  (Convert → Check → Clean → refinement), fixing the live-smoke lockout:
  pi's boundary Value.Converts correctAnswer: 17 → '17' but the record
  keeps raw args, so a parse without Convert rejected a posed quiz
  (invisible pending question, turn ended). Pinned with the real smoke
  emission shape.
- Subagents converted: demo-builder/researcher AgentTool-native,
  visual-maker/pdf-reader declaration-only pi-ai Tools with local execute
  fns, extraction submit schemas re-authored in TypeBox (owner option 2:
  zod defaults → Type.Optional + fill at the submit seam).
- Accepted delta, flagged: TypeBox-native schemas skip pi's JSON-Schema
  primitive coercion fast path... (boundary still Value.Converts), and
  prober-quiz unknown keys ride through (Value.Clean strips at display).
- Tests: 988 passing (was 967 at the recon baseline); tool-abort and
  read-tool rewritten against the native seams; question-core/question-
  tools/tool-parts/demos/web-search/graph-tools/plan-tree/probe-done/
  tool-toggles converted to Value.Check/execute pins.

Live smoke (PORT=3004, real model): native ls turn streams end-to-end; a
real quiz pose (first attempt missing correctAnswer → tool-input-error →
model retry → posed) terminates the turn cleanly and persists pending.
This commit is contained in:
amosblomqvist
2026-09-25 23:32:13 +03:00
parent b07cb313f0
commit b87a6925af
35 changed files with 1357 additions and 1083 deletions
+27 -30
View File
@@ -4,7 +4,8 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { z } from "zod";
import { Value } from "typebox/value";
import type { TSchema } from "typebox";
import {
askInputSchema,
askOptionProblems,
@@ -29,24 +30,25 @@ import {
toClientQuestion,
} from "../src/core/question-core.ts";
// ─── the authoring contract (tool-schema refinements) ───────────────────
// ─── the authoring contract (TypeBox schemas + execute-time refinements) ──
// Schema-valid ⇔ gradeable: a quiz whose correctAnswer can't be resolved
// dies at the tool-call boundary, and the SDK returns the error to the
// teacher to retry — it must never reach the learner's dock.
// dies at the tool-call boundary (structural TypeBox check in pi's loop +
// the refinement problems in the tool's execute — a ToolInputError the
// model retries on), and never reaches the learner's dock. The boundary
// predicate is BOTH: structural check AND zero authoring problems.
const boundaryOk = (schema: TSchema, input: unknown, problems: string[]): boolean =>
Value.Check(schema, input) && problems.length === 0;
describe("the schema's argument order (reasoning before the pick)", () => {
// Argument-order ruling (owner, 2026-09-25): the explanation is
// reasoning, and reasoning happens BEFORE the answer is chosen — models
// emit tool args in schema order, so explanation sits immediately before
// correctAnswer. z.toJSONSchema preserves zod's declaration order into
// the model-facing properties, so this pin guards the emission end to
// end. All consumers read by key — the order is authoring guidance.
// correctAnswer. The TypeBox authoring order IS the model-facing JSON
// Schema property order, so this pin guards the emission end to end.
// All consumers read by key — the order is authoring guidance.
it("emits explanation immediately before correctAnswer (both agents' schemas)", () => {
for (const schema of [quizInputSchema, probeQuizInputSchema]) {
const json = z.toJSONSchema(schema as z.ZodType, { io: "input" }) as {
properties: Record<string, unknown>;
};
const keys = Object.keys(json.properties);
const keys = Object.keys((schema as { properties: Record<string, unknown> }).properties);
assert.ok(
keys.indexOf("explanation") < keys.indexOf("correctAnswer"),
`explanation must precede correctAnswer in the emitted schema (got: ${keys.join(", ")})`,
@@ -73,8 +75,7 @@ describe("quizInputSchema / quizInputProblems (the authoring contract)", () => {
};
it("accepts a valid single-select quiz (label as correctAnswer)", () => {
const parsed = quizInputSchema.safeParse(valid);
assert.equal(parsed.success, true);
assert.equal(boundaryOk(quizInputSchema, valid, quizInputProblems(valid)), true);
assert.deepEqual(quizInputProblems(valid), []);
});
@@ -89,10 +90,10 @@ describe("quizInputSchema / quizInputProblems (the authoring contract)", () => {
correctAnswer: ["a", "b"],
multiSelect: true,
};
assert.equal(quizInputSchema.safeParse(multi).success, true);
assert.equal(boundaryOk(quizInputSchema, multi, quizInputProblems(multi)), true);
// The union lists String first, so providers sometimes stringify arrays.
const stringified = { ...multi, correctAnswer: JSON.stringify(multi.correctAnswer) };
assert.equal(quizInputSchema.safeParse(stringified).success, true);
assert.equal(boundaryOk(quizInputSchema, stringified, quizInputProblems(stringified)), true);
assert.deepEqual(quizInputProblems(stringified), []);
});
@@ -108,20 +109,16 @@ describe("quizInputSchema / quizInputProblems (the authoring contract)", () => {
correctAnswer: "cultural-diagnosis",
explanation: "...",
};
const parsed = quizInputSchema.safeParse(bad);
assert.equal(parsed.success, false);
assert.equal(boundaryOk(quizInputSchema, bad, quizInputProblems(bad)), false);
const problems = quizInputProblems(bad);
assert.equal(problems.length, 1);
assert.match(problems[0]!, /does not match any option value/);
assert.match(problems[0]!, /"A cultural diagnosis"/); // the valid values are listed for the retry
// The schema's error message carries the problems (the SDK returns them to the teacher).
const msg = JSON.stringify(parsed.error?.issues);
assert.match(msg, /does not match any option value/);
});
it("rejects multiple correct answers without multiSelect (ungradeable single-select)", () => {
const bad = { ...valid, correctAnswer: ["Venus", "Mercury"] };
assert.equal(quizInputSchema.safeParse(bad).success, false);
assert.equal(boundaryOk(quizInputSchema, bad, quizInputProblems(bad)), false);
assert.match(quizInputProblems(bad).join(" "), /multiSelect/);
});
@@ -133,38 +130,38 @@ describe("quizInputSchema / quizInputProblems (the authoring contract)", () => {
{ label: "Venus", value: "same" },
],
};
assert.equal(quizInputSchema.safeParse(bad).success, false);
assert.equal(boundaryOk(quizInputSchema, bad, quizInputProblems(bad)), false);
assert.match(quizInputProblems(bad).join(" "), /same value/);
});
it("rejects an empty option label", () => {
const bad = { ...valid, options: [{ label: " " }, { label: "Mercury" }, { label: "Mars" }] };
assert.equal(quizInputSchema.safeParse(bad).success, false);
assert.equal(boundaryOk(quizInputSchema, bad, quizInputProblems(bad)), false);
assert.match(quizInputProblems(bad).join(" "), /label is empty/);
});
it("requires a resolvable correctAnswer", () => {
assert.equal(quizInputSchema.safeParse({ ...valid, correctAnswer: "" }).success, false);
assert.equal(quizInputSchema.safeParse({ ...valid, correctAnswer: "[]" }).success, false);
assert.equal(boundaryOk(quizInputSchema, { ...valid, correctAnswer: "" }, quizInputProblems({ ...valid, correctAnswer: "" })), false);
assert.equal(boundaryOk(quizInputSchema, { ...valid, correctAnswer: "[]" }, quizInputProblems({ ...valid, correctAnswer: "[]" })), false);
assert.match(quizInputProblems({ ...valid, correctAnswer: "" }).join(" "), /required/);
});
});
describe("askInputSchema / askOptionProblems", () => {
it("accepts a valid ask", () => {
const parsed = askInputSchema.safeParse({
const ask = {
question: "Which topic first?",
options: [{ label: "Limits" }, { label: "Chain rule" }],
});
assert.equal(parsed.success, true);
};
assert.equal(boundaryOk(askInputSchema, ask, askOptionProblems(ask.options ?? [])), true);
});
it("rejects duplicate option values and empty labels (they break the dock's keying)", () => {
const bad = { question: "?", options: [{ label: "A", value: "x" }, { label: "B", value: "x" }] };
assert.equal(askInputSchema.safeParse(bad).success, false);
assert.equal(boundaryOk(askInputSchema, bad, askOptionProblems(bad.options ?? [])), false);
assert.match(askOptionProblems(bad.options).join(" "), /same value/);
const emptyLabel = { question: "?", options: [{ label: "" }, { label: "B" }] };
assert.equal(askInputSchema.safeParse(emptyLabel).success, false);
assert.equal(boundaryOk(askInputSchema, emptyLabel, askOptionProblems(emptyLabel.options ?? [])), false);
assert.match(askOptionProblems(emptyLabel.options).join(" "), /label is empty/);
});
});