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:
co-authored by
aksalatdev
parent
354bd34c0c
commit
f01fdf3260
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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") {
|
||||
|
||||
Reference in New Issue
Block a user