diff --git a/src/agent/__tests__/runtime-retry.test.ts b/src/agent/__tests__/runtime-retry.test.ts index aa1279ec..a4849ddc 100644 --- a/src/agent/__tests__/runtime-retry.test.ts +++ b/src/agent/__tests__/runtime-retry.test.ts @@ -120,6 +120,20 @@ function errorResponse(errorMessage: string): ChatResponse { }; } +function emptyZeroTokenResponse(): ChatResponse { + return { + message: { + role: "assistant", + content: [], + stopReason: "stop", + usage: { input: 0, output: 0 }, + timestamp: Date.now(), + }, + text: "", + context: { messages: [] }, + }; +} + async function flushMicrotasks(turns = 10): Promise { for (let i = 0; i < turns; i++) { await Promise.resolve(); @@ -241,4 +255,55 @@ describe("AgentRuntime retry backoff", () => { expect(chatWithContextMock).toHaveBeenCalledTimes(1); expect(vi.getTimerCount()).toBe(0); }); + + it("recovers NVIDIA GLM-5.1 empty zero-token streams with one recovery prompt", async () => { + vi.useFakeTimers(); + const config = makeConfig({ + provider: "nvidia", + api_key: "nvapi-test", + model: "z-ai/glm-5.1", + max_agentic_iterations: 1, + }); + const runtime = await createRuntime(config); + chatWithContextMock + .mockResolvedValueOnce(emptyZeroTokenResponse()) + .mockResolvedValueOnce(emptyZeroTokenResponse()) + .mockResolvedValueOnce(emptyZeroTokenResponse()) + .mockResolvedValueOnce(emptyZeroTokenResponse()) + .mockResolvedValueOnce(assistantResponse("ok after empty recovery")); + + const resultPromise = runtime.processMessage({ + chatId: "1001", + userMessage: "hello", + userName: "Owner", + toolContext: { + senderId: 1001, + config, + }, + }); + + await flushMicrotasks(); + expect(chatWithContextMock).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(2000); + await flushMicrotasks(); + expect(chatWithContextMock).toHaveBeenCalledTimes(2); + + await vi.advanceTimersByTimeAsync(4000); + await flushMicrotasks(); + expect(chatWithContextMock).toHaveBeenCalledTimes(3); + + await vi.advanceTimersByTimeAsync(6000); + await flushMicrotasks(); + expect(chatWithContextMock).toHaveBeenCalledTimes(5); + + await expect(resultPromise).resolves.toMatchObject({ + content: "ok after empty recovery", + toolCalls: [], + }); + const recoveryOptions = chatWithContextMock.mock.calls[4]?.[1]; + expect(recoveryOptions).toMatchObject({ + systemPrompt: expect.stringContaining("previous NVIDIA GLM-5.1 streaming response was empty"), + }); + }); }); diff --git a/src/agent/__tests__/runtime-utils.test.ts b/src/agent/__tests__/runtime-utils.test.ts index c98858b8..a0c75bec 100644 --- a/src/agent/__tests__/runtime-utils.test.ts +++ b/src/agent/__tests__/runtime-utils.test.ts @@ -7,6 +7,7 @@ import { isNetworkError, isNetworkErrorMessage, getEmptyResponseDiagnostic, + getEmptyResponseRecoveryPrompt, trimRagContext, LoopStallDetector, } from "../../agent/runtime-utils.js"; @@ -432,6 +433,46 @@ describe("getEmptyResponseDiagnostic", () => { }); }); +describe("getEmptyResponseRecoveryPrompt", () => { + it("returns a recovery prompt for NVIDIA GLM-5.1 empty zero-token responses", () => { + const result = getEmptyResponseRecoveryPrompt({ + provider: "nvidia", + model: "z-ai/glm-5.1", + hasText: false, + inputTokens: 0, + outputTokens: 0, + }); + + expect(result).toContain("previous NVIDIA GLM-5.1 streaming response was empty"); + expect(result).toContain("native tools"); + expect(result).toContain("do not return an empty message"); + }); + + it("does not return a recovery prompt when the response has text", () => { + expect( + getEmptyResponseRecoveryPrompt({ + provider: "nvidia", + model: "z-ai/glm-5.1", + hasText: true, + inputTokens: 0, + outputTokens: 0, + }) + ).toBeNull(); + }); + + it("does not return a recovery prompt for unrelated models", () => { + expect( + getEmptyResponseRecoveryPrompt({ + provider: "nvidia", + model: "deepseek-ai/deepseek-v3.1", + hasText: false, + inputTokens: 0, + outputTokens: 0, + }) + ).toBeNull(); + }); +}); + // ─── T15: trimRagContext ───────────────────────────────────────── describe("trimRagContext", () => { diff --git a/src/agent/runtime-utils.ts b/src/agent/runtime-utils.ts index 9bee57fe..3de12246 100644 --- a/src/agent/runtime-utils.ts +++ b/src/agent/runtime-utils.ts @@ -105,11 +105,34 @@ export interface EmptyResponseDiagnosticInput { outputTokens?: number; } -export function getEmptyResponseDiagnostic(input: EmptyResponseDiagnosticInput): string | null { +function isEmptyResponseWithoutUsage(input: EmptyResponseDiagnosticInput): boolean { const hasTokens = (input.inputTokens ?? 0) > 0 || (input.outputTokens ?? 0) > 0; - if (input.hasText || hasTokens) return null; + return !input.hasText && !hasTokens; +} + +export function isNvidiaGlm51EmptyResponse(input: EmptyResponseDiagnosticInput): boolean { + return ( + isEmptyResponseWithoutUsage(input) && + input.provider.toLowerCase() === "nvidia" && + input.model.toLowerCase() === "z-ai/glm-5.1" + ); +} + +export function getEmptyResponseRecoveryPrompt(input: EmptyResponseDiagnosticInput): string | null { + if (!isNvidiaGlm51EmptyResponse(input)) return null; + + return ( + "Provider recovery: the previous NVIDIA GLM-5.1 streaming response was empty " + + "and reported zero token usage. Continue the same user request with the native tools " + + "already available in this request. Return either the next tool call(s) needed to make " + + "progress or a concise final answer; do not return an empty message." + ); +} + +export function getEmptyResponseDiagnostic(input: EmptyResponseDiagnosticInput): string | null { + if (!isEmptyResponseWithoutUsage(input)) return null; - if (input.provider.toLowerCase() === "nvidia" && input.model.toLowerCase() === "z-ai/glm-5.1") { + if (isNvidiaGlm51EmptyResponse(input)) { return ( "NVIDIA NIM z-ai/glm-5.1 returned an empty streaming response with zero token usage. " + "Teleton sends this model through NVIDIA's OpenAI-compatible Chat Completions endpoint " + diff --git a/src/agent/runtime.ts b/src/agent/runtime.ts index f97dfa5b..8455443c 100644 --- a/src/agent/runtime.ts +++ b/src/agent/runtime.ts @@ -86,6 +86,7 @@ import { isNetworkError, isNetworkErrorMessage, getEmptyResponseDiagnostic, + getEmptyResponseRecoveryPrompt, trimRagContext, LoopStallDetector, sleepWithAbort, @@ -796,6 +797,9 @@ export class AgentRuntime { let networkErrorRetries = 0; let emptyResponseRetries = 0; const EMPTY_RESPONSE_MAX_RETRIES = 3; + let emptyResponseRecoveryPrompt: string | null = null; + let emptyResponseRecoveryAttempts = 0; + const EMPTY_RESPONSE_RECOVERY_MAX_ATTEMPTS = 1; let finalResponse: ChatResponse | null = null; const totalToolCalls: Array<{ name: string; input: Record }> = []; const allToolExecResults: Array<{ @@ -864,6 +868,9 @@ export class AgentRuntime { `🔧 Injecting response reinforcement (${totalToolCalls.length} tool calls so far)` ); } + if (emptyResponseRecoveryPrompt) { + effectiveSystemPrompt += `\n\n${emptyResponseRecoveryPrompt}`; + } let response: ChatResponse; const llmRequestEventId = this.recordAuditEvent( @@ -1105,11 +1112,16 @@ export class AgentRuntime { } const toolCalls = response.message.content.filter((block) => block.type === "toolCall"); + const hasTokens = !!(response.message.usage?.input || response.message.usage?.output); + const hasText = !!response.text; + if (hasText || hasTokens || toolCalls.length > 0) { + emptyResponseRetries = 0; + emptyResponseRecoveryAttempts = 0; + emptyResponseRecoveryPrompt = null; + } if (toolCalls.length === 0) { // Detect empty response with zero tokens — retry the whole loop rather than giving up - const hasTokens = !!(response.message.usage?.input || response.message.usage?.output); - const hasText = !!response.text; if (!hasText && !hasTokens) { if (emptyResponseRetries < EMPTY_RESPONSE_MAX_RETRIES) { emptyResponseRetries++; @@ -1122,13 +1134,28 @@ export class AgentRuntime { continue; } - const diagnostic = getEmptyResponseDiagnostic({ + const emptyResponseInput = { provider, model: this.config.agent.model, hasText, inputTokens: response.message.usage?.input, outputTokens: response.message.usage?.output, - }); + }; + const recoveryPrompt = getEmptyResponseRecoveryPrompt(emptyResponseInput); + if ( + recoveryPrompt && + emptyResponseRecoveryAttempts < EMPTY_RESPONSE_RECOVERY_MAX_ATTEMPTS + ) { + emptyResponseRecoveryAttempts++; + emptyResponseRecoveryPrompt = recoveryPrompt; + log.warn( + `⚠️ Empty NVIDIA GLM-5.1 response after ${EMPTY_RESPONSE_MAX_RETRIES} retries - retrying with recovery prompt (attempt ${emptyResponseRecoveryAttempts}/${EMPTY_RESPONSE_RECOVERY_MAX_ATTEMPTS})...` + ); + iteration--; + continue; + } + + const diagnostic = getEmptyResponseDiagnostic(emptyResponseInput); if (diagnostic) { log.error(`🚨 ${diagnostic}`); throw new Error(diagnostic);