From 6c42393e42bf5a98ca32fb84f5339216aa03f39d Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Sat, 19 Sep 2026 18:16:17 +0300 Subject: [PATCH] =?UTF-8?q?feat(tools):=20the=20P2=20toggle=20bridge=20?= =?UTF-8?q?=E2=80=94=20toolToggles=20first-class,=20definitive=20vocabular?= =?UTF-8?q?y?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reconciliation of the diverged tool-toggles seam (2026-09-19, master's P2): Platform injected the resolved delta as an ad-hoc intersection on AgentBuildOptions while the factories' enabled? param sat unread — the seam was inert on both sides. - teacher-agent: AgentBuildOptions gains toolToggles?: EnabledTools; both constructors thread it to the factories (createTeacherTools / createProberTools). An explicitly injected opts.tools registry is never filtered — the caller owns it. - Vocabulary ADJUDICATED with the user (2026-09-19): TOGGLEABLE_TOOLS is the definitive list — the job tools only (research/demo/visual/pdf). quiz/ask_user_question stay always-on forever (the prober's flow is graded-quiz brackets -> probe_done; quiz-off collapses assessment and teaching doctrine is built on graded verification — the wedge danger); the learner-fs set, plan tools, and probe_done also always-on. - settings-body (write side): unknown tool keys reject with 400 + the supported list, mirroring the prompt-switch validation; the read-side sanitizer stays lenient for forward-compat. - chat-handler: the intersection is gone (field now first-class) and the seam comment states the adjudicated vocabulary. Gates: typecheck, 28 new/updated tests green (tool-toggles-bridge, question-tools, settings-body), chat-flow + settings suites green. --- src/agent/teacher-agent.ts | 16 +++++-- src/core/pending.ts | 23 +++++---- src/server/chat-handler.ts | 13 +++--- src/server/settings-body.ts | 16 ++++++- tests/question-tools.test.ts | 10 ++-- tests/tool-toggles-bridge.test.ts | 78 +++++++++++++++++++++++++++++++ 6 files changed, 132 insertions(+), 24 deletions(-) create mode 100644 tests/tool-toggles-bridge.test.ts diff --git a/src/agent/teacher-agent.ts b/src/agent/teacher-agent.ts index 5c388af..e0630c8 100644 --- a/src/agent/teacher-agent.ts +++ b/src/agent/teacher-agent.ts @@ -15,7 +15,7 @@ import { } from "./tools.ts"; import { toPiTools, type LhTool } from "./lh-tool.ts"; import { quizInputObject, quizInputProblems } from "../core/question-core.ts"; -import { QUESTION_TOOLS } from "../core/pending.ts"; +import { QUESTION_TOOLS, type EnabledTools } from "../core/pending.ts"; /** * The two teaching-side agents (docs/probe-teach-split.md): the PROBER and @@ -88,8 +88,15 @@ export interface AgentBuildOptions { onUsage?: StepUsageCollector; /** Safety net (the old stepCountIs). Default 20 turns. */ maxTurns?: number; - /** Tests may pass a bare tool registry to drive deterministic flows. */ + /** Tests may pass a bare tool registry to drive deterministic flows. + * An explicit registry is NEVER toggle-filtered — the caller owns it. */ tools?: Record>; + /** The resolved per-user tool toggles (core/pending EnabledTools — the + * 2026-09-19 P2 bridge: Platform's chat-handler passes + * effective.agent.tools; the factories drop any tool explicitly false. + * THE DEFINITIVE toggleable set is TOGGLEABLE_TOOLS (job tools only); + * the filtering is generic, but writes validate against the list). */ + toolToggles?: EnabledTools; } /** @@ -159,7 +166,7 @@ function baseAgentOptions(kind: AgentKind, opts: AgentBuildOptions, tools: Agent * handler keys teacher construction on. */ export function createProberAgent(ctx: ProbeDoneContext, opts: AgentBuildOptions = {}) { - return baseAgentOptions("prober", opts, toPiTools(createProberTools(ctx))); + return baseAgentOptions("prober", opts, toPiTools(createProberTools(ctx, opts.toolToggles))); } /** @@ -175,7 +182,8 @@ export function createTeacherAgent( ctx?: TeacherToolContext, opts: AgentBuildOptions = {}, ) { - const tools = opts.tools ?? (ctx ? createTeacherTools(ctx) : bareQuestionTools()); + const tools = + opts.tools ?? (ctx ? createTeacherTools(ctx, opts.toolToggles) : bareQuestionTools()); return baseAgentOptions("teacher", opts, toPiTools(tools)); } diff --git a/src/core/pending.ts b/src/core/pending.ts index 618e821..7b1f3e2 100644 --- a/src/core/pending.ts +++ b/src/core/pending.ts @@ -32,15 +32,20 @@ export const JOB_TOOLS = ["research", "demo", "visual"] as const; // ─── Tool availability (2026-09 settings seam) ───────────────────────────── -/** The tools a user may toggle off in settings (Wave 2 T2a, user-decided - * 2026-09-15): for now only the SUB-AGENT dispatch tools — quiz, - * ask_user_question and the read tool are always on; - * the long-term vision is that everything becomes toggleable as new - * tool variants (e.g. alternative question tools) ship, so the factory - * seam below is GENERIC: it honors any tool name it receives. New - * toggleables join this list when settings exposes them. - * The names are the MODEL-FACING tool names (research, not researcher; - * pdf is the PDF-reader dispatch, sources v0 2026-09). */ +/** The tools a user may toggle off in settings — THE DEFINITIVE LIST + * (adjudicated 2026-09-19, P2 reconciliation: the sub-agent JOB tools + * only — research/demo/visual/pdf). Deliberately NOT toggleable, ever: + * quiz + ask_user_question (the prober's flow is graded-quiz brackets → + * probe_done; quiz-off would collapse the assessment and teaching + * doctrine is built on graded verification — the wedge danger), the + * learner-filesystem set (user-decided 2026-09, sources v1: always-on), + * plan tools, and probe_done (it is how the phase ENDS — a prober that + * can never conclude is a lockout). + * The write side validates against this list (settings-body.ts: unknown + * key = 400 with the supported list); the read-side sanitizer stays + * lenient for forward-compat. The names are the MODEL-FACING tool names + * (research, not researcher; pdf is the PDF-reader dispatch, sources v0 + * 2026-09). */ export const TOGGLEABLE_TOOLS = ["research", "demo", "visual", "pdf"] as const; /** The resolved settings → factory seam shape (Platform wires chat-handler diff --git a/src/server/chat-handler.ts b/src/server/chat-handler.ts index 7a7a4ab..f80b42d 100644 --- a/src/server/chat-handler.ts +++ b/src/server/chat-handler.ts @@ -210,13 +210,14 @@ export async function handleChatRequest( // Tests inject a factory: the agent is stateless per turn (docs/ // pi-engine-swap.md §2), so each request builds a fresh one off the // transcript — the factory mirrors that seam exactly. - // CROSS-STREAM SEAM (agreed 2026-09-19, settings wave): `toolToggles` + // CROSS-STREAM SEAM (P2 reconciliation, 2026-09-19): `toolToggles` // rides the build opts as the user-level DELTA map (absent key = - // enabled — quiz, ask_user_question, research, demo, visual). The - // Teacher stream adds the field to AgentBuildOptions + the filtering in - // createTeacherTools / the prober's registry this same wave; until that - // merge, the extra property is structurally ignored (a harmless no-op). - const agentOpts: AgentBuildOptions & { toolToggles?: Record } = { + // enabled). The definitive toggleable set is TOGGLEABLE_TOOLS — the + // sub-agent JOB tools (research/demo/visual/pdf; adjudicated with the + // user: quiz/ask/fs/plan/probe_done are always-on). First-class on + // AgentBuildOptions; the factories drop anything explicitly false and + // the settings write side rejects unknown keys. + const agentOpts: AgentBuildOptions = { engine, messages: recordToPiMessages(outgoing), onUsage: collectUsage, diff --git a/src/server/settings-body.ts b/src/server/settings-body.ts index 57e9e99..d61e03b 100644 --- a/src/server/settings-body.ts +++ b/src/server/settings-body.ts @@ -22,6 +22,7 @@ import type { RoleModelOverride, } from "../core/settings.ts"; import { BACKGROUND_MODEL_ROLES } from "../core/settings.ts"; +import { TOGGLEABLE_TOOLS } from "../core/pending.ts"; /** Parsed-or-rejected — `ok: false` carries the 400's error message. */ export type SettingsBodyResult = @@ -166,7 +167,20 @@ export function parseAgentSettingsBody(body: unknown): SettingsBodyResult { describe("tool toggles (core/pending.ts + factories)", () => { it("TOGGLEABLE_TOOLS: the sub-agent dispatch tools; quiz/ask/read stay off the list", () => { - // The user-toggleable vocabulary Platform's settings validates against. - // quiz/ask_user_question/read are always-on FOR NOW (user-decided - // 2026-09-15, sources v0); they join when settings exposes them — the - // factory seam is generic. + // THE DEFINITIVE vocabulary (adjudicated with the user 2026-09-19, P2 + // reconciliation): the job tools only. quiz/ask_user_question are + // permanently always-on (the prober's assessment + teaching doctrine + // are built on graded questions — the wedge danger), as are the + // learner-fs set, plan tools, and probe_done. The settings WRITE side + // validates against this list (settings-body). assert.deepEqual(TOGGLEABLE_TOOLS, ["research", "demo", "visual", "pdf"]); }); diff --git a/tests/tool-toggles-bridge.test.ts b/tests/tool-toggles-bridge.test.ts new file mode 100644 index 0000000..2a4bdac --- /dev/null +++ b/tests/tool-toggles-bridge.test.ts @@ -0,0 +1,78 @@ +/** + * The P2 tool-toggle bridge (2026-09-19): AgentBuildOptions.toolToggles is + * first-class — the agent constructors thread it to the factories' enabled? + * param (an explicit opts.tools registry is never filtered), and the + * settings write side rejects unknown tool keys against TOGGLEABLE_TOOLS + * (the definitive adjudicated list — a persisted `quiz: false` would + * genuinely drop the quiz once the bridge honors the delta). + * + * Parser tests are PURE (no fs — parallel-test discipline); the agent + * construction tests use an in-memory chat fixture like + * question-tools.test.ts. + */ +import assert from "node:assert/strict"; +import { z } from "zod"; +import { describe, it } from "node:test"; +import { createProberAgent, createTeacherAgent } from "../src/agent/teacher-agent.ts"; +import type { ProbeDoneContext } from "../src/agent/tools.ts"; +import { parseAgentSettingsBody } from "../src/server/settings-body.ts"; +import { chatStore } from "../src/store/chats.ts"; +import type { AgentSettings } from "../src/core/settings.ts"; + +describe("AgentBuildOptions.toolToggles (the P2 bridge)", () => { + const toolNames = (agent: { state: { tools: Array<{ name: string }> } }) => + agent.state.tools.map((t) => t.name).sort(); + + it("flows into the teacher's registry: a job-tool delta is a no-op on the bare set", () => { + // No ctx → bare agent: quiz/ask only. A job-tool delta names tools + // that don't exist there — the bridge must not crash on it. + const bare = createTeacherAgent(undefined, { toolToggles: { research: false } }); + assert.deepEqual(toolNames(bare), ["ask_user_question", "quiz"]); + }); + + it("flows into the prober's registry; probe_done survives everything", () => { + const chatId = chatStore.createChat("Toggle bridge test").id; + const ctx: ProbeDoneContext = { chatId, chatStore, deliver: () => {} }; + const prober = createProberAgent(ctx, { toolToggles: { ask_user_question: false } }); + const names = toolNames(prober); + assert.ok(!names.includes("ask_user_question"), "the delta dropped the tool"); + assert.ok(names.includes("probe_done"), "probe_done always-on"); + }); + + it("an explicitly injected opts.tools registry is NEVER toggle-filtered", () => { + const teacher = createTeacherAgent(undefined, { + tools: { + quiz: { name: "quiz", description: "test", clientQuestion: true, schema: z.object({}) }, + }, + toolToggles: { quiz: false }, + }); + assert.deepEqual(toolNames(teacher), ["quiz"]); + }); +}); + +describe("parseAgentSettingsBody: tool keys validate at write time", () => { + const parse = (tools: Record) => + parseAgentSettingsBody({ tools }) as + | { ok: true; settings: AgentSettings } + | { ok: false; error: string }; + + it("a valid job-tool delta parses", () => { + const r = parse({ research: false, pdf: false }); + assert.equal(r.ok, true); + assert.deepEqual(r.ok && r.settings.tools, { research: false, pdf: false }); + }); + + it("unknown keys are rejected with the supported list (the quiz wedge never persists)", () => { + const r = parse({ quiz: false }); + assert.equal(r.ok, false); + assert.match(!r.ok && r.error, /unknown tool "quiz" \(supported: research, demo, visual, pdf\)/); + const typo = parse({ reasearch: false }); + assert.equal(typo.ok, false); + assert.match(!typo.ok && typo.error, /unknown tool "reasearch"/); + }); + + it("absent tools field is fine (absent = enabled everywhere)", () => { + const r = parseAgentSettingsBody({}) as { ok: boolean }; + assert.equal(r.ok, true); + }); +});