Skip to content
Closed
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 scripts/eval/tier1-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
"src/repl.zig",
"TUI/root.zig"
],
"test_count_baseline": 1745,
"test_count_baseline": 1783,
"test_count_slack": 25,
"required_invariants": [
{
Expand Down
30 changes: 15 additions & 15 deletions src/agent_request.zig
Original file line number Diff line number Diff line change
Expand Up @@ -113,9 +113,9 @@ pub const landing_note =
"results beat dying mid-tool-call.";

pub fn request(self: *Agent, tools_in: ?[]const u8) !std.json.ObjectMap {
// Startup paints the prompt while CA loading continues. The root turn and
// title task rendezvous here, then issue their requests concurrently.
// Root and title requests rendezvous after launch-time CA loading.
http.waitForClientReady(self.io);
if (http.takeCaWarmFailure()) if (self.tracer) |tr| tr.note("ca_prewarm_failed", "CA bundle rescan failed; request will use lazy TLS initialization");
if (self.registry) |reg| {
if (@import("mcp_boot.zig").joinBeforeRequest(reg)) {
self.invalidateRootTools();
Expand Down Expand Up @@ -244,6 +244,7 @@ pub fn request(self: *Agent, tools_in: ?[]const u8) !std.json.ObjectMap {
// error.ApiError so the REPL returns to the prompt, never crashes.
const resp_body = blk: {
var attempt: usize = 0;
var retry_limit: ?usize = null;
while (true) : (attempt += 1) {
var conv_buf: [96]u8 = undefined;
const conv = http_headers.promptCacheKey(self.io, self.label, self, &conv_buf);
Expand Down Expand Up @@ -327,7 +328,8 @@ pub fn request(self: *Agent, tools_in: ?[]const u8) !std.json.ObjectMap {
// (1s·2ⁿ, capped at 8s; Esc cancels) and allow a few
// more attempts than a plain transport flake gets.
const throttled = err == error.RateLimited or err == error.ServerError;
const max_attempts: usize = RetryPlan.maxAttempts(throttled);
const max_attempts = retry_limit orelse RetryPlan.maxAttempts(throttled);
retry_limit = max_attempts;
// #opencode-parity: a 429 that's a billing/quota cap (not
// transient throttling) won't clear by retrying — fail fast so
// cross-provider /fallback can take over, instead of burning all
Expand All @@ -346,7 +348,11 @@ pub fn request(self: *Agent, tools_in: ?[]const u8) !std.json.ObjectMap {
if (self.tracer) |tr| tr.api(self.label, self.sub, self.provider.model, 0, body.len, 0, 0, 0, true);
return error.ApiError;
}
if (attempt < max_attempts) {
if (err == error.TlsRequestConstructionFailed or err == error.TlsRequestConstructionCaWarmFailed) if (self.tracer) |tr| tr.note(
"tls_request_construction",
if (err == error.TlsRequestConstructionCaWarmFailed) "rotated shared HTTP client generation; replacement CA prewarm failed" else "rotated shared HTTP client generation",
);
if (attempt + 1 < max_attempts) {
if (throttled) {
// #retry-after: prefer the provider's Retry-After
// (429/503) over our computed backoff, capped — like
Expand All @@ -362,11 +368,8 @@ pub fn request(self: *Agent, tools_in: ?[]const u8) !std.json.ObjectMap {
if (self.tracer) |tr| tr.note("retry", what);
self.sleepInterruptible(delay_ms) catch return error.Interrupted;
} else {
// Transport flake (HttpConnectionClosing, a reset,
// a truncated TLS read): back off before a fresh
// connection. Rapid-fire retries against a
// just-closed keep-alive almost always re-fail
// (#86). 250ms·2ⁿ, capped at 4s over 6 tries; Esc cancels.
// Transport flakes back off; rapid retries against a
// just-closed keep-alive re-fail (#86). Cap: 4s/6 tries.
const delay_ms = RetryPlan.delayMs(throttled, attempt);
@import("turn_chrome.zig").emitRetryNotice(self.io, @errorName(err), attempt + 1, max_attempts);
if (showRecoveredTransportRetry(self.call_kind))
Expand All @@ -380,12 +383,9 @@ pub fn request(self: *Agent, tools_in: ?[]const u8) !std.json.ObjectMap {
continue;
}
try self.say("[request failed: {t} — giving up this turn]\n", .{err});
// Network give-up is its own error kind: the ApiError
// handler's last_api_error would otherwise be an API
// envelope, stale or null on a pure transport failure —
// record the real reason so the failed turn's --json error
// event and trajectory node preserve it (#86).
self.last_api_error = std.fmt.allocPrint(self.arena, "network error: {s} (gave up after {d} attempts)", .{ @errorName(err), max_attempts }) catch null;
// Preserve whether this was provider throttling or a transport failure in the failed turn's JSON/trajectory (#86).
const failure_kind = if (err == error.RateLimited) "rate limited (429)" else if (err == error.ServerError) "server error (5xx)" else "network error";
self.last_api_error = std.fmt.allocPrint(self.arena, "{s}: {s} (gave up after {d} attempts)", .{ failure_kind, @errorName(err), max_attempts }) catch null;
self.last_request_write_failed = std.mem.eql(u8, @errorName(err), "WriteFailed");
if (telemetry.g_telem) |t| t.errorEvent("net", @errorName(err));
if (self.tracer) |tr| tr.api(self.label, self.sub, self.provider.model, 0, body.len, 0, 0, 0, true);
Expand Down
14 changes: 12 additions & 2 deletions src/agent_stream.zig
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ const reasoningDelta = @import("title.zig").reasoningDelta;
const stream_tests = @import("agent_stream_tests.zig");

const http = @import("http.zig");
const http_client = @import("http_client.zig");
const http_headers = @import("http_headers.zig");
const providerUserAgent = http.providerUserAgent;
const capture5xxBodyStream = http.capture5xxBodyStream;
Expand Down Expand Up @@ -52,6 +53,12 @@ pub fn postStream(self: *Agent, body: []const u8) ![]u8 {
/// keep-alive cannot poison the WS→SSE handoff and every fallback retry dials
/// from a clean pool.
pub fn postStreamWithClient(self: *Agent, client: *std.http.Client, body: []const u8) ![]u8 {
http_client.waitForReady(client.io);
var lease = http_client.acquire(client);
defer lease.release();
if (!lease.available) return error.Canceled;
if (http_client.injectedConstructionTls(&lease)) |err| return err;
const transport = lease.client;
const sink = engine_sink.forAgent(self);
sink.emit(self.io, .stream_begin);
// Every exit path — success, interrupt, transport error — tears down the
Expand Down Expand Up @@ -109,14 +116,17 @@ pub fn postStreamWithClient(self: *Agent, client: *std.http.Client, body: []cons
_ = drainSteerStdin(true);
restoreStdin(o);
};
var req = try client.request(.POST, try std.Uri.parse(provider.url), .{
var req = transport.request(.POST, try std.Uri.parse(provider.url), .{
.redirect_behavior = .unhandled,
.headers = .{
.content_type = .{ .override = "application/json" },
.user_agent = providerUserAgent(provider),
},
.extra_headers = extra,
});
}) catch |err| {
if (err == error.TlsInitializationFailed) return http_client.constructionTlsError(transport);
return err;
};
defer req.deinit();
// A failed SEND leaves reader.state == .ready, which Request.deinit
// reads as "connection still clean" and returns it to the keep-alive
Expand Down
38 changes: 18 additions & 20 deletions src/http.zig
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,9 @@ const Provider = provider_mod.Provider;
const Agent = agent_mod.Agent;
const headers = @import("http_headers.zig");
const stall = @import("http_stall.zig"); // #56: the watchdogs' pure budget arithmetic

/// Launch-scoped gate installed while the shared client's CA bundle warms in
/// the background. Null in unit tests and standalone pre-client subcommands.
pub var g_client_ready: ?*Io.Event = null;

pub fn waitForClientReady(io: Io) void {
if (g_client_ready) |ready| ready.waitUncancelable(io);
}

const http_client = @import("http_client.zig");
pub const waitForClientReady = http_client.waitForReady;
pub const takeCaWarmFailure = http_client.takeCaWarmFailure;
pub const providerUserAgent = headers.userAgent;
pub const providerHeaders = headers.providerHeaders;
/// Test/call-site seam: the !live request path's POST, with an explicit conv id.
Expand Down Expand Up @@ -113,15 +107,16 @@ test "retryAfterMs: seconds, ms preferred, cap, HTTP-date/none -> 0 (#retry-afte

/// POST the request body; returns the raw response body (caller frees).
/// Built on client.request, NOT client.fetch: fetch never exposes the
/// Request, so a failed body send could not be poisoned — std re-pooled the
/// dead connection (a failed SEND leaves reader.state == .ready, which
/// Request.deinit reads as "still clean") and findConnection handed the same
/// corpse to every retry and every later same-host request, so one
/// WriteFailed became a whole-session storm across compaction, [title], and
/// subagents (#177). Mirrors postStream's errdefer poison (agent_stream.zig).
/// The client and its connection pool stay shared across pool threads —
/// client.request is what fetch wraps and is equally thread-safe.
/// Request, so a failed body send could not be poisoned and std re-pooled the
/// dead connection across later retries, compaction, titles, and subagents
/// (#177). Mirrors postStream's errdefer poison (agent_stream.zig).
fn post(gpa: Allocator, client: *std.http.Client, provider: Provider, body: []const u8, conv_id: ?[]const u8) ![]u8 {
http_client.waitForReady(client.io);
var lease = http_client.acquire(client);
defer lease.release();
if (!lease.available) return error.Canceled;
if (http_client.injectedConstructionTls(&lease)) |err| return err;
const transport = lease.client;
var aw: Io.Writer.Allocating = .init(gpa);
errdefer aw.deinit();

Expand All @@ -132,16 +127,19 @@ fn post(gpa: Allocator, client: *std.http.Client, provider: Provider, body: []co
defer if (bearer.len > 0) gpa.free(bearer);

var headers_buf: [12]std.http.Header = undefined;
const extra = headers.providerHeadersWithConv(client.io, provider, bearer, &headers_buf, conv_id);
const extra = headers.providerHeadersWithConv(transport.io, provider, bearer, &headers_buf, conv_id);

var req = try client.request(.POST, try std.Uri.parse(provider.url), .{
var req = transport.request(.POST, try std.Uri.parse(provider.url), .{
.redirect_behavior = .unhandled,
.headers = .{
.content_type = .{ .override = "application/json" },
.user_agent = providerUserAgent(provider),
},
.extra_headers = extra,
});
}) catch |err| {
if (err == error.TlsInitializationFailed) return http_client.constructionTlsError(transport);
return err;
};
defer req.deinit();
// The #177 poison: on ANY error make deinit discard this connection
// instead of returning it to the keep-alive pool, so the retry (and
Expand Down
Loading
Loading