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:
@@ -316,9 +316,26 @@ export function concludedByDismissalPair(
|
||||
* conclude call or a dismissal marker deactivates. The ack result at pose
|
||||
* time does NOT (the problem stays on screen through nudges; the dock's
|
||||
* subject is the call, not a dangling result). */
|
||||
/** The ids of tool calls whose results are CLEAN (not errored) — the only
|
||||
* calls that ever happened, per the honest-activation ruling. */
|
||||
function cleanToolCallIds(messages: readonly LhMessage[]): Set<string> {
|
||||
const ids = new Set<string>();
|
||||
for (const m of messages) {
|
||||
if ((m as { role?: string }).role !== "toolResult") continue;
|
||||
const result = m as ToolResultMessage;
|
||||
if (!result.isError) ids.add(result.toolCallId);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
export function activeProblemOf(
|
||||
messages: readonly LhMessage[],
|
||||
): { toolCallId: string; input: unknown } | null {
|
||||
// HONEST ACTIVATION (owner ruling, 2026-10-05 — the BKT incident):
|
||||
// calls count only through their RESULTS — a problem activates on its
|
||||
// clean ack, a conclude frees on its clean ack; errored and dangling
|
||||
// calls are skipped ("either it goes through or it doesn't").
|
||||
const cleanCallIds = cleanToolCallIds(messages);
|
||||
for (let mi = messages.length - 1; mi >= 0; mi--) {
|
||||
const m = messages[mi]!;
|
||||
const role = (m as { role?: string }).role;
|
||||
@@ -332,9 +349,13 @@ export function activeProblemOf(
|
||||
for (let bi = assistant.content.length - 1; bi >= 0; bi--) {
|
||||
const block = assistant.content[bi]!;
|
||||
if (block.type !== "toolCall") continue;
|
||||
if (block.name === PRACTICE_CONCLUDE_TOOL) return null;
|
||||
if (block.name === PRACTICE_CONCLUDE_TOOL) {
|
||||
if (cleanCallIds.has(block.id)) return null; // a CLEAN conclude frees
|
||||
continue; // errored/dangling conclude: the free never happened
|
||||
}
|
||||
if (block.name !== PRACTICE_PROBLEM_TOOL) continue;
|
||||
return { toolCallId: block.id, input: block.arguments };
|
||||
if (cleanCallIds.has(block.id)) return { toolCallId: block.id, input: block.arguments };
|
||||
// errored/dangling pose — it did not go through; keep scanning
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -347,6 +368,7 @@ export function activeProblemOf(
|
||||
* (input-events). Newest-marker-wins scan: the first dismissal marker or
|
||||
* conclude call from the end decides. */
|
||||
export function pendingProblemDismissalOf(messages: readonly LhMessage[]): string | null {
|
||||
const cleanCallIds = cleanToolCallIds(messages);
|
||||
for (let mi = messages.length - 1; mi >= 0; mi--) {
|
||||
const m = messages[mi]!;
|
||||
const role = (m as { role?: string }).role;
|
||||
@@ -362,8 +384,14 @@ export function pendingProblemDismissalOf(messages: readonly LhMessage[]): strin
|
||||
for (let bi = assistant.content.length - 1; bi >= 0; bi--) {
|
||||
const block = assistant.content[bi]!;
|
||||
if (block.type !== "toolCall" && block.type !== "text") continue;
|
||||
if (block.type === "toolCall" && block.name === PRACTICE_CONCLUDE_TOOL) return null;
|
||||
if (block.type === "toolCall" && block.name === PRACTICE_PROBLEM_TOOL) return null;
|
||||
if (block.type === "toolCall" && block.name === PRACTICE_CONCLUDE_TOOL) {
|
||||
if (cleanCallIds.has(block.id)) return null; // a CLEAN conclude settles it
|
||||
continue; // errored/dangling: it never happened
|
||||
}
|
||||
if (block.type === "toolCall" && block.name === PRACTICE_PROBLEM_TOOL) {
|
||||
if (cleanCallIds.has(block.id)) return null; // a CLEAN pose supersedes it
|
||||
continue;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user