Skip to content

Commit 7ee3592

Browse files
Rate limits: count them and adapt how many calls run at once
- Providers report a refusal for rate or capacity reasons (HTTP 429, overloaded) as ProviderError::RateLimited, apart from other transient errors. It is retried with the same backoff. - One Throttle per session: max_parallel is the ceiling; a rate limit halves the window (never below one); ten calls in a row that go through give a slot back. Plan tasks and sub-agent calls start new work only while the window has room; running work is never stopped. - Counters provider_retries and rate_limit_retries; a guard event of kind rate_limited; a plan event window once a rate limit has narrowed it, which the task board shows ("slowed to 2 at a time after 3 rate limits"). - The score is shown as 93.0% ±0.8, the run-to-run standard error across the five runs, the measure the official leaderboard uses; the across-tasks interval stays, labelled as such.
1 parent d9bbe13 commit 7ee3592

26 files changed

Lines changed: 374 additions & 64 deletions

‎BLUEPRINT.md‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ This document says **what** gitagent is built to do and **why** each part exists
66

77
## 0. Goal in one paragraph
88

9-
gitagent 3 is a single static Rust binary that runs **agents defined as git repositories**. An agent is a folder of plain files (identity, rules, memory, skills, tools, hooks, schedules, sub-agents), so its behaviour is versioned, reviewed and diffed like code. One loop runs every agent, the same way in a terminal, behind an SDK, in a fresh clone of a repository, on a timetable, or inside a benchmark container. That loop is built to finish real work: it shapes what the model reads, keeps the provider's cache warm, guards against loops and running out of time, and refuses "done" until the work has been verified. The proof is public: 93.03% on Terminal-Bench 2.1 with Claude Opus 5.5, above every published harness on that benchmark.
9+
gitagent 3 is a single static Rust binary that runs **agents defined as git repositories**. An agent is a folder of plain files (identity, rules, memory, skills, tools, hooks, schedules, sub-agents), so its behaviour is versioned, reviewed and diffed like code. One loop runs every agent, the same way in a terminal, behind an SDK, in a fresh clone of a repository, on a timetable, or inside a benchmark container. That loop is built to finish real work: it shapes what the model reads, keeps the provider's cache warm, guards against loops and running out of time, and refuses "done" until the work has been verified. The proof is public: 93.0% ±0.8 on Terminal-Bench 2.1 with Claude Opus 5.5, above every published harness on that benchmark.
1010

1111
---
1212

@@ -43,7 +43,7 @@ gitagent 3 is a single static Rust binary that runs **agents defined as git repo
4343

4444
gitagent is measured on Terminal-Bench 2.1, and Terminal-Bench runs through **Harbor**, an open framework for running agents against containerised tasks. Harbor matters for four reasons.
4545

46-
1. **It is the same yardstick as the published results.** The leaderboards for Terminal-Bench 2.1 were produced with Harbor: the same task images, the same time limits, the same hidden tests, the same scoring. Running gitagent through it is what makes 93.03% comparable with the published 87.6% for Terminus 2 on the same model, rather than a number from a home-made runner.
46+
1. **It is the same yardstick as the published results.** The leaderboards for Terminal-Bench 2.1 were produced with Harbor: the same task images, the same time limits, the same hidden tests, the same scoring. Running gitagent through it is what makes 93.0% ±0.8 comparable with the published 87.6% for Terminus 2 on the same model, rather than a number from a home-made runner.
4747
2. **Grading is independent of the agent.** Harbor starts each task's container, hands the agent the instruction, stops it at the task's time limit, and when the agent has finished it runs the task's hidden tests itself and records the reward. gitagent never sees, reads or writes the tests. The result is a pass or a fail decided outside the agent, which is the only kind worth publishing.
4848
3. **It fits a static binary.** Harbor's "installed agent" model uploads the agent into the task container and runs it there. gitagent is one binary with no runtime, so installing it is a single file copy, and nothing in the container has to change. The shim that does this, `gitagent_harbor/agent.py`, is about two hundred lines.
4949
4. **It produces evidence others can check.** Harbor runs many trials in parallel, resumes trials that failed for reasons outside the agent (a revoked key, a full Docker network), keeps every trajectory and verifier log, and uploads a job publicly. The headline run's nine jobs, one per time limit, are public, so anyone can read every trial behind the number.
@@ -130,6 +130,7 @@ Steps 4 to 9 repeat until the stop check accepts, the agent replies in conversat
130130
| D24 | Everyday defaults keep the benchmark's guards, except the snapshot; the console has no time limit unless one is set | The loop detector, stop check and context clearing help in any repository; a person at the console stops a turn with Ctrl-C, while runs nobody watches keep a 30-minute ceiling; the snapshot costs up to 15 seconds per start in a project the user already knows |
131131
| D25 | Sub-agents are warned as their turns run low, may be given their own turn limit (up to 100), and come back marked partial when they run out | A review cut off mid-step wastes its whole cost; a warned one hands back what it found, and the lead re-runs only what is marked partial |
132132
| D26 | The `/tests` write guard applies to benchmark runs only | It protects graders' tests; in a user's project `/tests` is an ordinary path and a pattern such as `grep -v /tests/` must not be refused |
133+
| D27 | One throttle per session: `max_parallel` is a ceiling, a rate-limit refusal halves the window, ten clean calls in a row give a slot back | The right number of parallel calls depends on the account's rate limit, which gitagent cannot know; backing off beats retrying blindly, and the ceiling stays the user's choice |
133134

