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
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,9 @@ bot can also read a page with `web_fetch`, which the daemon runs itself. Put
`web_search = "disabled"` in `config.toml` to leave both out of new bots.
A message one agent sends another reaches its model with a line naming the
sender, so no agent takes it for yours; `senders = "hidden"` leaves that out.
A request Claude's safety classifiers decline ends the turn, unless
`[fallbacks]` names another model for the bot's own; that model then answers
that request alone ([CLI.md](docs/CLI.md)).

## How fast is it?

Expand Down
15 changes: 9 additions & 6 deletions bench/harbor_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,12 +115,14 @@ class Agent(BaseInstalledAgent):

def __init__(self, *args: Any, binary: str | None = None,
provider: str | list[str] | None = None, reasoning: str | None = None,
chatgpt_login: str | None = None, **kwargs: Any) -> None:
fallback: str | None = None, chatgpt_login: str | None = None,
**kwargs: Any) -> None:
limits = {key: kwargs.pop(key) for key in list(kwargs) if key in self._LIMITS}
super().__init__(*args, **kwargs)
self._binary = Path(binary or os.environ.get('AGENT_HARBOR_BINARY') or DEFAULT_BINARY)
self._providers = [provider] if isinstance(provider, str) else list(provider or [])
self._reasoning = reasoning
self._fallback = fallback
self._limits = limits
# A chatgpt/ model spends the plan through `agent login chatgpt`'s
# login, unless a chatgpt spec names another endpoint, which never
Expand Down Expand Up @@ -202,9 +204,10 @@ def _chatgpt_login(self, now: float | None = None) -> dict[str, Any]:
def _command(self, instruction: str) -> str:
if not self.model_name:
raise ValueError('Model name is required, as PROVIDER/MODEL')
# Benchmarks keep going past a declined Anthropic request on the
# model Anthropic recommends; a fleet opts in per bot.
flags = ['--new', '--bot', BOT, '--agents', '--fallbacks', '--model', self.model_name]
flags = ['--new', '--bot', BOT, '--agents', '--model', self.model_name]
# A request a safety classifier declined goes to this model alone.
if self._fallback:
flags += ['--fallback', self._fallback]
for spec in self._providers:
flags += ['--provider', spec]
if self._reasoning:
Expand Down Expand Up @@ -344,7 +347,7 @@ def populate_context_post_run(self, context: AgentContext) -> None:
context.metadata['bots'] = len({t['bot'] for t in turns})
# A bot the task delegated to chose its own settings.
context.metadata['bot_settings'] = {
t['bot']: {'reasoning': t['reasoning'], 'fallbacks': bool(t['fallbacks'])}
t['bot']: {'reasoning': t['reasoning'], 'fallback': t['fallback']}
for t in turns}
# Two arms asking for one model are matched only if both ran it, so
# say what was asked and what answered. Without the store only the
Expand Down Expand Up @@ -375,7 +378,7 @@ def _store_turns(self) -> list[dict[str, Any]] | None:
return [dict(row) for row in db.execute(
"SELECT t.bot,COALESCE(t.model,b.provider||'/'||b.model) AS model,t.status,"
't.input_tokens,t.cached_input_tokens,t.output_tokens,'
't.model_rounds,t.retries,t.paced_ms,b.reasoning,b.fallbacks '
't.model_rounds,t.retries,t.paced_ms,b.reasoning,b.fallback '
'FROM turns t JOIN bots b ON b.name=t.bot ORDER BY t.id')]
except sqlite3.Error:
return None
Expand Down
21 changes: 21 additions & 0 deletions client/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@
//! web_search = "disabled" # "live", the default, lets new agents search and fetch
//! senders = "hidden" # "shown", the default, labels what agents send
//!
//! [fallbacks] # the model a declined request goes to, by model
//! "anthropic/claude-opus-5-5" = "anthropic/claude-opus-4-8"
//!
//! [judge]
//! model = "openai/gpt-6-luna" # who judges for the approver and the lookout
//! effort = "low" # a model judge's effort
Expand Down Expand Up @@ -54,6 +57,10 @@ pub struct Config {
/// sent it with no line naming the sender: `senders = "hidden"`;
/// `"shown"`, the default, names it.
pub hide_senders: bool,
/// Each model's fallback, as `provider/model` in its family: a new
/// agent on that model that names none sends a request a safety
/// classifier declined to it, that request alone.
pub fallbacks: BTreeMap<String, String>,
pub judge: Judge,
pub sonny: Sonny,
}
Expand Down Expand Up @@ -339,6 +346,14 @@ fn parse(text: &str) -> Result<Config, String> {
};
continue;
}
if name == "fallbacks" {
let models = value.as_table().ok_or("fallbacks must be a table")?;
for (model, fallback) in models {
let fallback = string(&format!("fallbacks.{model}"), fallback)?;
config.fallbacks.insert(model.clone(), fallback);
}
continue;
}
if let Some(top) = match name.as_str() {
"model" => Some(&mut config.model),
"provider" => Some(&mut config.provider),
Expand Down Expand Up @@ -686,6 +701,12 @@ mod tests {
assert!(!quiet.sonny.chime && quiet.sonny.verbose);
assert!(parse("senders = \"hidden\"").unwrap().hide_senders);
assert!(!parse("senders = \"shown\"").unwrap().hide_senders);
let fallbacks = parse("[fallbacks]\n\"a/big\" = \"a/other\"\n")
.unwrap()
.fallbacks;
assert_eq!(fallbacks.get("a/big").map(String::as_str), Some("a/other"));
assert!(parse("[fallbacks]\n\"a/big\" = 1\n").is_err());
assert!(parse("fallbacks = \"a/other\"").is_err());
for (text, error) in [
(
"web_search = \"cached\"",
Expand Down
11 changes: 11 additions & 0 deletions client/src/project.rs
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,8 @@ pub struct Picks {
pub threads: Option<ThreadPicks>,
/// The person's `web_search`, for a coordinator its role gives no tools.
pub web_search: crate::config::WebSearch,
/// The person's fallback for each model, from their config.
pub fallbacks: std::collections::BTreeMap<String, String>,
}

#[derive(Debug, Default)]
Expand Down Expand Up @@ -408,6 +410,9 @@ pub async fn create(
if let Some(effort) = effort {
params["effort"] = json!(effort);
}
if let Some(fallback) = picks.fallbacks.get(model) {
params["fallback"] = json!(fallback);
}
// The client's settings, such as a sandbox, with the model's.
let mut settings = policy["settings"].as_object().cloned().unwrap_or_default();
if let Some(ttl) = crate::policy::cache_ttl(model) {
Expand Down Expand Up @@ -559,6 +564,7 @@ mod tests {
in_project: true,
}),
web_search: Default::default(),
fallbacks: [("alpha/one".into(), "alpha/two".into())].into(),
};
let made = make(&daemon, &dir, &policy, &picks).unwrap();
assert_eq!(
Expand Down Expand Up @@ -589,6 +595,8 @@ mod tests {
&json!("high")
)
);
// The person's fallback for its model.
assert_eq!(create["fallback"], "alpha/two");
assert_eq!(
create["instructions"],
"rules\n\nEvery task works in this folder, with no worktree of its own."
Expand Down Expand Up @@ -773,6 +781,7 @@ mod tests {
effort: Some("max".into()),
threads: None,
web_search: Default::default(),
fallbacks: [("alpha/one".into(), "alpha/two".into())].into(),
};
let made = make(&daemon, &dir, &json!({"instructions": "rules"}), &picks).unwrap();
assert_eq!(
Expand All @@ -785,6 +794,8 @@ mod tests {
(&create["bot"], &create["model"], &create["effort"]),
(&json!("demo.lead"), &json!("alpha/one"), &json!("low"))
);
// The fallback follows the model the file kept, not the pick.
assert_eq!(create["fallback"], "alpha/two");
// A kept file that names no effort takes the picked one.
let dir = root("kept-effort");
write(&dir, "plain", "alpha/one", None, &Threads::default()).unwrap();
Expand Down
1 change: 1 addition & 0 deletions docs/BEDROCK.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,7 @@ it bounds the sends, and a fleet cannot start every pre-read at once.
| Legacy thinking budget (Haiku 4.5) | yes | **observed** on Mantle | carried |
| Thinking-binding check (`anthropic-beta: thinking-binding-controls-2026-08-01`, `block_binding`) | yes (PR #12) | **observed** on both: signed blocks replayed with no drops; on Mantle `block_binding` without the beta is a 400 | carried |
| Server-side fallbacks (`fallbacks`, PR #13) | yes | **documented** unsupported; **observed** on Mantle: the field and the beta are each a 400 | not carried: Bedrock requests send neither |
| Fallback credit (`fallback_credit_token`, `fallback-credit-2026-07-01`) | yes | **documented** supported; not tried on Bedrock | not carried: a refused request's retry on the bot's `--fallback` sends no credit, since an unknown beta is a 400 on Mantle |
| Responses over WebSocket | yes | **documented** unsupported on either endpoint | refused for Bedrock URLs |
| Rate-limit headers for pacing | yes | **documented** absent | escalating refusal backoff instead |
| Legacy thinking budgets by model name | yes | ids are vendor- and profile-prefixed | carried: `us.anthropic.claude-haiku-4-5…` reads as `claude-haiku-4-5` |
Expand Down
9 changes: 9 additions & 0 deletions docs/CLI.md
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,15 @@ family is trained on: `apply_patch` for a Responses model, else `edit`, and
`~/.agent/config.toml` sets `web_search = "disabled"`. An
existing bot keeps its own,
so `--tools` while continuing one is a usage error.
`run --fallback PROVIDER/MODEL` gives a new bot a model, in its family, that
answers a request a safety classifier declined, that request alone; the next
goes to the bot's own model. Without it the bot takes its model's entry in
`[fallbacks]` in `~/.agent/config.toml`, if any:

```toml
[fallbacks]
"anthropic/claude-opus-5-5" = "anthropic/claude-opus-4-8"
```
Other client commands require an already running daemon.

`models` prints `~/.agent/models`, the models clients offer, as JSON
Expand Down
4 changes: 2 additions & 2 deletions docs/COMPARISON_CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,8 +153,8 @@ it asked for and what actually answered, each from that harness's own records:
response, and its per-model totals, which include any auxiliary model it
calls.
- **Fallback policy as configured.** Our adapter creates task bots with
`--fallbacks`, so a declined Anthropic request finishes on the model
Anthropic recommends; the flag does nothing on the OpenAI or ChatGPT
`--fallback` when given one, so a request a safety classifier declined is
answered by that model; it does nothing on the OpenAI or ChatGPT
providers. A bot the task delegates to sets its own, and the trial
metadata's `bot_settings` records each counted bot's choice. Each
baseline's flags or settings come from its adapter's source at the pinned
Expand Down
27 changes: 27 additions & 0 deletions docs/DAEMON_MEASUREMENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -5717,3 +5717,30 @@ in every bench, skip the look. The Perf workflow's matched screen, base main
Store jobs per turn did not change, and every change is within the gate's
15%. Sandboxed bots and the parse itself are not measured: it walks the
command's characters once, against a list of at most 64 prefixes.

## A declined request goes to the bot's fallback, that request alone (#349)

Every model call now keeps its own provider and model beside the target it
sends to, and takes a parked fallback back on resume; a bot without a
fallback, as in every bench, carries `None` through both. A refusal builds a
fallback only on `stop_reason: "refusal"`. The Perf workflow's matched
screen, base main fc8560a against the PR head 7de1ff4, on the GitHub runner
(2026-10-29):

| Bench | Measure | Base | Head | Change |
| --- | --- | ---: | ---: | ---: |
| admission | lone submit CPU, ms | 29.67 | 29.48 | -0.6% |
| admission | burst submit p99, ms | 4.079 | 3.971 | -2.7% |
| completion | release to terminal p99, ms | 9.463 | 9.056 | -4.3% |
| footprint | idle RSS, MiB | 18.861 | 19.109 | +1.3% |
| footprint | per idle bot, KiB | 1.652 | 1.372 | -16.9% |
| footprint | RSS after a turn each, MiB | 28.752 | 28.312 | -1.5% |
| footprint | CPU per turn, ms | 0.893 | 0.867 | -2.9% |
| lifecycle | CPU, s | 0.88 | 0.885 | +0.6% |
| lifecycle | p95 turn, ms | 630.609 | 620.821 | -1.6% |
| lifecycle | peak RSS, MiB | 25.453 | 25.283 | -0.7% |

Store jobs per turn did not change (7.5 on both), and no change is a cost
past the gate's 15%; the per-idle-bot drop is the bench's noise, as the
change adds nothing to an idle bot. The refused path is not measured: it
runs once per refusal, a second request to the provider.
16 changes: 9 additions & 7 deletions docs/HARBOR.md
Original file line number Diff line number Diff line change
Expand Up @@ -220,8 +220,8 @@ the same model can drive each harness on the same tasks.
empty when any model used is missing from the table, rather than reported low.
The trial's metadata records the requested model, `served_calls` (billed
calls per model, fallbacks and delegated bots included) and
`bot_settings` (each counted bot's reasoning level and whether it takes
fallbacks, since a delegated bot sets its own), so a comparison can check
`bot_settings` (each counted bot's reasoning level and fallback model,
since a delegated bot sets its own), so a comparison can check
that both arms ran the same model the same way
([COMPARISON_CONTRACT.md](COMPARISON_CONTRACT.md#task-comparisons)).
A call counts under the model the provider named in its response, each
Expand Down Expand Up @@ -274,10 +274,12 @@ harbor run -d terminal-bench/terminal-bench-2-1 -a strands -m anthropic/claude-f
```

To run on a ChatGPT plan instead of an API key, sign in with `agent login
chatgpt` and name the model `chatgpt/MODEL`, using an id `agent models` lists. Every
task bot is created with `--fallbacks`, so a declined Anthropic request finishes
on the model Anthropic recommends instead of failing the task; a fleet opts in
per bot, and so does a bot the task delegates to. The
chatgpt` and name the model `chatgpt/MODEL`, using an id `agent models` lists. With
`--ak fallback=PROVIDER/MODEL` the task bot is created with `--fallback`, so a
request a safety classifier declined is answered by that model instead of
failing the task; runs before 2026-10-29 created it with `--fallbacks`, which
asked Anthropic for its server-side fallback. A bot the task delegates to sets
its own. The
adapter copies the access token, client and expiry from `~/.agent/chatgpt.json`
(or `AGENT_CHATGPT_LOGIN`) into each task container, readable only by the agent
user, points `AGENT_CHATGPT_LOGIN` at it, and adds `--provider chatgpt`. The
Expand All @@ -301,7 +303,7 @@ PYTHONPATH=. harbor run -d terminal-bench/terminal-bench-2-1 -a bench.harbor_age
CODEX_FORCE_AUTH_JSON=1 harbor run -d terminal-bench/terminal-bench-2-1 -a codex -m MODEL -n 2 -l 5
```

Pass `--ak KEY=VALUE` for adapter options: `reasoning`, `max_output_tokens`,
Pass `--ak KEY=VALUE` for adapter options: `reasoning`, `fallback`, `max_output_tokens`,
`stall_timeout`, `context_bytes`, `compact_at`, `binary` (another build),
`chatgpt_login` (another login file for `chatgpt/` models), and
`provider` (a `--provider` spec for a gateway such as Bedrock, whose named key
Expand Down
Loading
Loading