feat(tools): the P2 toggle bridge — toolToggles first-class, definitive vocabulary

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.
This commit is contained in:
amosblomqvist
2026-09-19 18:16:17 +03:00
parent cb4fe25c89
commit 6c42393e42
6 changed files with 132 additions and 24 deletions
+12 -4
View File
@@ -15,7 +15,7 @@ import {
} from "./tools.ts"; } from "./tools.ts";
import { toPiTools, type LhTool } from "./lh-tool.ts"; import { toPiTools, type LhTool } from "./lh-tool.ts";
import { quizInputObject, quizInputProblems } from "../core/question-core.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 * The two teaching-side agents (docs/probe-teach-split.md): the PROBER and
@@ -88,8 +88,15 @@ export interface AgentBuildOptions {
onUsage?: StepUsageCollector; onUsage?: StepUsageCollector;
/** Safety net (the old stepCountIs). Default 20 turns. */ /** Safety net (the old stepCountIs). Default 20 turns. */
maxTurns?: number; 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<string, LhTool<any, any>>; tools?: Record<string, LhTool<any, any>>;
/** 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. * handler keys teacher construction on.
*/ */
export function createProberAgent(ctx: ProbeDoneContext, opts: AgentBuildOptions = {}) { 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, ctx?: TeacherToolContext,
opts: AgentBuildOptions = {}, 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)); return baseAgentOptions("teacher", opts, toPiTools(tools));
} }
+14 -9
View File
@@ -32,15 +32,20 @@ export const JOB_TOOLS = ["research", "demo", "visual"] as const;
// ─── Tool availability (2026-09 settings seam) ───────────────────────────── // ─── Tool availability (2026-09 settings seam) ─────────────────────────────
/** The tools a user may toggle off in settings (Wave 2 T2a, user-decided /** The tools a user may toggle off in settings — THE DEFINITIVE LIST
* 2026-09-15): for now only the SUB-AGENT dispatch tools — quiz, * (adjudicated 2026-09-19, P2 reconciliation: the sub-agent JOB tools
* ask_user_question and the read tool are always on; * only — research/demo/visual/pdf). Deliberately NOT toggleable, ever:
* the long-term vision is that everything becomes toggleable as new * quiz + ask_user_question (the prober's flow is graded-quiz brackets →
* tool variants (e.g. alternative question tools) ship, so the factory * probe_done; quiz-off would collapse the assessment and teaching
* seam below is GENERIC: it honors any tool name it receives. New * doctrine is built on graded verification — the wedge danger), the
* toggleables join this list when settings exposes them. * learner-filesystem set (user-decided 2026-09, sources v1: always-on),
* The names are the MODEL-FACING tool names (research, not researcher; * plan tools, and probe_done (it is how the phase ENDS — a prober that
* pdf is the PDF-reader dispatch, sources v0 2026-09). */ * 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; export const TOGGLEABLE_TOOLS = ["research", "demo", "visual", "pdf"] as const;
/** The resolved settings → factory seam shape (Platform wires chat-handler /** The resolved settings → factory seam shape (Platform wires chat-handler
+7 -6
View File
@@ -210,13 +210,14 @@ export async function handleChatRequest(
// Tests inject a factory: the agent is stateless per turn (docs/ // 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 // pi-engine-swap.md §2), so each request builds a fresh one off the
// transcript — the factory mirrors that seam exactly. // 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 = // rides the build opts as the user-level DELTA map (absent key =
// enabled — quiz, ask_user_question, research, demo, visual). The // enabled). The definitive toggleable set is TOGGLEABLE_TOOLS — the
// Teacher stream adds the field to AgentBuildOptions + the filtering in // sub-agent JOB tools (research/demo/visual/pdf; adjudicated with the
// createTeacherTools / the prober's registry this same wave; until that // user: quiz/ask/fs/plan/probe_done are always-on). First-class on
// merge, the extra property is structurally ignored (a harmless no-op). // AgentBuildOptions; the factories drop anything explicitly false and
const agentOpts: AgentBuildOptions & { toolToggles?: Record<string, boolean> } = { // the settings write side rejects unknown keys.
const agentOpts: AgentBuildOptions = {
engine, engine,
messages: recordToPiMessages(outgoing), messages: recordToPiMessages(outgoing),
onUsage: collectUsage, onUsage: collectUsage,
+15 -1
View File
@@ -22,6 +22,7 @@ import type {
RoleModelOverride, RoleModelOverride,
} from "../core/settings.ts"; } from "../core/settings.ts";
import { BACKGROUND_MODEL_ROLES } 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. */ /** Parsed-or-rejected — `ok: false` carries the 400's error message. */
export type SettingsBodyResult<T> = export type SettingsBodyResult<T> =
@@ -166,7 +167,20 @@ export function parseAgentSettingsBody(body: unknown): SettingsBodyResult<AgentS
} }
const tools = parseBooleanMap(raw.tools, "tools"); const tools = parseBooleanMap(raw.tools, "tools");
if (tools.error) return { ok: false, error: tools.error }; if (tools.error) return { ok: false, error: tools.error };
if (tools.map) settings.tools = tools.map; if (tools.map) {
// Unknown tool keys are rejected at WRITE time (user decision
// 2026-09-19): the delta is inert while unread, but once the bridge
// honors it a persisted `quiz: false` would genuinely drop the quiz —
// and the definitive list is the adjudicated job-tools-only set.
// The read-side sanitizer stays lenient for forward-compat.
const supported = TOGGLEABLE_TOOLS as readonly string[];
for (const key of Object.keys(tools.map)) {
if (!supported.includes(key)) {
return { ok: false, error: `unknown tool "${key}" (supported: ${supported.join(", ")})` };
}
}
settings.tools = tools.map;
}
const prompts = parseBooleanMap(raw.prompts, "prompts"); const prompts = parseBooleanMap(raw.prompts, "prompts");
if (prompts.error) return { ok: false, error: prompts.error }; if (prompts.error) return { ok: false, error: prompts.error };
if (prompts.map) settings.prompts = prompts.map; if (prompts.map) settings.prompts = prompts.map;
+6 -4
View File
@@ -155,10 +155,12 @@ describe("the prober's quiz: no nodeId field at all", () => {
describe("tool toggles (core/pending.ts + factories)", () => { describe("tool toggles (core/pending.ts + factories)", () => {
it("TOGGLEABLE_TOOLS: the sub-agent dispatch tools; quiz/ask/read stay off the list", () => { it("TOGGLEABLE_TOOLS: the sub-agent dispatch tools; quiz/ask/read stay off the list", () => {
// The user-toggleable vocabulary Platform's settings validates against. // THE DEFINITIVE vocabulary (adjudicated with the user 2026-09-19, P2
// quiz/ask_user_question/read are always-on FOR NOW (user-decided // reconciliation): the job tools only. quiz/ask_user_question are
// 2026-09-15, sources v0); they join when settings exposes them — the // permanently always-on (the prober's assessment + teaching doctrine
// factory seam is generic. // 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"]); assert.deepEqual(TOGGLEABLE_TOOLS, ["research", "demo", "visual", "pdf"]);
}); });
+78
View File
@@ -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<string, boolean>) =>
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);
});
});