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
+7 -6
View File
@@ -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<string, boolean> } = {
// 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,
+15 -1
View File
@@ -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<T> =
@@ -166,7 +167,20 @@ export function parseAgentSettingsBody(body: unknown): SettingsBodyResult<AgentS
}
const tools = parseBooleanMap(raw.tools, "tools");
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");
if (prompts.error) return { ok: false, error: prompts.error };
if (prompts.map) settings.prompts = prompts.map;