fix: honest activation + turn-scoped practice gates (the BKT stuck-slot incident)

The bkt-practice record showed ONE turn in which the model concluded
(clean ack) and then every pose died on 'a problem is already active'
until it gave up ('the problem slot is stuck — the conclude calls report
success'). Two flaws, both fixed:

1. GATES READ A FROZEN RECORD. problem/conclude gates re-read the stored
   record per execute — but the record freezes at the turn's write-ahead
   (persistTurn lands only at turn END), so a mid-turn conclude → pose
   pair could never see each other. practiceTools now builds ONE
   turn-scoped gate (createPracticeGate) seeded once from the record;
   the executes flip it. Fresh closure per turn (the agent is built per
   request) — no cross-turn leakage.

2. PHANTOM ACTIVATION. activeProblemOf ignored tool RESULTS, so the
   composer mounted from a pose the gate itself had refused (owner
   ruling: 'either it goes through or it doesn't'). Honest activation:
   a problem activates only on its clean ack; a conclude frees only on
   its clean ack; errored and dangling calls are skipped. Retroactively
   heals the BKT record (all its poses errored, all concludes clean →
   no active problem). pendingProblemDismissalOf follows the same law
   via the shared cleanToolCallIds helper.

Pinned: the mid-turn conclude→pose pair works in one toolset; the gate
re-arms after a successful pose; lone errored/dangling poses activate
nothing; a clean pose under a rejection stays active; an errored
conclude frees nothing.
This commit is contained in:
amosblomqvist
2026-10-05 01:22:25 +03:00
parent 9ccb33ea3b
commit e8fe5da6b1
4 changed files with 146 additions and 36 deletions
+21 -5
View File
@@ -17,7 +17,7 @@ import {
PRACTICE_PROBLEM_TOOL,
type LhMessage,
} from "../src/core/native-messages.ts";
import { concludeTool, problemTool, practiceTools } from "../src/agent/tools/practice.ts";
import { concludeTool, createPracticeGate, problemTool, practiceTools } from "../src/agent/tools/practice.ts";
import { composeInstructions, createPracticeAgent } from "../src/agent/teacher-agent.ts";
import { allSegmentPaths } from "../src/agent/prompt-segments.ts";
@@ -95,14 +95,14 @@ const CONCLUDE_INPUT = {
describe("the practice tool gating law", () => {
it("problem acks on a quiet record and ends the turn (terminate)", async () => {
const result = await problemTool(ctxOf([])).execute("call_x", PROBLEM_INPUT as never);
const result = await problemTool(createPracticeGate(ctxOf([]))).execute("call_x", PROBLEM_INPUT as never);
assert.equal(result.isError, undefined);
assert.equal(result.terminate, true);
assert.match((result.content[0] as { text: string }).text, /Problem posed/);
});
it("problem is REJECTED while a problem is active (conclude first)", async () => {
const result = await problemTool(ctxOf(activeProblemRecord())).execute("call_x", PROBLEM_INPUT as never);
const result = await problemTool(createPracticeGate(ctxOf(activeProblemRecord()))).execute("call_x", PROBLEM_INPUT as never);
assert.equal(result.isError, true);
assert.deepEqual((result.details as { problems: string[] }).problems, [
"a problem is already active — call conclude on it before posing the next one",
@@ -110,7 +110,7 @@ describe("the practice tool gating law", () => {
});
it("conclude is REJECTED with nothing active", async () => {
const result = await concludeTool(ctxOf([])).execute("call_x", CONCLUDE_INPUT as never);
const result = await concludeTool(createPracticeGate(ctxOf([]))).execute("call_x", CONCLUDE_INPUT as never);
assert.equal(result.isError, true);
assert.deepEqual((result.details as { problems: string[] }).problems, [
"no active problem to conclude — pose one first",
@@ -118,10 +118,26 @@ describe("the practice tool gating law", () => {
});
it("conclude acks on the active problem and the turn CONTINUES (no terminate)", async () => {
const result = await concludeTool(ctxOf(activeProblemRecord())).execute("call_x", CONCLUDE_INPUT as never);
const result = await concludeTool(createPracticeGate(ctxOf(activeProblemRecord()))).execute("call_x", CONCLUDE_INPUT as never);
assert.equal(result.isError, undefined);
assert.equal(result.terminate, undefined);
});
it("THE BKT INCIDENT — a mid-turn conclude → pose pair works (the gate is turn-scoped, not record-frozen)", async () => {
// One turn, one toolset: the record froze at the write-ahead with
// the old problem active. The gates used to re-read that frozen
// record per execute, so the pose after a successful conclude
// always died on "a problem is already active" until the model
// gave up ("the problem slot is stuck").
const gate = createPracticeGate(ctxOf(activeProblemRecord()));
const tools = { problem: problemTool(gate), conclude: concludeTool(gate) };
const conclude = await tools.conclude.execute("call_c1", CONCLUDE_INPUT as never);
assert.equal(conclude.isError, undefined, "the conclude acks against the seeded record");
const pose = await tools.problem.execute("call_p2", PROBLEM_INPUT as never);
assert.equal(pose.isError, undefined, "the freed gate lets the next problem through — SAME turn");
const greedy = await tools.problem.execute("call_p3", PROBLEM_INPUT as never);
assert.equal(greedy.isError, true, "the gate re-arms on the successful pose");
});
});
// ── the registry + the agent ────────────────────────────────────────────────