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
65 changes: 65 additions & 0 deletions src/agent/__tests__/runtime-retry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
for (let i = 0; i < turns; i++) {
await Promise.resolve();
Expand Down Expand Up @@ -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"),
});
});
});
41 changes: 41 additions & 0 deletions src/agent/__tests__/runtime-utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
isNetworkError,
isNetworkErrorMessage,
getEmptyResponseDiagnostic,
getEmptyResponseRecoveryPrompt,
trimRagContext,
LoopStallDetector,
} from "../../agent/runtime-utils.js";
Expand Down Expand Up @@ -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", () => {
Expand Down
29 changes: 26 additions & 3 deletions src/agent/runtime-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 " +
Expand Down
35 changes: 31 additions & 4 deletions src/agent/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ import {
isNetworkError,
isNetworkErrorMessage,
getEmptyResponseDiagnostic,
getEmptyResponseRecoveryPrompt,
trimRagContext,
LoopStallDetector,
sleepWithAbort,
Expand Down Expand Up @@ -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<string, unknown> }> = [];
const allToolExecResults: Array<{
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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++;
Expand All @@ -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);
Expand Down
Loading