134135
**Left out on purpose:** critics that rewrite answers; best-of-N on benchmarks; memory or learning during a scored run; automated rewriting of the harness; masking every turn; third-party multi-provider frameworks; a web interface; voice; remote sandboxes.
135136

@@ -223,7 +224,7 @@ Dependencies are kept few: `tokio`, `reqwest` with rustls, `serde`, `serde_json`
223224

224225
## 9. Observability
225226

226-
Counters are written to `counters.json` after every turn and exported with telemetry when it is on. They cover the loop (`turns`, `tool_calls`, `malformed_calls`, `repairs`, `refusals`, `fallback_turns`), the shell (`exec_timeouts`, `background_sessions`, `interrupts`, `shell_restarts`), shaping and context (`shaped_bytes_in`, `spill_reads`, `masks_applied`, `mask_checkpoints`, `compactions`, `cache_read_tokens`, `cache_write_tokens`), the guards (`stop_accepts`, `stop_rejections`, `loop_triggers`, `protected_writes_blocked`), hooks and compliance (`hook_blocks`, `hook_modifies`, `approvals_granted`, `approvals_denied`, `compliance_blocks`), memory, sub-agents and plans (`subagent_calls`, `plan_tasks`, `plan_failed_tasks`), workflows and MCP.
227+
Counters are written to `counters.json` after every turn and exported with telemetry when it is on. They cover the loop (`turns`, `tool_calls`, `malformed_calls`, `repairs`, `refusals`, `fallback_turns`, `provider_retries`, `rate_limit_retries`), the shell (`exec_timeouts`, `background_sessions`, `interrupts`, `shell_restarts`), shaping and context (`shaped_bytes_in`, `spill_reads`, `masks_applied`, `mask_checkpoints`, `compactions`, `cache_read_tokens`, `cache_write_tokens`), the guards (`stop_accepts`, `stop_rejections`, `loop_triggers`, `protected_writes_blocked`), hooks and compliance (`hook_blocks`, `hook_modifies`, `approvals_granted`, `approvals_denied`, `compliance_blocks`), memory, sub-agents and plans (`subagent_calls`, `plan_tasks`, `plan_failed_tasks`), workflows and MCP.
227228

228229
A feature whose counters stay at zero across a measurement run is either broken or useless, and is investigated before it is kept.
229230

‎README.md‎

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
</p>
1111

1212
<p align="center">
13-
<img src="https://img.shields.io/badge/Terminal--Bench_2.1-93.03%25-e05a44?style=flat-square" alt="Terminal-Bench 2.1: 93.0%" />
13+
<img src="https://img.shields.io/badge/Terminal--Bench_2.1-93.0%25_%C2%B10.8-e05a44?style=flat-square" alt="Terminal-Bench 2.1: 93.0% ±0.8" />
1414
<img src="https://img.shields.io/badge/version-3.0.0-2a1f18?style=flat-square" alt="version 3.0.0" />
1515
<img src="https://img.shields.io/badge/built_with-Rust-2a1f18?style=flat-square" alt="Rust" />
1616
<img src="https://img.shields.io/github/license/open-gitagent/gitagent?style=flat-square&color=2a1f18" alt="license" />
@@ -32,7 +32,7 @@
3232
<img src="./docs/media/console.gif" alt="A gitagent session in the terminal" width="820" />
3333
</p>
3434

