fix(agent): name billed output tokens on capped empty stops

An empty assistant `stop` that exhausts the retry cap always reported the
context/`/shake images` hint, even when the provider billed output tokens
for the turn. Billed output on a zero-block stop means content was generated
and then dropped downstream (a filter/refusal flattened to
`finish_reason: "stop"` by a proxy, or a lossy API translation), so the
images/context advice is actively misleading there.

Branch the capped `finalError` in `#handleEmptyAssistantStop` on
`assistantMessage.usage.output`: keep the context hint when nothing was
generated, and otherwise name the billed output-token count and point at a
provider-side filter/translation. Also log `outputTokens` alongside the
existing warning fields.

Fixes #8511
This commit is contained in:
roboomp
2026-08-14 04:51:06 +00:00
parent 039728ad80
commit d256367b72
3 changed files with 56 additions and 4 deletions
+4
View File
@@ -2,6 +2,10 @@
## [Unreleased]
### Fixed
- Fixed the capped empty-stop failure always naming the context/`/shake images` hint even when the provider billed output tokens. A zero-block `stop` with `usage.output > 0` means content was generated and dropped downstream (a filter/refusal flattened to `finish_reason: "stop"` by a proxy, or a lossy API translation), so the message now reports the billed output-token count and points at a provider-side filter/translation instead of a context problem, and logs `outputTokens` alongside the existing warning fields ([#8511](https://github.com/can1357/oh-my-pi/issues/8511)).
## [17.3.3] - 2026-08-14
### Fixed
@@ -674,15 +674,27 @@ export class TurnRecovery {
this.#emptyStopRetryCount++;
if (this.#emptyStopRetryCount > EMPTY_STOP_MAX_RETRIES) {
const attempts = this.#emptyStopRetryCount - 1;
const finalError = providerEmptyOutput
? "Assistant returned no final output after retry cap; try switching models"
: "Assistant returned empty stop after retry cap; try switching models or `/shake images` to remove archived frames";
const outputTokens = assistantMessage.usage.output;
let finalError: string;
if (providerEmptyOutput) {
finalError = "Assistant returned no final output after retry cap; try switching models";
} else if (outputTokens > 0) {
// Billed output on a zero-block stop means content was generated and then
// dropped downstream (a filter/refusal flattened to `finish_reason: "stop"`
// by a proxy, or a lossy API translation) — the context/`/shake images`
// hint is wrong here, so name the billed output instead.
finalError = `Assistant returned an empty stop after retry cap, but the provider billed ${outputTokens} output token${outputTokens === 1 ? "" : "s"} for it; content was generated and then dropped before delivery, which usually points to a provider-side content filter or a lossy API translation rather than a context problem`;
} else {
finalError =
"Assistant returned empty stop after retry cap; try switching models or `/shake images` to remove archived frames";
}
assistantMessage.errorMessage = finalError;
if (providerEmptyOutput) assistantMessage.errorId = AIError.create();
logger.warn(finalError, {
attempts,
model: assistantMessage.model,
provider: assistantMessage.provider,
outputTokens,
});
await this.#host.emitSessionEvent({
type: "auto_retry_end",
@@ -57,7 +57,18 @@ function emptyStop(): MockResponse {
return {
content: [],
stopReason: "stop",
usage: { output: 1, cacheRead: 100 },
usage: { output: 0, cacheRead: 100 },
};
}
// A zero-block `stop` for which the provider still billed output tokens: content
// was generated and dropped downstream (e.g. a filter/refusal flattened to
// `finish_reason: "stop"` by a proxy), so the context/`/shake images` hint is wrong.
function filteredEmptyStop(): MockResponse {
return {
content: [],
stopReason: "stop",
usage: { output: 126, cacheRead: 100 },
};
}
@@ -463,6 +474,31 @@ describe("AgentSession empty stop guard", () => {
expect(retryEndEvents[0]?.finalError).toContain("/shake images");
});
it("names billed output tokens instead of the context hint when a capped empty stop billed output", async () => {
const { session, mock } = await createHarness([
filteredEmptyStop(),
filteredEmptyStop(),
filteredEmptyStop(),
filteredEmptyStop(),
]);
const retryEndEvents: Array<Extract<AgentSessionEvent, { type: "auto_retry_end" }>> = [];
session.subscribe(event => {
if (event.type === "auto_retry_end") {
retryEndEvents.push(event);
}
});
await expectPromptCompletes(session.prompt("answer that gets filtered"));
await session.waitForIdle();
expect(mock.calls).toHaveLength(4);
expect(retryEndEvents).toHaveLength(1);
expect(retryEndEvents[0]?.success).toBe(false);
const finalError = retryEndEvents[0]?.finalError ?? "";
expect(finalError).toContain("billed 126 output tokens");
expect(finalError).not.toContain("/shake images");
});
it("ends auto-retry state when empty stop retries hit the cap", async () => {
vi.spyOn(scheduler, "wait").mockResolvedValue(undefined);
const { session, mock } = await createHarness(