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:
@@ -10,6 +10,7 @@ import {
|
|||||||
createProberTools,
|
createProberTools,
|
||||||
createTeacherTools,
|
createTeacherTools,
|
||||||
type ProbeDoneContext,
|
type ProbeDoneContext,
|
||||||
|
type ProberToolContext,
|
||||||
type TeacherToolContext,
|
type TeacherToolContext,
|
||||||
} from "./tools.ts";
|
} from "./tools.ts";
|
||||||
import { toPiTools, type LhTool } from "./lh-tool.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
|
* handoff wake (see tools/probe.ts) and is the transcript seam the
|
||||||
* handler keys teacher construction on.
|
* 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)));
|
return baseAgentOptions("prober", opts, toPiTools(createProberTools(ctx, opts.toolToggles)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+40
-5
@@ -8,7 +8,7 @@ import {
|
|||||||
import { filterEnabledTools, type EnabledTools } from "../core/pending.ts";
|
import { filterEnabledTools, type EnabledTools } from "../core/pending.ts";
|
||||||
import type { JobRecord } from "../core/contracts.ts";
|
import type { JobRecord } from "../core/contracts.ts";
|
||||||
import type { JobStore } from "../core/ports.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 { planTools, type PlanToolContext } from "./tools/plan.ts";
|
||||||
import { probeDoneTool, type ProbeDoneContext } from "./tools/probe.ts";
|
import { probeDoneTool, type ProbeDoneContext } from "./tools/probe.ts";
|
||||||
import { readTool } from "./tools/read.ts";
|
import { readTool } from "./tools/read.ts";
|
||||||
@@ -67,18 +67,40 @@ export const probeQuizTool: LhTool<any, any> = {
|
|||||||
schema: probeQuizInputSchema,
|
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
|
* `enabled` is the resolved settings seam (core/pending.ts): any tool
|
||||||
* explicitly false is dropped from the registry. probe_done stays
|
* explicitly false is dropped from the registry. probe_done stays
|
||||||
* always-on regardless — it is how the phase ENDS; a prober that can
|
* always-on regardless — it is how the phase ENDS; a prober that can
|
||||||
* never conclude is a lockout. */
|
* never conclude is a lockout. */
|
||||||
export function createProberTools(
|
export function createProberTools(
|
||||||
ctx: ProbeDoneContext,
|
ctx: ProberToolContext,
|
||||||
enabled?: EnabledTools,
|
enabled?: EnabledTools,
|
||||||
): Record<string, LhTool<any, any>> {
|
): Record<string, LhTool<any, any>> {
|
||||||
// probe_done is re-added AFTER the filter, always-on by construction — it
|
// 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.
|
// is how the phase ENDS; a prober that can never conclude is a lockout.
|
||||||
const done = probeDoneTool(ctx);
|
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 {
|
return {
|
||||||
...filterEnabledTools(
|
...filterEnabledTools(
|
||||||
{
|
{
|
||||||
@@ -91,6 +113,15 @@ export function createProberTools(
|
|||||||
ls: lsTool,
|
ls: lsTool,
|
||||||
find: findTool,
|
find: findTool,
|
||||||
grep: grepTool,
|
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,
|
...done,
|
||||||
},
|
},
|
||||||
enabled,
|
enabled,
|
||||||
@@ -153,7 +184,7 @@ export interface TeacherToolContext extends PlanToolContext {
|
|||||||
/** Fire the background runner for a freshly created job. The tool input
|
/** Fire the background runner for a freshly created job. The tool input
|
||||||
* rides along — the spawn fires mid-stream, before the tool part is
|
* rides along — the spawn fires mid-stream, before the tool part is
|
||||||
* persisted to the transcript (a transcript scan would race it). */
|
* 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
|
/** Search availability (search port wave, 2026-09-20): the research
|
||||||
* dispatch tool's honest-disable signal — no live engine = the tool
|
* dispatch tool's honest-disable signal — no live engine = the tool
|
||||||
* drops from this registry (JobToolContext.searchAvailable). */
|
* drops from this registry (JobToolContext.searchAvailable). */
|
||||||
@@ -188,7 +219,11 @@ export function createTeacherTools(
|
|||||||
find: findTool,
|
find: findTool,
|
||||||
grep: grepTool,
|
grep: grepTool,
|
||||||
...planTools(ctx),
|
...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,
|
enabled,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -276,6 +276,12 @@ export async function handleChatRequest(
|
|||||||
chatId,
|
chatId,
|
||||||
chatStore: stores.chatStore,
|
chatStore: stores.chatStore,
|
||||||
deliver: (steer) => void deliverJobResult(steer),
|
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,
|
agentOpts,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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", () => {
|
it("the prober's tool registry carries the nodeId-less quiz, probe_done, and the learner-filesystem set", () => {
|
||||||
const chatId = newChat("Prober: registry");
|
const chatId = newChat("Prober: registry");
|
||||||
const tools = createProberTools({ chatId, chatStore, deliver: () => {} });
|
const tools = createProberTools({ chatId, chatStore, deliver: () => {}, jobStore, spawn: () => {} });
|
||||||
assert.deepEqual(Object.keys(tools).sort(), ["ask_user_question", "find", "grep", "ls", "probe_done", "quiz", "read"]);
|
// 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);
|
const schema = quizSchemaOf(tools);
|
||||||
assert.equal(schema.safeParse({ ...validQuiz, nodeId: "anything" }).success, true, "no session check to fail");
|
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", () => {
|
it("createProberTools: absent record = full set; explicit false drops exactly that tool; probe_done survives everything", () => {
|
||||||
const chatId = newChat("Prober: toggles");
|
const chatId = newChat("Prober: toggles");
|
||||||
const ctx = { chatId, chatStore, deliver: () => {} };
|
const ctx = { chatId, chatStore, deliver: () => {}, jobStore, spawn: () => {} };
|
||||||
assert.deepEqual(Object.keys(createProberTools(ctx)).sort(), ["ask_user_question", "find", "grep", "ls", "probe_done", "quiz", "read"]);
|
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 });
|
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),
|
// quiz off is honored by the generic machinery too (future toggleable),
|
||||||
// but probe_done is never droppable — it is how the phase ends.
|
// but probe_done is never droppable — it is how the phase ends.
|
||||||
const noQuiz = createProberTools(ctx, { quiz: false });
|
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");
|
assert.ok(createProberTools(ctx, { probe_done: false }).probe_done, "probe_done always-on");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ import assert from "node:assert/strict";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { describe, it } from "node:test";
|
import { describe, it } from "node:test";
|
||||||
import { createProberAgent, createTeacherAgent } from "../src/agent/teacher-agent.ts";
|
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 { parseAgentSettingsBody } from "../src/server/settings-body.ts";
|
||||||
import { chatStore } from "../src/store/chats.ts";
|
import { chatStore } from "../src/store/chats.ts";
|
||||||
import type { AgentSettings } from "../src/core/settings.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", () => {
|
it("flows into the prober's registry; probe_done survives everything", () => {
|
||||||
const chatId = chatStore.createChat("Toggle bridge test").id;
|
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 prober = createProberAgent(ctx, { toolToggles: { ask_user_question: false } });
|
||||||
const names = toolNames(prober);
|
const names = toolNames(prober);
|
||||||
assert.ok(!names.includes("ask_user_question"), "the delta dropped the tool");
|
assert.ok(!names.includes("ask_user_question"), "the delta dropped the tool");
|
||||||
|
|||||||
@@ -137,3 +137,75 @@ describe("the research registry drop (jobTools)", () => {
|
|||||||
assert.ok(tools.research, "the seam is optional — default is honest availability");
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user