Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions src/degenerate-turn.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import type { TagEchoFilterStats } from "./loop/tag-echo-filter.js";

// #673: detection of degenerate terminal turns — upstream finishes normally
// (end_turn / stop / completed) but the client receives zero visible text and
// zero tool calls. Observed cause: the turn's only text was a render-tag echo
// the stripper missed (typo'd tag name), so the agent receives an empty turn
// mid-orchestration and stalls until manually nudged. The condition is
// deliberately recall-first: a legitimately empty text-only terminal turn is
// rare and equally confusing to an agentic client, so it warns too. Callers
// gate on their wire's own state (reason seen, tool calls emitted, thinking
// presence) and feed the filter's lifetime text accounting.
export interface TurnOutcome {
/** Wire-native reason observed (stop_reason / finish_reason / status), if any. */
reason: string | undefined;
/** The wire's normal-completion reason ("end_turn" / "stop" / "completed"). */
terminalReason: string;
toolCalls: number;
/** Lifetime visible-text accounting (summed across text fields/blocks). */
text: TagEchoFilterStats;
sawThinking: boolean;
/** Wire label for logs, e.g. "anthropic" or "plugin-passthrough-openai". */
wire: string;
}

export function degenerateTurnWarning(o: TurnOutcome): string | null {
if ((o.reason ?? o.terminalReason) !== o.terminalReason) return null;
if (o.toolCalls > 0) return null;
if (o.text.outputChars > 0) return null;
const bits: string[] = [];
if (o.sawThinking) bits.push("thinking present");
if (o.text.dropped && o.text.inputChars > 0) bits.push(`${o.text.inputChars} chars of emitted text stripped as render-tag echo`);
else bits.push("no visible text emitted");
return (
`[degenerate-turn] ${o.wire}: turn ended ${o.terminalReason} with zero visible text and zero tool calls (${bits.join("; ")}) ` +
`— the agent receives an empty turn and may stall until nudged (#673)`
);
}
20 changes: 19 additions & 1 deletion src/loop/adapter-anthropic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { CoreMessage } from "acp-kernel";
import { coreToAnthropic, extractSystem, buildSystem, type AnthropicRequestBody } from "acp-kernel/wire";
import { buildVisibilityMarker } from "../compress-loop.js";
import { createTagEchoFilter } from "./tag-echo-filter.js";
import { degenerateTurnWarning } from "../degenerate-turn.js";
import { log as loggerLog } from "../logger.js";
import type {
CompressLoopAdapter,
Expand Down Expand Up @@ -222,6 +223,17 @@ export function createAnthropicAdapter(requestBody: Record<string, unknown>, ori
loggerLog("warn", `[tag-echo] stripped model-emitted render tag: ${snippet.slice(0, 80).replace(/\n/g, " ")}`);
});
let lastTextIndex: number | null = null;
let sawThinking = false;
let toolCallsEmitted = 0;
let degenerateWarned = false;
const maybeWarnDegenerate = (reason: string | undefined) => {
if (degenerateWarned) return;
const msg = degenerateTurnWarning({ reason, terminalReason: "end_turn", toolCalls: toolCallsEmitted, text: tagFilter.stats(), sawThinking, wire: "anthropic" });
if (msg) {
degenerateWarned = true;
loggerLog("warn", msg);
}
};

