fix(tools): wire the searchAvailable gate + research/pdf in the probe phase

Fix wave (2026-09-20), two owner-approved fixes:

Fix 1 — the searchAvailable gate was dead code on the live wiring path:
createTeacherTools RECEIVED ctx.searchAvailable but dropped it when
building the inner JobToolContext, so jobTools' research gate always took
its ?? true default (jobTools-direct tests passed; the production path
never saw the signal). Threaded through — the gate now reads the live
server signal on the teacher registry too.

Fix 2 — the prober registry gains the research + pdf dispatch tools
(owner ruling, 2026-09-20): these two are phase-omnipresent — 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 side: toggle-filtered, research subject to the search gate
(honestly absent when no engine is live — the prober's dispatch closes
over the SAME jobTools builder, one gate, one semantics). probe_done
stays always-on-by-construction; the learner-fs set untouched.

- ProberToolContext (extends ProbeDoneContext with the job wiring);
  createProberAgent takes it; chat-handler injects jobStore + spawn +
  searchAvailable into the prober ctx.
- TeacherToolContext.spawn's stale input union fixed (PdfInput was
  missing from the TYPE — runtime already passed it).
- Tests: the threaded-gate describe in web-search.test.ts pins the LIVE
  factory path both ways (teacher + prober); question-tools' registry
  key-sets gain research/pdf; the prober toggle test pins the
  research:false path.

Gates: typecheck, 807/807 tests, lint, build — all green.
This commit is contained in:
amosblomqvist
2026-09-22 16:22:57 +03:00
parent 57dd25993c
commit d85d8a58a0
6 changed files with 135 additions and 14 deletions
+12 -6
View File
@@ -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");
});
+3 -2
View File
@@ -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");
+72
View File
@@ -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");
});
});