From 5b15ba9697b112f74cf14beeff0773e74dc840cf Mon Sep 17 00:00:00 2001 From: konard Date: Sat, 20 Jun 2026 18:01:45 +0000 Subject: [PATCH 1/3] Initial commit with task details Adding .gitkeep for PR creation (default mode). This file will be removed when the task is complete. Issue: https://github.com/xlabtg/teleton-agent/issues/676 --- .gitkeep | 1 + 1 file changed, 1 insertion(+) create mode 100644 .gitkeep diff --git a/.gitkeep b/.gitkeep new file mode 100644 index 00000000..fe7ccf25 --- /dev/null +++ b/.gitkeep @@ -0,0 +1 @@ +# .gitkeep file auto-generated at 2026-06-20T18:01:45.846Z for PR creation at branch issue-676-61e795d703ef for issue https://github.com/xlabtg/teleton-agent/issues/676 \ No newline at end of file From b10d84695f6006f21f2838f2e66d67f0896ffad3 Mon Sep 17 00:00:00 2001 From: konard Date: Sat, 20 Jun 2026 18:27:57 +0000 Subject: [PATCH 2/3] =?UTF-8?q?fix(agent):=20=D0=B2=D0=BE=D1=81=D1=81?= =?UTF-8?q?=D1=82=D0=B0=D0=BD=D0=BE=D0=B2=D0=B8=D1=82=D1=8C=20GLM-5.1=20?= =?UTF-8?q?=D0=BF=D0=BE=D1=81=D0=BB=D0=B5=20=D0=BF=D1=83=D1=81=D1=82=D0=BE?= =?UTF-8?q?=D0=B3=D0=BE=20stream?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/agent/__tests__/runtime-retry.test.ts | 65 +++++++++++++++++++++++ src/agent/__tests__/runtime-utils.test.ts | 41 ++++++++++++++ src/agent/runtime-utils.ts | 29 ++++++++-- src/agent/runtime.ts | 35 ++++++++++-- 4 files changed, 163 insertions(+), 7 deletions(-) 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); From e17dc632cade6a5f534364a63d40feca5a746cac Mon Sep 17 00:00:00 2001 From: konard Date: Sat, 20 Jun 2026 18:34:23 +0000 Subject: [PATCH 3/3] =?UTF-8?q?chore:=20=D1=83=D0=B1=D1=80=D0=B0=D1=82?= =?UTF-8?q?=D1=8C=20=D0=B2=D1=80=D0=B5=D0=BC=D0=B5=D0=BD=D0=BD=D1=8B=D0=B9?= =?UTF-8?q?=20.gitkeep?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitkeep | 1 - 1 file changed, 1 deletion(-) delete mode 100644 .gitkeep diff --git a/.gitkeep b/.gitkeep deleted file mode 100644 index fe7ccf25..00000000 --- a/.gitkeep +++ /dev/null @@ -1 +0,0 @@ -# .gitkeep file auto-generated at 2026-06-20T18:01:45.846Z for PR creation at branch issue-676-61e795d703ef for issue https://github.com/xlabtg/teleton-agent/issues/676 \ No newline at end of file