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
31 changes: 31 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,37 @@ versions are listed at

## Unreleased

### Changed: a response cut at the output token limit reports `PROVIDER_OUTPUT_TRUNCATED`

An Anthropic response that stops at the output token limit part way through a
tool call now fails with the new curated code `PROVIDER_OUTPUT_TRUNCATED` and
the message "The model stopped at its output token limit before it finished
the response." It previously surfaced as a malformed provider stream, and on
agents that use provider replay checkpoints it surfaced as "Provider replay
turn failed before its boundary", neither of which named the real cause.

The failure is terminal, not retryable: the same request and the same output
token budget truncate again. Raise the model output token limit, or ask for a
shorter response. This is a deliberate retry-semantics change and needs your
decision if you depend on the old behaviour: `PROVIDER_OUTPUT_TRUNCATED` joins
the curated provider failure codes, so it is classified as a known terminal
error and hosted child runs -- including durable child forks -- stop retrying
it, where a truncation previously landed in the unknown, retryable
`PROVIDER_STREAM_ERROR` bucket. A retry above temperature 0 could occasionally have produced a shorter
tool input and succeeded; that accidental recovery is gone, in exchange for a
named failure instead of a retry loop against a budget that cannot fit the
response. The incomplete tool call is also dropped rather than replayed, so no
partial tool input reaches a tool, and no later tool call from the same
truncated turn is dispatched.

A replay checkpoint boundary that fails now reports the provider failure the
stream reports instead of a fixed message. Anything that matched on the literal
string "Provider replay turn failed before its boundary" must match on the run
error code instead. That string remains only as the neutral fallback for a turn
that ends with no reported cause, such as a client cancellation, and a failure
to persist a durable run event now reports
`DURABLE_RUN_EVENT_PERSISTENCE_FAILED` rather than a provider failure.

### Changed: `veryfront up` pushes committed work again

