Files
learnharness/tests/graph-tools.test.ts
T
amosblomqvist b87a6925af 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.
2026-09-25 23:32:13 +03:00

201 lines
8.0 KiB
TypeScript

// THE PROBER'S GRAPH TOOLS (wave 7) — pinned behaviors:
// 1. REGISTRATION: the factory registers list_sessions/list_propositions
// on the prober ONLY when the composition root passes a graphStore
// (the graphToolsEnabled gate lives server-side; absent store = closed
// gate — the factory must not reach for settings itself).
// 2. OUTPUT: names + levels + dating only — the owner ruling bars
// statement text and session contents from the prober's view of the map.
// 3. HONESTY: empty graph, unknown session id — the tools answer in
// their own voice, never throw, never guess.
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { createProberTools } from "../src/agent/tools.ts";
import type { ChatStore, GraphStore, JobStore } from "../src/core/ports.ts";
import type { ChatSummary } from "../src/core/contracts/chat.ts";
import type { GraphEdge, GraphNode, LedgerEvent } from "../src/core/contracts/graph.ts";
// ─── fixtures ────────────────────────────────────────────────────────────────
const pt = (chatId: string, messageId = "m1") => ({ chatId, messageId });
const node = (
id: string,
name: string,
provenance: Array<{ chatId: string; messageId: string }>,
): GraphNode => ({
id,
name,
statement: `statement of ${name}`, // must NEVER leak into tool output
aliases: [],
createdAt: "2026-09-23T00:00:00.000Z",
provenance,
});
const observation = (
nodeId: string,
chatId: string,
note: string,
at: string,
): LedgerEvent =>
({ kind: "observation", nodeId, chatId, note, at }) as unknown as LedgerEvent;
function fakeStores(
nodes: GraphNode[],
over: {
events?: LedgerEvent[];
chats?: Array<Pick<ChatSummary, "id" | "title" | "createdAt" | "updatedAt">>;
} = {},
): { chatStore: ChatStore; graphStore: GraphStore } {
const edges: GraphEdge[] = [];
const graphStore: GraphStore = {
readNodes: () => nodes,
readEdges: () => edges,
readEvents: () => over.events ?? [],
readMappings: () => [],
readCheckpoints: () => [],
readCoverage: () => [],
readSources: () => [],
readPositions: () => ({}),
writePositions: () => {},
readClusterLabels: () => [],
appendClusterLabel: () => {},
appendNamingRun: () => {},
} as unknown as GraphStore;
const chatStore: ChatStore = {
listChats: () =>
(over.chats ?? []).map((c) => ({ ...c, messageCount: 0 }) as unknown as ChatSummary),
getChat: (id: string) =>
((over.chats ?? []).find((c) => c.id === id) as unknown as ChatSummary) ?? undefined,
} as unknown as ChatStore;
return { chatStore, graphStore };
}
const stubJobStore = {
createJob: (() => ({})) as never,
getJob: (() => undefined) as never,
listJobs: (() => []) as never,
updateJob: (() => {}) as never,
} as unknown as JobStore;
const baseCtx = {
chatId: "c1",
deliver: () => {},
jobStore: stubJobStore,
spawn: () => {},
};
// ─── registration (the factory gate) ────────────────────────────────────────
describe("prober graph tools — registration", () => {
it("no graphStore in the context → the tools are absent (gate decided server-side)", () => {
const { chatStore } = fakeStores([]);
const tools = createProberTools({ ...baseCtx, chatStore });
assert.equal("list_sessions" in tools, false);
assert.equal("list_propositions" in tools, false);
});
it("graphStore present → list_sessions + list_propositions join the registry beside the usual set", () => {
const { chatStore, graphStore } = fakeStores([node("n1", "Water cycle", [pt("c1")])]);
const tools = createProberTools({ ...baseCtx, chatStore, graphStore });
// The standing set (fix wave 2026-09-20: research + pdf are
// phase-omnipresent) plus the two graph-map readers.
assert.deepEqual(Object.keys(tools).sort(), [
"ask_user_question",
"find",
"grep",
"list_propositions",
"list_sessions",
"ls",
"pdf",
"probe_done",
"quiz",
"read",
"research",
]);
});
});
// ─── output shape + the no-prose rule ──────────────────────────────────────
describe("list_sessions", () => {
it("names, kinds, counts, dates, ids — in the prober's voice, no statements", async () => {
const { chatStore, graphStore } = fakeStores(
[
node("n1", "Water cycle", [pt("c1")]),
node("n2", "Evaporation", [pt("c1")]),
node("n3", "Nietzsche", [pt("c-file")]),
],
{
chats: [
{
id: "c1",
title: "First lesson",
createdAt: "2026-09-20T10:00:00.000Z",
updatedAt: "2026-09-21T10:00:00.000Z",
},
],
events: [],
},
);
const tools = createProberTools({ ...baseCtx, chatStore, graphStore });
const out = (await tools.list_sessions!.execute!("tc", {})).details as string;
assert.match(out, /2 session\(s\) in the knowledge graph, most recent first:/);
// The chat: name, kind, born count, dates, id — the full resume.
assert.match(out, /First lesson \(chat, 2 propositions, born 2026-09-20T10:00:00.000Z, last active 2026-09-21T10:00:00.000Z\) — id: c1/);
// The unrecorded birth session falls back honestly: the id, no fake date.
assert.match(out, /c-file \(file, 1 proposition, born unknown date\) — id: c-file/);
// The owner ruling: the map's prose never reaches the prober.
assert.ok(!out.includes("statement of"));
});
it("an empty graph gets an honest answer, not an empty string or a throw", async () => {
const { chatStore, graphStore } = fakeStores([]);
const tools = createProberTools({ ...baseCtx, chatStore, graphStore });
const out = (await tools.list_sessions!.execute!("tc", {})).details as string;
assert.equal(out, "The knowledge graph is empty — no session has contributed propositions yet.");
});
});
describe("list_propositions", () => {
it("names + latest understanding + that evaluation's date — the latest event wins", async () => {
const { chatStore, graphStore } = fakeStores(
[node("n1", "Water cycle", [pt("c1")]), node("n2", "Evaporation", [pt("c1")])],
{
events: [
// The birth session's OWN evaluations only — a later
// session's observation must not leak in here.
observation("n1", "c1", "shaky", "2026-09-20T12:00:00.000Z"),
observation("n1", "c1", "solid", "2026-09-22T12:00:00.000Z"),
observation("n1", "c2", "resisted", "2026-09-23T12:00:00.000Z"),
observation("n2", "c1", "not yet engaged", "2026-09-21T12:00:00.000Z"),
],
},
);
const tools = createProberTools({ ...baseCtx, chatStore, graphStore });
const out = (await tools.list_propositions!.execute!("tc", { session: "c1" })).details as string;
assert.match(out, /2 proposition\(s\) born in this session:/);
assert.match(out, /- Water cycle — solid \(as of 2026-09-22T12:00:00.000Z\)/);
assert.match(out, /- Evaporation — not yet engaged \(as of 2026-09-21T12:00:00.000Z\)/);
// Never the other session's word, never the statement text.
assert.ok(!out.includes("resisted"));
assert.ok(!out.includes("statement of"));
});
it("a proposition with no observation reads 'never evaluated', undated", async () => {
const { chatStore, graphStore } = fakeStores([node("n1", "Water cycle", [pt("c1")])], {
events: [],
});
const tools = createProberTools({ ...baseCtx, chatStore, graphStore });
const out = (await tools.list_propositions!.execute!("tc", { session: "c1" })).details as string;
assert.match(out, /- Water cycle — never evaluated/);
assert.ok(!out.includes("(as of"));
});
it("an unknown session id — honest redirect, not a throw", async () => {
const { chatStore, graphStore } = fakeStores([node("n1", "Water cycle", [pt("c1")])]);
const tools = createProberTools({ ...baseCtx, chatStore, graphStore });
const out = (await tools.list_propositions!.execute!("tc", { session: "no-such" })).details as string;
assert.equal(out, 'No propositions born in session "no-such". Call list_sessions for the current ids.');
});
});