35-
**Terminal-Bench 2.1: 93.03%** with Claude Opus 5.5, all 89 tasks, five runs each, code frozen at the published commit. The published harnesses on the same benchmark: Terminus 2 with the same model 87.6%, Codex CLI with GPT-6 Astra 87.4%, and the highest published entry, Artificial Analysis's own harness with Claude Fable 5.1 at maximum effort, 91.4%. The numbers are under [Results](#results); the reason is under [How gitagent works](#how-gitagent-works).
35+
**Terminal-Bench 2.1: 93.0% ±0.8** with Claude Opus 5.5, all 89 tasks, five runs each, code frozen at the published commit; the ± is the run-to-run variation, as on the official leaderboard. The published harnesses on the same benchmark: Terminus 2 with the same model 87.6%, Codex CLI with GPT-6 Astra 87.4%, and the highest published entry, Artificial Analysis's own harness with Claude Fable 5.1 at maximum effort, 91.4%. The numbers are under [Results](#results); the reason is under [How gitagent works](#how-gitagent-works).
3636

3737
## Why gitagent
3838

@@ -49,7 +49,7 @@ Most agent frameworks treat the agent as code scattered across your application.
4949

5050
Fork an agent. Branch a personality. `git log` its memory. Diff its rules. Review a change to its behaviour like a code change. Every result traces back to a commit.
5151