for await (const eventStr of iterSseEvents(upstream)) {
const parsed = parseAnthropicSse(eventStr);
Expand Down Expand Up @@ -267,7 +279,10 @@ export function createAnthropicAdapter(requestBody: Record<string, unknown>, ori
const id = typeof block.id === "string" ? block.id : `toolu_${upstreamIndex}`;
pending.set(upstreamIndex, { id, name, json: "" });
} else {
if (block.type === "thinking" || block.type === "redacted_thinking") thinkingIndexes.add(upstreamIndex);
if (block.type === "thinking" || block.type === "redacted_thinking") {
thinkingIndexes.add(upstreamIndex);
sawThinking = true;
}
const ci = clientIndex++;
indexMap.set(upstreamIndex, ci);
openBlocks.push(ci);
Expand Down Expand Up @@ -312,6 +327,7 @@ export function createAnthropicAdapter(requestBody: Record<string, unknown>, ori
const tb = pending.get(upstreamIndex);
if (tb) {
pending.delete(upstreamIndex);
toolCallsEmitted++;
yield {
kind: "tool_call",
name: tb.name,
Expand Down Expand Up @@ -376,6 +392,7 @@ export function createAnthropicAdapter(requestBody: Record<string, unknown>, ori
cachedTokens: roundCached,
} as ParsedStreamEvent;
}
maybeWarnDegenerate(stopReason);
yield { kind: "done", finishReason: stopReason } as ParsedStreamEvent;
} else if (type === "message_stop") {
if (lastTextIndex !== null) {
Expand All @@ -394,6 +411,7 @@ export function createAnthropicAdapter(requestBody: Record<string, unknown>, ori
cachedTokens: roundCached,
} as ParsedStreamEvent;
}
maybeWarnDegenerate(stopReason);
yield { kind: "done", finishReason: stopReason ?? "end_turn" } as ParsedStreamEvent;
} else if (round === 1) {
yield { kind: "meta", chunk: rawBuf, firstRoundOnly: true } as ParsedStreamEvent;
Expand Down
19 changes: 19 additions & 0 deletions src/loop/adapter-openai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { CoreMessage } from "acp-kernel";
import { coreToOpenai, injectOpenaiSystem } from "acp-kernel/wire";
import { buildVisibilityMarker } from "../compress-loop.js";
import { createTagEchoFilter } from "./tag-echo-filter.js";
import { degenerateTurnWarning } from "../degenerate-turn.js";
import { log as loggerLog } from "../logger.js";
import { systemToUser } from "../util.js";

Expand Down Expand Up @@ -229,6 +230,17 @@ export function createOpenaiAdapter(requestBody: Record<string, unknown>, client
yield { kind: "text", delta: tail, raw: buildContent(tail) } as ParsedStreamEvent;
}
};
let sawReasoning = false;
let toolCallsEmitted = 0;
let degenerateWarned = false;
const maybeWarnDegenerate = (reason: string | undefined) => {
if (degenerateWarned) return;
const msg = degenerateTurnWarning({ reason, terminalReason: "stop", toolCalls: toolCallsEmitted, text: tagFilter.stats(), sawThinking: sawReasoning, wire: "openai" });
if (msg) {
degenerateWarned = true;
loggerLog("warn", msg);
}
};
// Raw tool_call chunks in arrival order. Backends (SGLang/vLLM)
// stream a tool name across MULTIPLE deltas — the first fragment
// carries the name, continuation fragments carry empty names.
Expand All @@ -241,6 +253,7 @@ export function createOpenaiAdapter(requestBody: Record<string, unknown>, client
const flushPendingAsStructured = function* (): Generator<ParsedStreamEvent> {
for (const [, tc] of pending) {
if (tc.name.length > 0 || tc.id.length > 0) {
toolCallsEmitted++;
yield {
kind: "tool_call",
name: tc.name,
Expand Down Expand Up @@ -269,6 +282,7 @@ export function createOpenaiAdapter(requestBody: Record<string, unknown>, client
}
for (const [idx, tc] of pending) {
if (realIndexes.has(idx) && (tc.name.length > 0 || tc.id.length > 0)) {
toolCallsEmitted++;
yield {
kind: "tool_call",
name: tc.name,
Expand All @@ -288,6 +302,7 @@ export function createOpenaiAdapter(requestBody: Record<string, unknown>, client
}
for (const [idx, tc] of pending) {
if (!realIndexes.has(idx) && tc.name.length > 0) {
toolCallsEmitted++;
yield {
kind: "tool_call",
name: tc.name,
Expand All @@ -312,6 +327,7 @@ export function createOpenaiAdapter(requestBody: Record<string, unknown>, client
if (sawRealToolCall) {
yield { kind: "meta", chunk: Buffer.from(eventStr + "\n\n", "utf8") } as ParsedStreamEvent;
}
maybeWarnDegenerate("stop");
yield { kind: "done", finishReason: "stop", ...(sawRealToolCall ? { suppressCompletion: true } : {}) } as ParsedStreamEvent;
continue;
}
Expand Down Expand Up @@ -370,9 +386,11 @@ export function createOpenaiAdapter(requestBody: Record<string, unknown>, client
// to the text/reasoning branches (which would re-emit the same
// bytes after the finish reason).
yield { kind: "meta", chunk } as ParsedStreamEvent;
maybeWarnDegenerate(finishReason);
yield { kind: "done", finishReason, suppressCompletion: true } as ParsedStreamEvent;
continue;
} else {
maybeWarnDegenerate(finishReason);
yield {
kind: "done",
finishReason: hadToolCalls && finishReason === "stop" ? "tool_calls" : finishReason,
Expand All @@ -383,6 +401,7 @@ export function createOpenaiAdapter(requestBody: Record<string, unknown>, client
if (!delta) continue;

if (typeof delta.reasoning_content === "string" && delta.reasoning_content.length > 0) {
sawReasoning = true;
yield { kind: "reasoning", delta: delta.reasoning_content, raw: finishReason ? stripFinishReasonChunk(rawBuf) : rawBuf } as ParsedStreamEvent;
}

Expand Down
20 changes: 18 additions & 2 deletions src/loop/adapter-responses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ import type { CoreMessage } from "acp-kernel";
import { coreToResponses, injectResponsesDeveloperMessage, patchResponsesInput, type ResponseInputItem, type ResponsesProjection } from "acp-kernel/wire";
import { buildVisibilityMarker } from "../compress-loop.js";
import { hashId } from "../util.js";
import { createTagEchoFilter, stripResponsesText, containsRenderTagText } from "./tag-echo-filter.js";
import { createTagEchoFilter, stripResponsesText, containsRenderTagText, ACP_NAME_ALT } from "./tag-echo-filter.js";
import { degenerateTurnWarning } from "../degenerate-turn.js";
import { log as loggerLog } from "../logger.js";
import { ACP_TEXT_OPEN, ACP_TEXT_CLOSE, ACP_STATUS_OPEN, ACP_STATUS_CLOSE, ACP_SEARCH_OPEN, ACP_SEARCH_CLOSE, ACP_DECOMPRESS_OPEN, ACP_DECOMPRESS_CLOSE, COMPRESS_TOOL_NAME, PROXY_TOOL_NAMES } from "../compress-tool.js";
import type { BiliMessage } from "acp-kernel/wire";
Expand Down Expand Up @@ -117,7 +118,7 @@ export function dropWhitespaceResponsesMessages(input: unknown): number {
return dropped;
}

const RENDER_TAG_RE = /\x3cacp\s[^>]*\x3e[^<]*\x3c\/acp\x3e|\x3cacp\s[^>]*\/\x3e/g;
const RENDER_TAG_RE = new RegExp("\x3c" + ACP_NAME_ALT + "\\s[^\x3e]*\x3e[^\x3c]*\x3c\\/" + ACP_NAME_ALT + "\x3e|\x3c" + ACP_NAME_ALT + "\\s[^\x3e]*\\/\x3e", "g");

function stripRenderTags(text: string): string {
return text.replace(RENDER_TAG_RE, "");
Expand Down Expand Up @@ -316,6 +317,17 @@ export function createResponsesAdapter(textProtocol?: boolean, projection?: Resp
yield { kind: "text", delta: tail, ...(raw ? { raw } : {}) } as ParsedStreamEvent;
}
};
let sawReasoning = false;
let toolCallsEmitted = 0;
let degenerateWarned = false;
const maybeWarnDegenerate = (reason: string | undefined) => {
if (degenerateWarned) return;
const msg = degenerateTurnWarning({ reason, terminalReason: "completed", toolCalls: toolCallsEmitted, text: tagFilter.stats(), sawThinking: sawReasoning, wire: "responses" });
if (msg) {
degenerateWarned = true;
loggerLog("warn", msg);
}
};
for await (const eventStr of iterSseEvents(upstream)) {
const explicitType = extractEventType(eventStr);
const dataLine = extractDataLine(eventStr);
Expand All @@ -332,6 +344,7 @@ export function createResponsesAdapter(textProtocol?: boolean, projection?: Resp
// back to it when no event: line is present (explicit line still wins).
const type = explicitType ?? (typeof obj.type === "string" ? obj.type : null);
if (!type) continue;
if (type.startsWith("response.reasoning")) sawReasoning = true;
const rawBuf = Buffer.from(eventStr + "\n\n", "utf8");
if (round === 1 && typeof obj.output_index === "number") {
outputIndex = Math.max(outputIndex, obj.output_index + 1);
Expand Down Expand Up @@ -422,6 +435,7 @@ export function createResponsesAdapter(textProtocol?: boolean, projection?: Resp
if (fc) {
if (typeof item.arguments === "string" && item.arguments) fc.arguments = item.arguments;
pending.delete(itemId);
toolCallsEmitted++;
yield {
kind: "tool_call",
name: fc.name,
Expand All @@ -430,6 +444,7 @@ export function createResponsesAdapter(textProtocol?: boolean, projection?: Resp
} as ParsedStreamEvent;
} else {
yield { kind: "meta", chunk: rawBuf, firstRoundOnly: false } as ParsedStreamEvent;
toolCallsEmitted++;
yield {
kind: "tool_call",
name: typeof item.name === "string" ? item.name : "",
Expand Down Expand Up @@ -468,6 +483,7 @@ export function createResponsesAdapter(textProtocol?: boolean, projection?: Resp
outputTokens: typeof respUsage?.output_tokens === "number" ? respUsage.output_tokens : undefined,
cachedTokens: typeof pd?.cached_tokens === "number" ? pd.cached_tokens : undefined,
} as ParsedStreamEvent;
maybeWarnDegenerate("completed");
yield { kind: "done", finishReason: "completed" } as ParsedStreamEvent;
} else if (type === "response.incomplete") {
yield* flushFilter();
Expand Down
Loading
Loading