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
2 changes: 1 addition & 1 deletion packages/moss-agent/src/core/llm/llm-error-classifier.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ function isAbortLike(message: string): boolean {
}

function isPrematureClose(message: string): boolean {
return /err_stream_premature_close|premature close|stream closed prematurely|other side closed|stream.*terminated|^(?:llm\s+stream\s+error:\s*)?terminated\.?$/i.test(
return /err_stream_premature_close|premature close|stream closed prematurely|other side closed|stream.*terminated|stream (?:ended|terminated) without (?:\[done\]\s*(?:or\s*)?)?finish_reason|^(?:llm\s+stream\s+error:\s*)?terminated\.?$/i.test(
message
);
}
Expand Down
1 change: 1 addition & 0 deletions packages/moss-agent/src/provider/error-classify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,7 @@ function matchOpaqueStreamConnectionDrop(msg: string): boolean {
m === 'connection error.' ||
/^(?:llm\s+stream\s+error:\s*)?terminated\.?$/i.test(msg.trim()) ||
/^(?:llm\s+stream\s+error:\s*)?connection error\.?$/i.test(msg.trim()) ||
/stream (?:ended|terminated) without (?:\[done\]\s*(?:or\s*)?)?finish_reason/i.test(msg) ||
/terminated.*other side closed|other side closed|stream.*terminated/i.test(m)
);
}
Expand Down
11 changes: 11 additions & 0 deletions packages/moss-agent/test/error-classify-500.spec.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,17 @@ for (const status of [500, 502, 503, 504, 529]) {
assert.equal(r.retryable, true, `HTTP ${status} is retryable`);
}

// ─── truncated successful HTTP streams are also transient ─────────────────

for (const errorMessage of [
'LLM stream incomplete: Stream ended without finish_reason',
'OpenAI provider: stream terminated without [DONE] or finish_reason',
]) {
const r = classifyProviderError({ errorMessage });
assert.equal(r.category, 'service_unavailable', 'missing terminal marker is a gateway interruption');
assert.equal(r.retryable, true, 'missing terminal marker is retried');
}

// ─── non-retryable errors stay non-retryable (no false regression) ─────────

{
Expand Down
28 changes: 28 additions & 0 deletions packages/moss-agent/test/provider-retry-contract.spec.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -99,4 +99,32 @@ function makeAgent(provider, maxLLMRetries = 1) {
assert.equal(calls, 2, '429 gets one bounded retry, not nested retry loops');
}

{
let calls = 0;
const provider = {
id: 'missing-terminal-marker', displayName: 'missing-terminal-marker',
async complete() { throw new Error('not used'); },
async stream(_options, onEvent) {
calls++;
if (calls === 1) {
throw new Error('LLM stream incomplete: Stream ended without finish_reason');
}
onEvent({ type: 'message_start' });
onEvent({ type: 'content_block_start' });
onEvent({ type: 'content_block_delta', text: 'recovered after truncated stream' });
onEvent({ type: 'content_block_stop' });
onEvent({ type: 'message_delta', stopReason: 'end_turn' });
onEvent({ type: 'message_stop' });
return {
stopReason: 'end_turn',
content: [{ type: 'text', text: 'recovered after truncated stream' }],
};
},
};
const agent = makeAgent(provider);
const result = await agent.chat('missing-terminal-marker', 'hello');
assert.equal(result.response, 'recovered after truncated stream');
assert.equal(calls, 2, 'a stream missing finish_reason is retried inside the same agent run');
}

console.log('[PASS] provider auth and rate-limit retry contracts');