52-
**gitagent 3** is the Rust rewrite: one static binary with no runtime to install, the harness that scored 93.03% on Terminal-Bench 2.1, a console in your terminal, parallel sub-agents with a live task board, and SDKs for Python, TypeScript and any other language over a simple line protocol. Agents, hooks, schedules and SDK code written for the TypeScript gitagent (1.x and 2.x) run unchanged, and agent folders in the OpenGAP format load too. See [Coming from the TypeScript gitagent](#coming-from-the-typescript-gitagent).
52+
**gitagent 3** is the Rust rewrite: one static binary with no runtime to install, the harness that scored 93.0% on Terminal-Bench 2.1, a console in your terminal, parallel sub-agents with a live task board, and SDKs for Python, TypeScript and any other language over a simple line protocol. Agents, hooks, schedules and SDK code written for the TypeScript gitagent (1.x and 2.x) run unchanged, and agent folders in the OpenGAP format load too. See [Coming from the TypeScript gitagent](#coming-from-the-typescript-gitagent).
5353

5454
## Install
5555

@@ -161,6 +161,14 @@ the next task starts as soon as one finishes. Use it for work that splits
161161
into similar pieces: many files to read, many items to check. It costs
162162
tokens in proportion, so give it scoped tasks.
163163

164+
`max_parallel` is a ceiling. When the model provider refuses a call for
165+
rate limits, the call is retried after a backoff and the number of tasks
166+
running at once is halved; after ten calls in a row go through, one slot
167+
comes back, up to the ceiling. The task board says when this happens
168+
("slowed to 2 at a time after 3 rate limits"), and every run counts its
169+
`rate_limit_retries`. So set `max_parallel` to what you want when the
170+
account allows it, and let gitagent slow down when it does not.
171+
164172
Each task gets 30 model calls unless the agent gives it its own
165173
`max_turns` (up to 100), which it can size to the task: more for a large
166174
review, fewer for a quick look-up. A task is warned when 80% of its calls
@@ -214,7 +222,7 @@ The headline run's logs are public: every trial of it, with its trajectory and v
214222

215223
| Harness | Model | Terminal-Bench 2.1 | Runs per task | Source |
216224
|---|---|---|---|---|
217-
| **gitagent 3** | Claude Opus 5.5 | **93.03%** (414/445; 75 of 89 tasks passed every run) | 5 | [job logs on the Harbor hub](#the-same-run-by-time-limit) |
225+
| **gitagent 3** | Claude Opus 5.5 | **93.0% ±0.8** (414/445; 75 of 89 tasks passed every run) | 5 | [job logs on the Harbor hub](#the-same-run-by-time-limit) |
218226
| Terminus 2 | Claude Opus 5.5 | 87.6% | 1 | [Vals.ai](https://www.vals.ai/benchmarks/terminal-bench-2-1) |
219227
| Codex CLI | GPT-6 Astra | 87.4% ±0.9 | — | [Snorkel, the official leaderboard](https://snorkel.ai/leaderboard/terminal-bench-2-1/) |
220228
| Artificial Analysis's own harness | Claude Fable 5.1, max effort | 91.4% | — | [Artificial Analysis](https://artificialanalysis.ai/evaluations/terminalbench-2-1) |
@@ -234,9 +242,9 @@ Pass@k is the chance that at least one of k runs passes:
234242
| 60 min | 65 | 95.4% | 97.7% | 100.0% | [job](https://hub.harborframework.com/jobs/e1ea8dbd-7a55-4fa3-95de-b9338a3c1223) |
235243
| 2 h | 5 | 100.0% | 100.0% | 100.0% | [job](https://hub.harborframework.com/jobs/127445ee-63ec-44c7-8206-189eb22cd912) |
236244
| 3 h 20 min | 5 | 100.0% | 100.0% | 100.0% | [job](https://hub.harborframework.com/jobs/1d8e0bdb-7ae5-4ec7-8e54-329007590e9d) |
237-
| **all** | **445** | **93.03%** | | | |
245+
| **all** | **445** | **93.0% ±0.8** | | | |
238246

239-
Two of the 89 tasks start a virtual machine inside the task container, which this host cannot do, and no harness has passed them here; without those two the mean is 95.2%. The 95% interval for the headline run is 88.8–96.9%. It cost $112.85, about $0.25 per run, with 92% of the input tokens served from the prompt cache.
247+
Two of the 89 tasks start a virtual machine inside the task container, which this host cannot do, and no harness has passed them here; without those two the mean is 95.2%. The ±0.8 is the standard error across the five runs (93.3, 95.5, 92.1, 92.1 and 92.1%), the measure the official leaderboard shows; across tasks, the 95% interval is 88.8–96.9%, which answers the wider question of how the score would hold on a different set of tasks. It cost $112.85, about $0.25 per run, with 92% of the input tokens served from the prompt cache.
240248

241249
### Why it scores higher than the other harnesses
242250

‎crates/cli/src/console.rs‎

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -463,6 +463,9 @@ struct BoardState {
463463
phase_total: usize,
464464
/// Columns to draw in; longer lines are cut so each takes one row.
465465
width: usize,
466+
/// (running at once, ceiling, rate limits so far), once a rate limit
467+
/// has narrowed the window.
468+
window: Option<(usize, usize, u64)>,
466469
tasks: Vec<TaskRow>,
467470
reconcile: Option<(bool, Option<bool>, String, std::time::Instant)>,
468471
drawn: usize,
@@ -543,6 +546,13 @@ impl Board {
543546
t.took = Some(now.duration_since(t.started));
544547
}
545548
}
549+
"window" => {
550+
b.window = Some((
551+
e["at_once"].as_u64().unwrap_or(0) as usize,
552+
e["ceiling"].as_u64().unwrap_or(0) as usize,
553+
e["rate_limited"].as_u64().unwrap_or(0),
554+
));
555+
}
546556
"reconcile_start" => b.reconcile = Some((true, None, String::new(), now)),
547557
"reconcile_end" => {
548558
b.reconcile = Some((false, Some(e["ok"] == true), s("result"), now));
@@ -624,12 +634,23 @@ impl Board {
624634
let finished = b.tasks.len() - running;
625635
let total = b.phase_total.max(b.tasks.len());
626636
let elapsed = b.started.map(|s| s.elapsed().as_secs()).unwrap_or(0);
637+
let slowed = match b.window {
638+
Some((w, c, hits)) if w < c => format!(
639+
" · slowed to {w} at a time after {hits} rate limit{}",
640+
if hits == 1 { "" } else { "s" }
641+
),
642+
Some((_, c, hits)) => format!(
643+
" · {hits} rate limit{}, back to {c} at a time",
644+
if hits == 1 { "" } else { "s" }
645+
),
646+
None => String::new(),
647+
};
627648
out.push(format!(
628649
"{} {} {}",
629650
st.orange("⇶"),
630651
st.bold(&format!("phase {}/{} {}", b.phase, b.of, b.phase_name)),
631652
st.dim(&format!(
632-
"· {running} running · {finished} of {total} done · {elapsed}s"
653+
"· {running} running · {finished} of {total} done · {elapsed}s{slowed}"
633654
))
634655
));
635656
const SHOW: usize = 14;

‎crates/cli/src/ipc.rs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1014,6 +1014,7 @@ async fn session(cli: Cli, out: Out) -> i32 {
10141014
kill: Some(ctl.kill.clone()),
10151015
steer: Some(ctl.steering.clone()),
10161016
external_tools: external.clone(),
1017+
throttle: None,
10171018
};
10181019
let run = gitagent_core::run(&s.spec, s.provider.as_ref(), opts);
10191020
tokio::pin!(run);
@@ -1142,6 +1143,7 @@ async fn session(cli: Cli, out: Out) -> i32 {
11421143
kill: None,
11431144
steer: None,
11441145
external_tools: external.clone(),
1146+
throttle: None,
11451147
};
11461148
match gitagent_core::teardown(&s.spec, s.provider.as_ref(), opts).await {
11471149
Some(Ok(done)) => out.system(

‎crates/cli/src/main.rs‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -542,6 +542,7 @@ async fn single(cli: Cli, instruction: String) -> ExitCode {
542542
kill: None,
543543
steer: None,
544544
external_tools: None,
545+
throttle: None,
545546
},
546547
)
547548
.await?;
@@ -620,6 +621,7 @@ async fn run_workflow(cli: Cli, name: String) -> ExitCode {
620621
kill: None,
621622
steer: None,
622623
external_tools: None,
624+
throttle: None,
623625
};
624626
let report =
625627
gitagent_core::run_workflow(&s.spec, s.provider.as_ref(), &name, &inputs, opts).await?;
@@ -666,6 +668,7 @@ pub(crate) async fn teardown(
666668
kill: None,
667669
steer: None,
668670
external_tools: None,
671+
throttle: None,
669672
};
670673
match gitagent_core::teardown(&s.spec, s.provider.as_ref(), opts).await {
671674
Some(Ok(mut done)) => {

‎crates/cli/src/repl.rs‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ pub(crate) async fn run(s: Setup, out_dir: &Path) -> Result<()> {
108108
kill: None,
109109
steer: None,
110110
external_tools: None,
111+
throttle: None,
111112
};
112113
match gitagent_core::run_workflow(&s.spec, s.provider.as_ref(), &name, &inputs, opts)
113114
.await
@@ -160,6 +161,7 @@ pub(crate) async fn run(s: Setup, out_dir: &Path) -> Result<()> {
160161
kill: None,
161162
steer: None,
162163
external_tools: None,
164+
throttle: None,
163165
};
164166
let spinner = console::Spinner::start(st, "thinking");
165167
tokio::select! {
@@ -203,6 +205,7 @@ pub(crate) async fn run(s: Setup, out_dir: &Path) -> Result<()> {
203205
kill: None,
204206
steer: None,
205207
external_tools: None,
208+
throttle: None,
206209
};
207210
match gitagent_core::teardown(&s.spec, s.provider.as_ref(), opts).await {
208211
Some(Ok(done)) => eprintln!(

‎crates/cli/src/repo_mode.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ async fn run_inner(cli: &mut Cli, url: String, task: String) -> Result<ExitCode>
9999
kill: None,
100100
steer: None,
101101
external_tools: None,
102+
throttle: None,
102103
},
103104
)
104105
.await?;

‎crates/cli/src/schedule_cmd.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -343,6 +343,7 @@ impl Runner {
343343
kill: None,
344344
steer: None,
345345
external_tools: None,
346+
throttle: None,
346347
};
347348
let (success, result) =
348349
match gitagent_core::run(&self.setup.spec, self.setup.provider.as_ref(), opts).await {

0 commit comments

Comments
 (0)