`veryfront up` now pushes the local source to main whenever the checkout no
Expand Down
15 changes: 8 additions & 7 deletions docs/api-reference/veryfront/provider.md
Original file line number Diff line number Diff line change
Expand Up @@ -205,13 +205,14 @@ import {

#### Classes

| Name | Description | Source |
| ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ |
| `ProviderError` | Base class for typed provider errors. The `retryable` flag is the primary signal for callers (or a retry wrapper) to decide whether to re-issue the request. `retryAfterMs` is set when the provider gave an explicit delay hint (Retry-After header, Retry-Info trailer). | [source](https://github.com/veryfront/veryfront-code/blob/main/src/provider/runtime-loader/provider-http.ts) |
| `ProviderOverloadedError` | Provider reports it is overloaded (Anthropic 529, OpenAI/Google 503). | [source](https://github.com/veryfront/veryfront-code/blob/main/src/provider/runtime-loader/provider-http.ts) |
| `ProviderQuotaError` | Provider account quota is exhausted - non-retryable. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/provider/runtime-loader/provider-http.ts) |
| `ProviderRateLimitError` | Provider is rate limiting this API key (OpenAI/Google 429 with Retry-After). | [source](https://github.com/veryfront/veryfront-code/blob/main/src/provider/runtime-loader/provider-http.ts) |
| `ProviderRequestError` | Non-retryable 4xx/5xx that doesn't fit another bucket. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/provider/runtime-loader/provider-http.ts) |
| Name | Description | Source |
| ------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ |
| `ProviderError` | Base class for typed provider errors. The `retryable` flag is the primary signal for callers (or a retry wrapper) to decide whether to re-issue the request. `retryAfterMs` is set when the provider gave an explicit delay hint (Retry-After header, Retry-Info trailer). | [source](https://github.com/veryfront/veryfront-code/blob/main/src/provider/runtime-loader/provider-http.ts) |
| `ProviderOutputTruncatedError` | Provider stopped generating at the output token limit, leaving the response incomplete (for example a `tool_use` block whose input JSON never closed). | [source](https://github.com/veryfront/veryfront-code/blob/main/src/provider/runtime-loader/provider-http.ts) |
| `ProviderOverloadedError` | Provider reports it is overloaded (Anthropic 529, OpenAI/Google 503). | [source](https://github.com/veryfront/veryfront-code/blob/main/src/provider/runtime-loader/provider-http.ts) |
| `ProviderQuotaError` | Provider account quota is exhausted - non-retryable. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/provider/runtime-loader/provider-http.ts) |
| `ProviderRateLimitError` | Provider is rate limiting this API key (OpenAI/Google 429 with Retry-After). | [source](https://github.com/veryfront/veryfront-code/blob/main/src/provider/runtime-loader/provider-http.ts) |
| `ProviderRequestError` | Non-retryable 4xx/5xx that doesn't fit another bucket. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/provider/runtime-loader/provider-http.ts) |

#### Types

Expand Down
230 changes: 228 additions & 2 deletions extensions/ext-llm-anthropic/src/anthropic-stream.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { assertEquals, assertRejects } from "#veryfront/testing/assert.ts";
import { assertEquals, assertInstanceOf, assertRejects } from "#veryfront/testing/assert.ts";
import { describe, it } from "#veryfront/testing/bdd.ts";
import { ProviderOverloadedError, ProviderRequestError } from "veryfront/provider/shared";
import {
ProviderOutputTruncatedError,
ProviderOverloadedError,
ProviderRequestError,
} from "veryfront/provider/shared";
import {
addAnthropicUsage,
extractAnthropicUsage,
Expand Down Expand Up @@ -906,6 +910,228 @@ describe("ext-llm-anthropic/anthropic-stream", () => {
);
});

it("classifies a max_tokens-truncated tool_use as a provider output truncation", async () => {
const truncatedToolStream = [
data({ type: "message_start", message: { usage: { input_tokens: 1 } } }),
data({
type: "content_block_start",
index: 0,
content_block: { type: "tool_use", id: "toolu_x", name: "create_file", input: {} },
}),
data({
type: "content_block_delta",
index: 0,
delta: {
type: "input_json_delta",
partial_json: '{"path":"/inbox/mail-1.json","content":"<html>truncated',
},
}),
data({ type: "content_block_stop", index: 0 }),
data({
type: "message_delta",
delta: { stop_reason: "max_tokens" },
usage: { output_tokens: 4096 },
}),
data({ type: "message_stop" }),
].join("");

const error = await assertRejects(
() => collectParts(streamFromText(truncatedToolStream)),
ProviderOutputTruncatedError,
"provider output truncated at the max output token limit",
);
assertInstanceOf(error, ProviderOutputTruncatedError);
assertEquals(error.retryable, false);
assertEquals(
error.message.includes("tool call arguments were not valid JSON object text"),
false,
);
});

// Codex P2 on veryfront-code#4516: a usage-only message_delta carries no
// stop_reason, and deciding there would classify the truncation as a
// malformed stream before the delta that actually says max_tokens.
it("waits for a stop reason when a usage-only delta arrives first", async () => {
const lateStopReasonStream = [
data({ type: "message_start", message: { usage: { input_tokens: 1 } } }),
data({
type: "content_block_start",
index: 0,
content_block: { type: "tool_use", id: "toolu_z", name: "create_file", input: {} },
}),
data({
type: "content_block_delta",
index: 0,
delta: {
type: "input_json_delta",
partial_json: '{"path":"/inbox/mail-2.json","content":"<html>trunc',
},
}),
data({ type: "content_block_stop", index: 0 }),
// Usage only -- no stop_reason yet.
data({ type: "message_delta", delta: {}, usage: { output_tokens: 2048 } }),
data({
type: "message_delta",
delta: { stop_reason: "max_tokens" },
usage: { output_tokens: 4096 },
}),
data({ type: "message_stop" }),
].join("");

const error = await assertRejects(
() => collectParts(streamFromText(lateStopReasonStream)),
ProviderOutputTruncatedError,
"provider output truncated at the max output token limit",
);
assertInstanceOf(error, ProviderOutputTruncatedError);
});

it("still reports a malformed tool stream when the stop reason is not max_tokens", async () => {
const malformedToolStream = [
data({ type: "message_start", message: { usage: { input_tokens: 1 } } }),
data({
type: "content_block_start",
index: 0,
content_block: { type: "tool_use", id: "toolu_y", name: "create_file", input: {} },
}),
data({
type: "content_block_delta",
index: 0,
delta: { type: "input_json_delta", partial_json: '{"path":' },
}),
data({ type: "content_block_stop", index: 0 }),
data({ type: "message_delta", delta: { stop_reason: "end_turn" } }),
data({ type: "message_stop" }),
].join("");

await assertRejects(
() => collectParts(streamFromText(malformedToolStream)),
ProviderRequestError,
"tool call arguments were not valid JSON object text",
);
});

it("yields no tool call after a deferred tool input failure", async () => {
const mixedToolStream = [
data({ type: "message_start", message: { usage: { input_tokens: 1 } } }),
data({
type: "content_block_start",
index: 0,
content_block: { type: "tool_use", id: "toolu_truncated", name: "create_file", input: {} },
}),
data({
type: "content_block_delta",
index: 0,
delta: {
type: "input_json_delta",
partial_json: '{"path":"/inbox/mail-1.json","content":',
},
}),
data({ type: "content_block_stop", index: 0 }),
data({
type: "content_block_start",
index: 1,
content_block: { type: "tool_use", id: "toolu_complete", name: "bash", input: {} },
}),
data({
type: "content_block_delta",
index: 1,
delta: { type: "input_json_delta", partial_json: '{"command":"pwd"}' },
}),
data({ type: "content_block_stop", index: 1 }),
data({ type: "message_delta", delta: { stop_reason: "end_turn" } }),
data({ type: "message_stop" }),
].join("");

const parts: unknown[] = [];
await assertRejects(
async () => {
for await (const part of streamAnthropicCompatibleParts(streamFromText(mixedToolStream))) {
parts.push(part);
}
},
ProviderRequestError,
"tool call arguments were not valid JSON object text",
);

// Deferring the failure must not let a later, well-formed tool call reach
// the caller: the turn throws either way, and a dispatched tool call from
// a failed turn would be a side effect the pre-deferral parser never had.
assertEquals(
parts.some((part) => (part as { type?: string }).type === "tool-call"),
false,
);
// Streaming progress parts (`tool-input-start` / `tool-input-delta`) still
// flow, as they do for any tool block; only the dispatchable `tool-call`
// part is withheld.
assertEquals(
parts.every((part) =>
(part as { type?: string }).type === "tool-input-start" ||
(part as { type?: string }).type === "tool-input-delta"
),
true,
);
});

it("classifies a truncation resolved from a buffered trailing message_delta", async () => {
// The `message_delta` handler refuses to run while a content block is open,
// so the only way a deferred failure survives to `validateCompletion()` is
// the client tool-use read timeout: the trailing event is still in the SSE
// buffer and `mergeTrailingBufferUsage()` applies it first.
let cancelCount = 0;
const stream = streamFromChunksWithCancelSpy([
[
data({ type: "message_start", message: { usage: { input_tokens: 1 } } }),
data({
type: "content_block_start",
index: 0,
content_block: { type: "tool_use", id: "toolu_done", name: "bash", input: {} },
}),
data({
type: "content_block_delta",
index: 0,
delta: { type: "input_json_delta", partial_json: '{"command":"pwd"}' },
}),
data({ type: "content_block_stop", index: 0 }),
data({
type: "content_block_start",
index: 1,
content_block: { type: "tool_use", id: "toolu_cut", name: "create_file", input: {} },
}),
data({
type: "content_block_delta",
index: 1,
delta: { type: "input_json_delta", partial_json: '{"path":"/inbox/mail-1.json","c' },
}),
data({ type: "content_block_stop", index: 1 }),
// Deliberately unterminated: only the trailing-buffer flush sees it.
`event: message_delta\r\ndata: ${
JSON.stringify({
type: "message_delta",
delta: { stop_reason: "max_tokens" },
usage: { output_tokens: 4096 },
})
}`,
].join(""),
], {
closeDelayMs: 600,
onCancel: () => cancelCount++,
});

await assertRejects(
() =>
collectParts(stream, {
clientToolUseTrailingUsageGraceMs: 5,
allowPostTerminalUsage: true,
}),
ProviderOutputTruncatedError,
"provider output truncated at the max output token limit",
);

await waitForCondition(() => cancelCount === 1 && !stream.locked, 500);
assertEquals(stream.locked, false);
});

it("accepts a complete empty assistant stream", async () => {
for (
const terminal of [
Expand Down
Loading
Loading