diff --git a/src/agent/teacher-agent.ts b/src/agent/teacher-agent.ts index 805892b..8e92ae7 100644 --- a/src/agent/teacher-agent.ts +++ b/src/agent/teacher-agent.ts @@ -10,6 +10,7 @@ import { createProberTools, createTeacherTools, type ProbeDoneContext, + type ProberToolContext, type TeacherToolContext, } from "./tools.ts"; import { toPiTools, type LhTool } from "./lh-tool.ts"; @@ -181,7 +182,7 @@ function baseAgentOptions(kind: AgentKind, opts: AgentBuildOptions, tools: Agent * handoff wake (see tools/probe.ts) and is the transcript seam the * handler keys teacher construction on. */ -export function createProberAgent(ctx: ProbeDoneContext, opts: AgentBuildOptions = {}) { +export function createProberAgent(ctx: ProberToolContext, opts: AgentBuildOptions = {}) { return baseAgentOptions("prober", opts, toPiTools(createProberTools(ctx, opts.toolToggles))); } diff --git a/src/agent/tools.ts b/src/agent/tools.ts index 62837c4..239bc7b 100644 --- a/src/agent/tools.ts +++ b/src/agent/tools.ts @@ -8,7 +8,7 @@ import { import { filterEnabledTools, type EnabledTools } from "../core/pending.ts"; import type { JobRecord } from "../core/contracts.ts"; import type { JobStore } from "../core/ports.ts"; -import { jobTools, type DemoInput, type ResearchInput, type VisualInput } from "./tools/jobs.ts"; +import { jobTools, type DemoInput, type PdfInput, type ResearchInput, type VisualInput } from "./tools/jobs.ts"; import { planTools, type PlanToolContext } from "./tools/plan.ts"; import { probeDoneTool, type ProbeDoneContext } from "./tools/probe.ts"; import { readTool } from "./tools/read.ts"; @@ -67,18 +67,40 @@ export const probeQuizTool: LhTool = { schema: probeQuizInputSchema, }; -/** The prober's tool set: probe quiz (no nodeId) + ask + probe_done. +/** The prober's registry context: the probe's own needs (probe_done — + * ProbeDoneContext) PLUS the job-dispatch wiring, since the owner ruling + * (2026-09-20) puts research + pdf in the probe phase's registry too. */ +export interface ProberToolContext extends ProbeDoneContext { + /** Creates the job record (deduped by toolCallId). */ + jobStore: JobStore; + /** Fire the background runner for a freshly created job. */ + spawn: (job: JobRecord, input: ResearchInput | DemoInput | VisualInput | PdfInput) => void; + /** Search availability — the research dispatch tool's honest-disable + * signal (same gate as the teacher's). Absent = available. */ + searchAvailable?: () => boolean; +} + +/** The prober's tool set: probe quiz (no nodeId) + ask + the learner-fs + * set + probe_done + the two phase-omnipresent job dispatch tools. * `enabled` is the resolved settings seam (core/pending.ts): any tool * explicitly false is dropped from the registry. probe_done stays * always-on regardless — it is how the phase ENDS; a prober that can * never conclude is a lockout. */ export function createProberTools( - ctx: ProbeDoneContext, + ctx: ProberToolContext, enabled?: EnabledTools, ): Record> { // probe_done is re-added AFTER the filter, always-on by construction — it // is how the phase ENDS; a prober that can never conclude is a lockout. const done = probeDoneTool(ctx); + // The job tools build over the SAME context/gates as the teacher's + // (one builder, one gating semantic); the prober carries a SUBSET: + const jobs = jobTools({ + chatId: ctx.chatId, + jobStore: ctx.jobStore, + spawn: ctx.spawn, + searchAvailable: ctx.searchAvailable, + }); return { ...filterEnabledTools( { @@ -91,6 +113,15 @@ export function createProberTools( ls: lsTool, find: findTool, grep: grepTool, + // Job dispatch in the probe phase (owner ruling, 2026-09-20): + // research + pdf are ALWAYS available in every phase — the + // prober can verify facts or read learner PDFs while assessing. + // demo/visual stay teach-phase-only (they need the lesson's plan + // context). Same gating as the teacher: toggle-filtered here, + // research subject to the searchAvailable gate (honestly absent + // when no engine is live). + ...(jobs.research ? { research: jobs.research } : {}), + pdf: jobs.pdf!, ...done, }, enabled, @@ -153,7 +184,7 @@ export interface TeacherToolContext extends PlanToolContext { /** Fire the background runner for a freshly created job. The tool input * rides along — the spawn fires mid-stream, before the tool part is * persisted to the transcript (a transcript scan would race it). */ - spawn: (job: JobRecord, input: ResearchInput | DemoInput | VisualInput) => void; + spawn: (job: JobRecord, input: ResearchInput | DemoInput | VisualInput | PdfInput) => void; /** Search availability (search port wave, 2026-09-20): the research * dispatch tool's honest-disable signal — no live engine = the tool * drops from this registry (JobToolContext.searchAvailable). */ @@ -188,7 +219,11 @@ export function createTeacherTools( find: findTool, grep: grepTool, ...planTools(ctx), - ...jobTools({ chatId: ctx.chatId, jobStore: ctx.jobStore, spawn: ctx.spawn }), + // Fix wave (2026-09-20): searchAvailable was RECEIVED here but + // DROPPED when building the inner JobToolContext — the drop-gate + // always took its ?? true default. Threaded now: the live signal + // reaches jobTools' research gate. + ...jobTools({ chatId: ctx.chatId, jobStore: ctx.jobStore, spawn: ctx.spawn, searchAvailable: ctx.searchAvailable }), }, enabled, ); diff --git a/src/server/chat-handler.ts b/src/server/chat-handler.ts index 07347b5..36be74f 100644 --- a/src/server/chat-handler.ts +++ b/src/server/chat-handler.ts @@ -276,6 +276,12 @@ export async function handleChatRequest( chatId, chatStore: stores.chatStore, deliver: (steer) => void deliverJobResult(steer), + // Fix wave (2026-09-20): the prober carries research + pdf + // dispatch (owner ruling — phase-omnipresent), so its ctx + // needs the same job wiring as the teacher's. + jobStore: stores.jobStore, + spawn: (job, input) => void jobRunner.runJob(job.id, input), + searchAvailable, }, agentOpts, ); diff --git a/tests/question-tools.test.ts b/tests/question-tools.test.ts index cd40aca..15cf79c 100644 --- a/tests/question-tools.test.ts +++ b/tests/question-tools.test.ts @@ -145,8 +145,12 @@ describe("the prober's quiz: no nodeId field at all", () => { it("the prober's tool registry carries the nodeId-less quiz, probe_done, and the learner-filesystem set", () => { const chatId = newChat("Prober: registry"); - const tools = createProberTools({ chatId, chatStore, deliver: () => {} }); - assert.deepEqual(Object.keys(tools).sort(), ["ask_user_question", "find", "grep", "ls", "probe_done", "quiz", "read"]); + const tools = createProberTools({ chatId, chatStore, deliver: () => {}, jobStore, spawn: () => {} }); + // Fix wave (2026-09-20): the owner ruling adds research + pdf dispatch + // to the probe phase's registry (demo/visual stay teach-only). + assert.deepEqual(Object.keys(tools).sort(), [ + "ask_user_question", "find", "grep", "ls", "pdf", "probe_done", "quiz", "read", "research", + ]); const schema = quizSchemaOf(tools); assert.equal(schema.safeParse({ ...validQuiz, nodeId: "anything" }).success, true, "no session check to fail"); }); @@ -166,16 +170,18 @@ describe("tool toggles (core/pending.ts + factories)", () => { it("createProberTools: absent record = full set; explicit false drops exactly that tool; probe_done survives everything", () => { const chatId = newChat("Prober: toggles"); - const ctx = { chatId, chatStore, deliver: () => {} }; - assert.deepEqual(Object.keys(createProberTools(ctx)).sort(), ["ask_user_question", "find", "grep", "ls", "probe_done", "quiz", "read"]); + const ctx = { chatId, chatStore, deliver: () => {}, jobStore, spawn: () => {} }; + assert.deepEqual(Object.keys(createProberTools(ctx)).sort(), [ + "ask_user_question", "find", "grep", "ls", "pdf", "probe_done", "quiz", "read", "research", + ]); const noAsk = createProberTools(ctx, { ask_user_question: false }); - assert.deepEqual(Object.keys(noAsk).sort(), ["find", "grep", "ls", "probe_done", "quiz", "read"]); + assert.deepEqual(Object.keys(noAsk).sort(), ["find", "grep", "ls", "pdf", "probe_done", "quiz", "read", "research"]); // quiz off is honored by the generic machinery too (future toggleable), // but probe_done is never droppable — it is how the phase ends. const noQuiz = createProberTools(ctx, { quiz: false }); - assert.deepEqual(Object.keys(noQuiz).sort(), ["ask_user_question", "find", "grep", "ls", "probe_done", "read"]); + assert.deepEqual(Object.keys(noQuiz).sort(), ["ask_user_question", "find", "grep", "ls", "pdf", "probe_done", "read", "research"]); assert.ok(createProberTools(ctx, { probe_done: false }).probe_done, "probe_done always-on"); }); diff --git a/tests/tool-toggles-bridge.test.ts b/tests/tool-toggles-bridge.test.ts index 2a4bdac..e0fc4ce 100644 --- a/tests/tool-toggles-bridge.test.ts +++ b/tests/tool-toggles-bridge.test.ts @@ -14,7 +14,8 @@ 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 type { ProberToolContext } from "../src/agent/tools.ts"; +import { jobStore } from "../src/store/jobs.ts"; import { parseAgentSettingsBody } from "../src/server/settings-body.ts"; import { chatStore } from "../src/store/chats.ts"; import type { AgentSettings } from "../src/core/settings.ts"; @@ -32,7 +33,7 @@ describe("AgentBuildOptions.toolToggles (the P2 bridge)", () => { 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 ctx: ProberToolContext = { chatId, chatStore, deliver: () => {}, jobStore, spawn: () => {} }; 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"); diff --git a/tests/web-search.test.ts b/tests/web-search.test.ts index 90d81ae..4dfd373 100644 --- a/tests/web-search.test.ts +++ b/tests/web-search.test.ts @@ -137,3 +137,75 @@ describe("the research registry drop (jobTools)", () => { assert.ok(tools.research, "the seam is optional — default is honest availability"); }); }); + +// ─── the threaded gate (fix wave, 2026-09-20) ──────────────────────────────── +// +// The fix: createTeacherTools/createProberTools used to DROP searchAvailable +// when building the inner JobToolContext, so jobTools' gate always took its +// ?? true default even when the live wiring said unavailable. These pins +// hold the THREADED path (the registry factories, not jobTools directly). + +import { createProberTools, createTeacherTools } from "../src/agent/tools.ts"; +import type { ChatStore } from "../src/core/ports.ts"; + +/** A chat-store stub — the registry factories only CLOSE OVER it at build + * time (quiz/plan superRefines read it at execute time, never here). */ +const fakeChatStore = { + getChat: () => null, + saveMessages: () => {}, +} as unknown as ChatStore; + +const ctxBase = { chatId: "chat-gate-test", chatStore: fakeChatStore }; + +describe("the threaded searchAvailable gate (registry factories)", () => { + it("createTeacherTools: unavailable drops research on the live wiring path", () => { + const tools = createTeacherTools({ + ...ctxBase, + jobStore: fakeJobStore(), + spawn: () => {}, + searchAvailable: () => false, + }); + assert.equal("research" in tools, false, "the threaded signal reached jobTools' gate"); + assert.ok(tools.demo && tools.visual && tools.pdf, "the other job tools survive"); + }); + + it("createTeacherTools: available keeps research (the gate reads the signal both ways)", () => { + const tools = createTeacherTools({ + ...ctxBase, + jobStore: fakeJobStore(), + spawn: () => {}, + searchAvailable: () => true, + }); + assert.ok(tools.research, "a keyed server exposes research through the teacher"); + }); + + it("createProberTools: research + pdf are phase-omnipresent; the gate drops research only", () => { + const on = createProberTools({ + ...ctxBase, + deliver: () => {}, + jobStore: fakeJobStore(), + spawn: () => {}, + searchAvailable: () => true, + }); + assert.ok(on.research && on.pdf, "the owner ruling: research + pdf in the probe phase"); + + const off = createProberTools({ + ...ctxBase, + deliver: () => {}, + jobStore: fakeJobStore(), + spawn: () => {}, + searchAvailable: () => false, + }); + assert.equal("research" in off, false, "no engine = no research dispatch in the prober either"); + assert.ok(off.pdf && off.probe_done, "pdf is keyless — it survives; probe_done always-on"); + }); + + it("createProberTools: the user toggle drops research independently of the gate", () => { + const tools = createProberTools( + { ...ctxBase, deliver: () => {}, jobStore: fakeJobStore(), spawn: () => {}, searchAvailable: () => true }, + { research: false }, + ); + assert.equal("research" in tools, false, "toggle-filtered like the teacher side"); + assert.ok(tools.pdf, "pdf unaffected by a research toggle"); + }); +});