mirror of
https://github.com/vercel-labs/fx.git
synced 2026-10-28 10:15:17 +08:00
Ask again once when a reply runs out of output before any text
When a request named no output limit and the provider's own default ran out before any text or tool call, fx ended the turn with "Done." for an empty reply. fx now sends the request again once, with a limit of 32,768 tokens, and says it is retrying. If that reply is also empty, or the request already had the catalog's limit, the turn ends with "The reply ran out of output tokens before any text." instead of "Done.".
This commit is contained in:
4 files changed
+124
-5
No files matched your search
@@ -83,6 +83,8 @@ const AgentRuntimeDeps = runtime_deps.AgentRuntimeDeps;
|
||||
const CredentialRefreshMode = runtime_deps.CredentialRefreshMode;
|
||||
|
||||
const http_error_detail_max_bytes: usize = 4096;
|
||||
const empty_length_retry_notice = "The reply ran out of output tokens before any text. Retrying with a higher limit.";
|
||||
const empty_length_limited_reply = "The reply ran out of output tokens before any text.";
|
||||
const assistant_prefill_recovery_prompt =
|
||||
"Continue from the preceding tool result.";
|
||||
pub const assistant_tail_continuation_prompt =
|
||||
@@ -6860,6 +6862,9 @@ fn processQueuedPromptLoop(
|
||||
defer interrupted_persisted_ptr.* = interrupted_persisted;
|
||||
var silent_tool_steps: usize = 0;
|
||||
var continuation_injected = false;
|
||||
// Set once, for the turn's single retry of an empty reply the provider
|
||||
// cut short while the request named no output limit.
|
||||
var length_retry_output_tokens: ?u32 = null;
|
||||
var last_step_ctx = finish_trace.ctx;
|
||||
var current_step_index: usize = 0;
|
||||
var last_tool_call_name: []const u8 = "none";
|
||||
@@ -7426,7 +7431,7 @@ fn processQueuedPromptLoop(
|
||||
.tool_choice = tool_choice,
|
||||
.vision_mode = vision_mode,
|
||||
.provider_options = provider_opts,
|
||||
.max_output_tokens = model_capabilities.requestOutputTokens(request_capabilities),
|
||||
.max_output_tokens = length_retry_output_tokens orelse model_capabilities.requestOutputTokens(request_capabilities),
|
||||
.budget = .{ .cancel_flag = config.cancel_flag },
|
||||
};
|
||||
// The request re-sends every image the session keeps, and
|
||||
@@ -9403,6 +9408,22 @@ fn processQueuedPromptLoop(
|
||||
},
|
||||
);
|
||||
try runtime_telemetry.traceReturnedToolCalls(arena, step_ctx, completion.tool_calls);
|
||||
// A reply the provider cut short before any text or tool call, from a
|
||||
// request that named no output limit, ran out of the provider's own
|
||||
// default. It is sent again once with an explicit limit.
|
||||
if (disposition == .length_limited and
|
||||
completion.tool_calls.len == 0 and
|
||||
std.mem.trim(u8, partial_assistant, " \t\r\n").len == 0 and
|
||||
length_retry_output_tokens == null and
|
||||
model_capabilities.requestOutputTokens(request_capabilities) == null and
|
||||
agent_steps.allowsStep(config.agent_step_limit, step + 1))
|
||||
{
|
||||
length_retry_output_tokens = model_capabilities.empty_length_retry_output_tokens;
|
||||
debug_trace.eventf("agent", "empty_length_limited_retry", step_ctx, "max_output_tokens={d}", .{model_capabilities.empty_length_retry_output_tokens});
|
||||
try deps.push_text(deps.ctx, .{ .operational = empty_length_retry_notice });
|
||||
try deps.push_text(deps.ctx, .{ .operational = "\n" });
|
||||
continue;
|
||||
}
|
||||
try runtime_assistant_stream.emitProviderLengthNotice(deps, arena, disposition);
|
||||
const terminal_provider_completion = isTerminalProviderExecutedCompletion(completion);
|
||||
const final_provider_replay = if (terminal_provider_completion or filtered_provider_calls.removed > 0)
|
||||
@@ -9499,9 +9520,10 @@ fn processQueuedPromptLoop(
|
||||
continue;
|
||||
}
|
||||
|
||||
const raw_final = if (has_content) partial_assistant else "Done.";
|
||||
const empty_final: []const u8 = if (disposition == .length_limited) empty_length_limited_reply else "Done.";
|
||||
const raw_final = if (has_content) partial_assistant else empty_final;
|
||||
const final_text = try runtime_assistant_stream.normalizeAssistantTextForDisplay(arena, raw_final);
|
||||
const rendered = if (final_text.len > 0) final_text else "Done.";
|
||||
const rendered = if (final_text.len > 0) final_text else empty_final;
|
||||
const history_text = try arena.dupe(u8, raw_final);
|
||||
const history_replay = if (has_content) final_provider_replay else try deps.agent_stream_provider.projectReplay(arena, final_provider_replay, &.{}, false, true);
|
||||
|
||||
|
||||
@@ -8611,6 +8611,7 @@ test "processQueuedPrompt no-tool length bypasses silent-tool continuation" {
|
||||
.{ .tool_calls = &call_one },
|
||||
.{ .tool_calls = &call_two },
|
||||
.{ .finish_reason = .length },
|
||||
.{ .finish_reason = .length },
|
||||
.{ .content = "UNEXPECTED_CONTINUATION" },
|
||||
};
|
||||
var gateway = FakeGateway.init(alloc, &completions);
|
||||
@@ -8621,12 +8622,17 @@ test "processQueuedPrompt no-tool length bypasses silent-tool continuation" {
|
||||
|
||||
try runFakePrompt(&gateway, &hooks, fixture.config(), fixture.job());
|
||||
|
||||
try std.testing.expectEqual(@as(usize, 3), gateway.request_bodies.items.len);
|
||||
// The empty reply's one retry sends the same request with an output
|
||||
// limit; neither request asks for a summary.
|
||||
try std.testing.expectEqual(@as(usize, 4), gateway.request_bodies.items.len);
|
||||
try expectBodyNotContains(&gateway, 2, "\"maxOutputTokens\"");
|
||||
try expectBodyContains(&gateway, 3, "\"maxOutputTokens\":32768");
|
||||
try expectBodyNotContains(&gateway, 3, "Summarize what you just did.");
|
||||
try std.testing.expectEqual(@as(usize, 2), hooks.executed_names.items.len);
|
||||
try std.testing.expectEqual(types.TurnPresentationOutcome.completed, hooks.finalized_outcome.?);
|
||||
try std.testing.expectEqual(types.ProviderCompletionDisposition.length_limited, hooks.finalized_disposition.?);
|
||||
try std.testing.expectEqual(@as(usize, 1), hooks.finalization_count);
|
||||
try std.testing.expectEqualStrings("Done.", hooks.finish_assistant_text.?);
|
||||
try std.testing.expectEqualStrings("The reply ran out of output tokens before any text.", hooks.finish_assistant_text.?);
|
||||
}
|
||||
|
||||
test "processQueuedPrompt non-ok gateway response trims and clips HTTP detail" {
|
||||
|
||||
@@ -130,6 +130,10 @@ pub fn resolveForApp(comptime App: type, app: *App, model: []const u8) Capabilit
|
||||
const full_window_output_tokens: u32 = 32_768;
|
||||
const full_window_output_divisor: u32 = 8;
|
||||
|
||||
/// The output limit for the one retry of a reply the provider cut short before
|
||||
/// any text, when the first request set no limit and the provider's default ran out.
|
||||
pub const empty_length_retry_output_tokens: u32 = full_window_output_tokens;
|
||||
|
||||
/// Output tokens to request and reserve for one model call, or null to leave the limit unset.
|
||||
pub fn requestOutputTokens(capabilities: Capabilities) ?u32 {
|
||||
const advertised = capabilities.max_output_tokens orelse return null;
|
||||
|
||||
@@ -8923,6 +8923,93 @@ printf '%s' ${JSON.stringify(trailingMarker)} > ${JSON.stringify(effectPath)}
|
||||
55_000,
|
||||
);
|
||||
|
||||
// The provider's own default limit ran out before any text: reasoning only.
|
||||
const emptyLengthLimitedReply = () => fakeGatewaySse([
|
||||
{ type: "reasoning-start", id: "spent_reasoning" },
|
||||
{ type: "reasoning-delta", id: "spent_reasoning", delta: "thinking until the provider stops" },
|
||||
{ type: "reasoning-end", id: "spent_reasoning" },
|
||||
{
|
||||
type: "finish",
|
||||
finishReason: { unified: "length", raw: "length" },
|
||||
usage: { inputTokens: { total: 3 }, outputTokens: { total: 4096 } },
|
||||
},
|
||||
]);
|
||||
|
||||
async function askWithReplies(
|
||||
label: string,
|
||||
replies: Array<() => Response>,
|
||||
model: Record<string, unknown> = {},
|
||||
) {
|
||||
const root = createFixtureRoot(label);
|
||||
const tracePath = join(root.root, "trace.log");
|
||||
let index = 0;
|
||||
const gateway = startDynamicFakeGateway(
|
||||
() => (replies[index++] ?? (() => new Response("unexpected request", { status: 500 })))(),
|
||||
{ models: [{ id: MODEL, type: "language", tags: ["tool-use"], ...model }] },
|
||||
);
|
||||
try {
|
||||
const result = await runFx(
|
||||
["ask", "--json", "--auto", "--no-save", "Answer briefly."],
|
||||
{ cwd: root.workspace, env: fixtureEnv(root, gateway, tracePath), timeoutMs: 30_000 },
|
||||
);
|
||||
return {
|
||||
result,
|
||||
json: parseAskJson(result.stdout),
|
||||
bodies: gateway.requests.map((request) => JSON.parse(request.body) as { maxOutputTokens?: number }),
|
||||
trace: readFileSync(tracePath, "utf8"),
|
||||
};
|
||||
} finally {
|
||||
gateway.stop();
|
||||
rmSync(root.root, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
test("an empty reply cut short by the provider's default limit is asked again with a limit", async () => {
|
||||
const { result, json, bodies, trace } = await askWithReplies("empty-length-retry", [
|
||||
emptyLengthLimitedReply,
|
||||
() => fakeGatewayFinalText("answered after the limit retry"),
|
||||
]);
|
||||
|
||||
expect(result.code).toBe(0);
|
||||
expect(json.output).toContain("answered after the limit retry");
|
||||
expect(json.final_output).toContain("answered after the limit retry");
|
||||
expect(`${result.stdout}\n${result.stderr}`).not.toContain("Done.");
|
||||
expect(json.final_output).not.toContain("ran out of output tokens");
|
||||
// The catalog names no limit, so only the retry sends one.
|
||||
expect(bodies).toHaveLength(2);
|
||||
expect(bodies[0]!.maxOutputTokens).toBeUndefined();
|
||||
expect(bodies[1]!.maxOutputTokens).toBe(32_768);
|
||||
expect(`${result.stdout}\n${result.stderr}`).toContain("Retrying with a higher limit.");
|
||||
expect(trace).toContain("event=empty_length_limited_retry");
|
||||
}, 40_000);
|
||||
|
||||
test("an empty reply still cut short after the retry says so instead of Done.", async () => {
|
||||
const { result, json, bodies } = await askWithReplies("empty-length-twice", [
|
||||
emptyLengthLimitedReply,
|
||||
emptyLengthLimitedReply,
|
||||
() => fakeGatewayFinalText("a third request must not be sent"),
|
||||
]);
|
||||
|
||||
expect(result.code).toBe(0);
|
||||
expect(bodies).toHaveLength(2);
|
||||
expect(json.final_output).toBe("The reply ran out of output tokens before any text.");
|
||||
expect(`${result.stdout}\n${result.stderr}`).not.toContain("Done.");
|
||||
}, 40_000);
|
||||
|
||||
test("an empty reply cut short at the catalog's own limit is not asked again", async () => {
|
||||
const { result, json, bodies, trace } = await askWithReplies("empty-length-catalog-limit", [
|
||||
emptyLengthLimitedReply,
|
||||
() => fakeGatewayFinalText("a retry must not be sent"),
|
||||
], { context_window: 200_000, max_tokens: 8_000 });
|
||||
|
||||
expect(result.code).toBe(0);
|
||||
expect(bodies).toHaveLength(1);
|
||||
expect(bodies[0]!.maxOutputTokens).toBe(8_000);
|
||||
expect(json.final_output).toBe("The reply ran out of output tokens before any text.");
|
||||
expect(`${result.stdout}\n${result.stderr}`).not.toContain("Done.");
|
||||
expect(trace).not.toContain("event=empty_length_limited_retry");
|
||||
}, 40_000);
|
||||
|
||||
test("provider error finish reconciles without authorizing local tool execution", async () => {
|
||||
const root = createFixtureRoot("provider-error");
|
||||
const tracePath = join(root.root, "trace.log");
|
||||
|
||||
Reference in new issue
Block a user