- 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.
201 lines
8.0 KiB
TypeScript
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.');
|
|
});
|
|
});
|