fix: require a successful probe result before handoff

Adapt the bounded probe-completion foundation from PR #130 by aksalatdev (source commit 535202bebe038fa1e0648f5df809d5ccfc26d071), not its mixed stream-salvage implementation.

Require a non-failed call followed by a matching probe_done result with neither isError nor explicit details.ok:false. Preserve older clean acknowledgments, saved attempts, and all existing handoff/retry/provider policies. Share the native completion seam with phase detection and exclude explicit refusals from UI phases.

Verify retained failed-probe Retry recovery with real agent/tool composition and synthetic engines: one fresh conclusion, one handoff, then teacher selection, without stored-tool replay. Focused tests 58/58; full tests 2473/2473; typecheck and full ESLint pass in isolated storage with scrubbed provider credentials.

Co-authored-by: aksalatdev <73862605+aksalatdev@users.noreply.github.com>
This commit is contained in:
amosblomqvist
2026-10-27 20:49:04 +02:00
co-authored by aksalatdev
parent 354bd34c0c
commit f01fdf3260
7 changed files with 178 additions and 32 deletions
+3 -2
View File
@@ -42,8 +42,9 @@ function probeConcluded(messages: readonly LhMessage[]): boolean {
* live), so the agent sees the fixed PROBE_DONE_ACK_TEXT — never an
* echo. details = {ok:true} only.
*
* The first probe_done in the transcript is also the construction seam:
* from that message on, every request builds the TEACHER agent.
* The first concluded probe_done (non-failed call plus matching successful
* result) is also the construction seam: from that result on, every request
* builds the TEACHER agent. Bare calls and refused results keep probing.
*/
export function probeDoneTool(ctx: ProbeDoneContext): Record<string, AgentTool<any>> {
return {
+27 -7
View File
@@ -669,15 +669,35 @@ export function danglingQuestionOf(
return null;
}
/** The probe→teach construction seam (chat-handler): a probe_done call in
* the transcript — any state — means the probe is concluded: teach. */
export function hasProbeDone(messages: readonly LhMessage[]): boolean {
/** Successful probe_done result MESSAGE ids, in transcript order. A call
* from a failed/aborted generation grants no completion authority. Its
* later result must match both call id and tool name, must not be an
* error, and must not explicitly refuse with details.ok:false. Older
* clean acknowledgments without ok:true still count. No history changes. */
export function probeDoneResultIds(messages: readonly LhMessage[]): Set<string> {
const calls = new Set<string>();
const results = new Set<string>();
for (const m of messages) {
if ((m as { role?: string }).role !== "assistant") continue;
const assistant = m as AssistantMessage;
if (assistant.content.some((b) => b.type === "toolCall" && b.name === "probe_done")) return true;
if (m.role === "assistant") {
if (m.stopReason === "error" || m.stopReason === "aborted") continue;
for (const block of m.content) {
if (block.type === "toolCall" && block.name === "probe_done") calls.add(block.id);
}
} else if (
m.role === "toolResult" && m.toolName === "probe_done" &&
calls.has(m.toolCallId) && !m.isError &&
(m.details as { ok?: unknown } | null | undefined)?.ok !== false
) {
results.add(m.id);
}
}
return false;
return results;
}
/** The probe→teach construction seam (chat-handler and the tool guard):
* only a concluded call plus a matching successful result means teach. */
export function hasProbeDone(messages: readonly LhMessage[]): boolean {
return probeDoneResultIds(messages).size > 0;
}
/** Every toolResult id in the record — answer-event validation (the call
+8 -11
View File
@@ -33,7 +33,7 @@
* Both are strictly sequential; the two derivations agree on every settled
* record (a call's part projects into the same view boundary).
*
* Pure: no imports, no runtime deps — safe on server, client, and tests.
* Pure: only browser-safe core helpers — safe on server, client, and tests.
*/
/** The three phases of a session, as the picker / header / dividers see them. */
@@ -80,7 +80,10 @@ function advance(
// plan → teach only via the first nodeId-tagged quiz CALL (the call,
// not its answer — an errored-and-retried call still proves teaching
// began, and the retry lands in the same group anyway).
if (phase === "probe" && part.type === "tool-probe_done" && part.state === "output-available") {
if (
phase === "probe" && part.type === "tool-probe_done" && part.state === "output-available" &&
(part.output as { ok?: unknown } | null | undefined)?.ok !== false
) {
phase = "plan";
}
// plan → teach via the first nodeId-tagged quizzing CALL — quiz OR
@@ -153,7 +156,7 @@ export function phaseTimeline(messages: readonly PhaseMessage[]): Phase3[] {
// ─── the native fold (Wave D: record-side phase over LhMessage[]) ───────────
import type { LhMessage } from "./native-messages.ts";
import { probeDoneResultIds, type LhMessage } from "./native-messages.ts";
/** The phase at `upToIndex` (inclusive; default: the last message) of a
* NATIVE record. probe→plan advances at the probe_done call's clean
@@ -163,18 +166,12 @@ import type { LhMessage } from "./native-messages.ts";
export function phaseAt3Lh(messages: readonly LhMessage[], upToIndex?: number): Phase3 {
let phase: Phase3 = "probe";
const last = Math.min(upToIndex ?? messages.length - 1, messages.length - 1);
const concluded = probeDoneResultIds(messages.slice(0, last + 1));
for (let i = 0; i <= last; i++) {
const m = messages[i]!;
const role = (m as { role?: string }).role;
if (role === "toolResult") {
const result = m as { toolName?: string; isError?: boolean };
if (
phase === "probe" &&
result.toolName === "probe_done" &&
!result.isError
) {
phase = "plan";
}
if (phase === "probe" && concluded.has(m.id)) phase = "plan";
} else if (role === "assistant") {
const content = (m as { content?: ReadonlyArray<{ type?: string; name?: string; arguments?: unknown }> }).content ?? [];
if (phase === "plan") {