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
2 changes: 1 addition & 1 deletion bench/admission_burst.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ def send():
row['burst_create'] = phase(client, process, model, together(
[('create', dict(bot=f'burst{n}', workspace=workspace)) for n in range(bots)]))
row['burst_submit'] = phase(client, process, model, together(
[('submit', dict(bot=f'burst{n}', request_id='first', prompt='hold:admission'))
[('submit', dict(bot=f'burst{n}', request_id='first', prompt='hold:admission', delivery='reject'))
for n in range(bots)]), turns=bots)
started = client.request('stats')['result']['active_turns']
assert started == 2 * bots, started
Expand Down
4 changes: 2 additions & 2 deletions bench/approval_overhead.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ def send(op, **params):
def submit(name):
remaining[name] -= 1
pending_submits[send('submit', bot=name, request_id=f'{name}-{remaining[name]}',
prompt=prompt)] = (name, time.monotonic())
prompt=prompt, delivery='reject')] = (name, time.monotonic())

def answer(message, calls):
for call in calls:
Expand Down Expand Up @@ -291,7 +291,7 @@ def send(op, **params):
def submit(name):
remaining[name] -= 1
pending_submits[send('submit', bot=name, request_id=f'{name}-{remaining[name]}',
prompt='shell:true')] = (name, time.monotonic())
prompt='shell:true', delivery='reject')] = (name, time.monotonic())

wall0 = time.monotonic()
for name in names:
Expand Down
2 changes: 1 addition & 1 deletion bench/daemon_driver.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ def submit(self, agent):
turn = self.turn_of[agent]
self.progress[agent] = [0, 0]
self.request('submit', agent, bot=f'b{agent}', request_id=f'{agent}-{turn}',
prompt=prompt(self.config, agent, turn))
prompt=prompt(self.config, agent, turn), delivery='reject')
return {'event': 'turn_start', 'agent': str(agent), 'turn': str(turn)}

def handle(self, message):
Expand Down
2 changes: 1 addition & 1 deletion bench/footprint.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ def rss_mib():
began, cpu = time.monotonic(), cpu_ms(process)
turns = []
for first in range(0, bots, BATCH):
for reply in pipelined(client, [('submit', dict(bot=f'b{n}', request_id='one', prompt='hi'))
for reply in pipelined(client, [('submit', dict(bot=f'b{n}', request_id='one', prompt='hi', delivery='reject'))
for n in range(first, min(bots, first + BATCH))]):
assert 'result' in reply, reply
turns.append(reply['result']['turn'])
Expand Down
6 changes: 3 additions & 3 deletions bench/lookout_scale.py
Original file line number Diff line number Diff line change
Expand Up @@ -295,7 +295,7 @@ def setup(self):
(folder / f'p{p}.lead.json').write_text(json.dumps({'subscriptions': [tasks]}))
# One finished turn each lead's messages to its tasks come from: what
# a lead writes in its tasks is no news to it, only how they end.
firsts = self.control.many([('submit', {'bot': lead, 'request_id': 'first', 'prompt': 'hi'})
firsts = self.control.many([('submit', {'bot': lead, 'request_id': 'first', 'prompt': 'hi', 'delivery': 'reject'})
for lead in self.leads()])
self.from_turn = {lead: r['result']['turn'] for lead, r in zip(self.leads(), firsts)}
while self.busy_tasks():
Expand Down Expand Up @@ -357,7 +357,7 @@ def load(self, seconds, with_lookout=True):
# all have ended.
while time.monotonic() < stop:
batch = [('submit', {'bot': task, 'request_id': f'{self.phase}r{n}-{task}',
'prompt': f'stream:{count}:{ms}',
'prompt': f'stream:{count}:{ms}', 'delivery': 'reject',
'from': {'bot': task.split('.')[0] + '.lead',
'turn': self.from_turn[task.split('.')[0] + '.lead']}})
for task in tasks]
Expand Down Expand Up @@ -450,7 +450,7 @@ def approver(self, gated):
raise RuntimeError('approver did not start')
sampler = Sampler(process.pid)
started = time.monotonic()
self.control.many([('submit', {'bot': name, 'request_id': 'gate', 'prompt': 'shell:true'})
self.control.many([('submit', {'bot': name, 'request_id': 'gate', 'prompt': 'shell:true', 'delivery': 'reject'})
for name in names])
decided = lambda: [c for l in lines if l.get('event') == 'judged' for c in l['calls']]
while len(decided()) < gated and time.monotonic() - started < 300:
Expand Down
3 changes: 3 additions & 0 deletions bench/runtime_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,9 @@ def receive(self, predicate, timeout=5):
self.saved.append(message)

def request(self, op, **params):
if op == 'submit':
# What a busy bot does with it; the daemon supplies none.
params.setdefault('delivery', 'reject')
if op == 'create':
params.setdefault('model', self.model)
params.setdefault('instructions', self.instructions)
Expand Down
3 changes: 3 additions & 0 deletions bench/socket_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,9 @@ def record(self, event):
self.queue.put(event)

def request(self, op, **params):
if op == 'submit':
# What a busy bot does with it; the daemon supplies none.
params.setdefault('delivery', 'reject')
if op == 'create':
# This client's choice for a new bot; the daemon supplies none.
params.setdefault('model', 'openai/synthetic-model')
Expand Down
41 changes: 41 additions & 0 deletions client/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,47 @@ pub fn default_tools(
/// together, so a client refuses a daemon of any other version.
pub const PROTOCOL: u64 = 24;

/// Variables naming the terminal a daemon was started in: a terminal
/// multiplexer's place. A client starting the daemon leaves them out of its
/// tool shells (`--shell-exclude`), as a command that used one would land
/// in that terminal, not the bot's.
pub const TERMINAL_PLACES: [&str; 12] = [
"REX_SESSION",
"REX_BLOCK",
"TMUX",
"TMUX_PANE",
"HERDR_ENV",
"HERDR_WORKSPACE_ID",
"HERDR_TAB_ID",
"HERDR_PANE_ID",
"CMUX_WORKSPACE_ID",
"CMUX_SURFACE_ID",
"CMUX_TAB_ID",
"CMUX_PANEL_ID",
];

/// Field names whose printed values are secrets: a client starting the
/// daemon has their values redacted from tool output (`--redact-field`). A
/// name matches a field's end, so `aws_secret_access_key` matches
/// `secret_access_key` and `AccessKeyId` or `NextToken` match nothing.
pub const SECRET_FIELDS: [&str; 12] = [
"secret",
"secret_key",
"secret_access_key",
"session_token",
"access_token",
"refresh_token",
"id_token",
"auth_token",
"api_key",
"password",
"passwd",
"private_key",
];

/// PEM blocks whose bodies are secrets (`--redact-block`).
pub const SECRET_BLOCKS: [&str; 1] = ["PRIVATE KEY"];

/// Start `command` in a session of its own, for a service that outlives the
/// terminal it was started from: closing a Herdr pane, or stopping Herdr,
/// ends every process in the pane's session, not only its foreground job.
Expand Down
14 changes: 4 additions & 10 deletions client/src/policy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,8 @@ pub fn preamble() -> String {
}

pub const PREAMBLE: &str = "To delegate a subtask to another agent with its own conversation, run \
\"$AGENT_BIN\" run --detach --new --agents --bot NAME -- TASK from the shell; it prints a turn handle immediately. \
--agents gives it this folder's AGENTS.md, skills and memory, so its task need not say where they are. \
Start NAME with your own name up to its first dot (shop.cart-review for shop.fix-cart), so it is listed with your project's agents. \
An agent started to review or check something does it itself and starts no agents of its own. \
\"$AGENT_BIN\" run --detach --new --bot NAME -- TASK from the shell; it prints a turn handle immediately. \
Add --agents to give it this folder's AGENTS.md, skills and memory. \
Add --model PROVIDER/MODEL to give it one of the models \"$AGENT_BIN\" models lists; without it, it gets yours. \
Continue an existing agent with \"$AGENT_BIN\" run --detach --bot NAME -- TASK. \
To tell an agent something that needs no reply, such as a change of scope it should take into account, \
Expand All @@ -58,13 +56,11 @@ Use \"$AGENT_BIN\" fork --source NAME --bot NEW to branch an agent from where it
To ask an agent something without disturbing it, mid-turn included, run \
\"$AGENT_BIN\" run --detach --fork NAME --bot NEW --allow '' --expires 15m -- QUESTION and wait on its handle: \
a copy of NAME answers from its history without acting, and is deleted once it has been idle 15 minutes. \
Ask this way to learn where a busy agent is, or how it would take a steer, before sending one. \
$AGENT_BOT and $AGENT_BOT_ID identify you; $AGENT_PARENT and $AGENT_PARENT_ID, when set, identify the agent that created you. \
To contact that creator, run \
\"$AGENT_BIN\" run --detach --bot \"$AGENT_PARENT\" --bot-id \"$AGENT_PARENT_ID\" -- TASK; \
the identity check refuses a deleted creator or a replacement with the same name. \
Your final reply already reaches whoever waits on your turn, so contact your creator only to ask something you need, \
not to report results.";
Your final reply reaches whoever waits on your turn.";

/// The prompt-cache lifetime human-facing clients make a bot with: an hour
/// for Claude, on Anthropic's API and on Bedrock, where a lead or monitor that
Expand Down Expand Up @@ -1213,9 +1209,7 @@ mod tests {
let sonny = "\"/opt/homebrew/opt/sonny/bin/sonny\" agent";
let text = naming(PREAMBLE, sonny);
assert!(!text.contains("AGENT_BIN"), "{text}");
assert!(text.contains(&format!(
"{sonny} run --detach --new --agents --bot NAME -- TASK"
)));
assert!(text.contains(&format!("{sonny} run --detach --new --bot NAME -- TASK")));
assert!(text.contains(&format!("{sonny} run --detach --bot \"$AGENT_PARENT\"")));
// Without one, the binary the daemon gives every shell.
assert_eq!(naming(PREAMBLE, "\"$AGENT_BIN\""), PREAMBLE);
Expand Down
10 changes: 9 additions & 1 deletion docs/CLI.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,16 @@ PATTERN`, repeatable, leaves out more: every variable whose name matches, with
starts a daemon passes it Codex's `[shell_environment_policy]` from
`~/.agent/config.toml`: each name in `exclude`, and `*KEY*`, `*SECRET*` and
`*TOKEN*` too with `ignore_default_excludes = false`. As in Codex, those three
are kept by default. A running daemon keeps the excludes it started with, and
are kept by default. It also leaves out the variables that name the
terminal it was started in (`TMUX`, `TMUX_PANE`, Rex's, Herdr's and cmux's
places, `agent_client::TERMINAL_PLACES`), so a bot's command never lands in
that terminal. A running daemon keeps the excludes it started with, and
a variable a bot or caller sets with `--env` is still set.
`serve --redact-field NAME` has the value printed after a field named like
NAME (its end, any case, `_` and `-` ignored) redacted from tool output, and
`serve --redact-block LABEL` the body of each PEM block whose header names
LABEL. A client that starts a daemon passes `agent_client::SECRET_FIELDS`
(`password`, `api_key`, `secret_access_key` and the like) and `PRIVATE KEY`.
`run` without `--bot` creates a fresh identity. `run --bot NAME` continues an
existing bot; add `--new` to create that name. A prompt of `-` reads stdin.

Expand Down
28 changes: 28 additions & 0 deletions docs/DAEMON_MEASUREMENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -5975,3 +5975,31 @@ probe p50 / max in ms):
The probe used to wait behind the whole 64-request queue, mostly the
flood's. Now it waits behind one of the flood's requests, and the flood's
own rate holds.

## Opinions move out to clients (#447)

The daemon no longer strips terminal variables by name, requires `delivery`
on submit, reads a turn's round bound from the bot's `max_rounds`, and runs
its field-name and PEM redaction on the names a client passes at start
(`--redact-field`, `--redact-block`) instead of a constant list. The Perf
workflow's matched screen, base main 96228b5 against the PR head eb96cf9,
on the GitHub runner (2026-10-30):

| Bench | Measure | Base | Head | Change |
| --- | --- | ---: | ---: | ---: |
| admission | lone submit CPU, ms | 40.231 | 40.158 | -0.2% |
| admission | burst submit p99, ms | 6.308 | 6.049 | -4.1% |
| completion | release to terminal p99, ms | 13.545 | 13.622 | +0.6% |
| footprint | idle RSS, MiB | 19.293 | 19.355 | +0.3% |
| footprint | per idle bot, KiB | 1.402 | 1.452 | +3.6% |
| footprint | RSS after a turn each, MiB | 27.102 | 27.365 | +1.0% |
| footprint | CPU per turn, ms | 1.252 | 1.274 | +1.8% |
| lifecycle | CPU, s | 1.15 | 1.15 | +0.0% |
| lifecycle | p95 turn, ms | 579.367 | 583.056 | +0.6% |
| lifecycle | peak RSS, MiB | 25.621 | 25.662 | +0.2% |

Store jobs per turn did not change (about 7.5 on both), and nothing passed
the gate's 15%. The benches start the daemon with `serve` and no redact
flags, so the head runs no field scan where the base does. With the flags
the CLI passes, the scan is the base's code over the same twelve names, so
a CLI-started daemon does the base's work per tool result.
31 changes: 19 additions & 12 deletions docs/RUST_PROTOTYPE.md
Original file line number Diff line number Diff line change
Expand Up @@ -406,6 +406,7 @@ keep-warm when it sets any of them.
| `compact_at` (`--compact-at`) | Percent of either context envelope that triggers compaction, and of the byte envelope that triggers tool-result elision first. Estimated completion headroom can advance the byte trigger without reducing the input allowance. | 75 |
| `compact_keep` (`--compact-keep`) | Target percent of either context envelope kept verbatim: as newest whole turns by compaction, as newest items by elision. Reduced when pinned context leaves less room. Must be below `compact_at`. | 25 |
| `compact_tokens` (`--compact-tokens`) | Input tokens per request that start elision and compaction too, whichever of this and `compact_at` comes first (see [compaction](#compaction)). 0 leaves only `compact_at`; otherwise at least 1,024. | from the model's context window |
| `max_rounds` (`--max-rounds`) | Model calls each turn may make; the last goes with no tool call allowed, and the turn fails `tool_round_limit` past it. | none |
| `keep_turns` (`--keep-turns`) | After each of its turns finishes, prune the bot to this many turns' records (see [Retention](#retention)). | none |
| `approval_hold_ms` (`--approval-hold`) | Milliseconds a gated call waits live for its verdict before its turn parks (see [tool approval](#tool-approval)). 0 parks at once; at most 3,600,000. | 2,000 |
| `max_output_tokens` (`--max-output-tokens`) | Generated tokens per model call, including reasoning, summaries included. Anthropic calls use the model's full output limit (read inside Bedrock ids) unless this is set; set, it is sent as `max_tokens`, at least 2,048 so a legacy thinking budget of 1,024 or more fits beside the answer. Bedrock deducts input plus this bound from quota when a call starts, so a bound near real output buys throughput there. | none |
Expand Down Expand Up @@ -1489,11 +1490,13 @@ Protocol 8 adds the turn view.
### Delivery modes

`delivery` on `submit` says what happens when the bot is busy or the daemon
is at `--max-active`. It is one field with four values; every mode returns
is at `--max-active`. It is one required field with four values, since
what a busy bot does with a message is the client's choice
(`invalid_delivery` without it); every mode returns
the turn's id and handle at once, and `wait`, `turns`, and
`interrupt` work on the turn unchanged.

- `reject` (default): `bot_busy` while a turn runs or is parked,
- `reject`: `bot_busy` while a turn runs or is parked,
`active_agent_limit` when no slot is free. Nothing is written. The
`bot_busy` detail says what is in the way (`turn N is running`, or
`earlier work is waiting`) and which requests get past it, in this
Expand Down Expand Up @@ -1623,7 +1626,7 @@ queued work as `bot_busy`. `stats` reports queued and ready turns together
as `queued_turns`, a count the storage worker keeps at each transition
and recounts at open, so admission and `stats` cost the store nothing.
`agent run --delivery MODE` sets the field, defaulting
to `AGENT_DELIVERY` in the client's environment, never in the daemon; with
to `AGENT_DELIVERY` in the client's environment, then `reject`; with
`--detach` a peer can hand a busy bot work or a mid-turn message without
racing on `bot_busy`.

Expand Down Expand Up @@ -1864,8 +1867,10 @@ completion, failure, explicit interruption, or parking, and accumulate across
resumption. A hard process kill can lose the current segment's unflushed
counters. Retries are on by default because they cannot repeat an effect; what they can repeat is
billing for a failed attempt, which is recorded as failed usage. Successful
model responses and failures with reported usage share the durable limit of
200 model rounds per turn, including across parking and restart. The last
model responses and failures with reported usage share the bot's durable
limit of model rounds per turn, its `max_rounds` setting (unset, none: the
turn's token budget bounds a runaway instead), including across parking and
restart. The last
of them, a retry included when a billed failure made it the last, goes with the bot's tools shown but `tool_choice` `none` (both
families), so the model must answer in words, and the turn ends on that reply
instead of failing with its work done and unreported; a live
Expand Down Expand Up @@ -2189,12 +2194,11 @@ omission stated and the full stream is stored as an artifact retrievable through
the `artifact` operation. Output is redacted before it is stored or sent,
artifacts included, as every tool's result is: each credential the daemon
holds, wherever it appears; the value printed after a field name ending in
`secret`, `secret_key`, `secret_access_key`, `session_token`, `access_token`,
`refresh_token`, `id_token`, `auth_token`, `api_key`, `password`, `passwd` or
`private_key` (any case, with or without `_` or `-`, as `NAME=`, `NAME:` or
`"NAME":`), when it is at least 8 bytes of letters and digits both, so a
credential tool's JSON or a credentials file never reaches the model or the
store; and the body of a PEM private key block. Results include separate output, exit code, and
one the daemon was started with (`--redact-field`, any case, with or without
`_` or `-`, as `NAME=`, `NAME:` or `"NAME":`), when it is at least 8 bytes and
mixes letters and digits; and the body of a PEM block whose header names a
`--redact-block` label. Which names count as secrets is the client's
judgement: the CLI passes `agent_client::SECRET_FIELDS` and `PRIVATE KEY`. Results include separate output, exit code, and
success status. Nonzero exit is a recorded tool result. A command's return,
timeout, or overflow kills its owned process group, so a process it started
with `&` does not outlive it; the tool description tells the model so. A
Expand Down Expand Up @@ -2437,7 +2441,10 @@ Either way the call's `arguments` are `{"input": TEXT}` and its answer is
the provider's custom tool output where it has one. The grammar counts
toward the 64 KiB. A client's code-running tool is one.

Protocol version 24 adds `item` to `text_delta` and `thinking_delta`, so a
Protocol version 24 makes `delivery` a required `submit` field
(`invalid_delivery` names the four), since the daemon has no opinion of
its own on what a busy bot does with a message.
It adds `item` to `text_delta` and `thinking_delta`, so a
client draws each message a call streams apart, as it is kept (see
`follow` above). It also reports an unset `cache_ttl` in `inspect`'s
`settings` as `null` rather than `"5m"`, so a client tells a lifetime
Expand Down
2 changes: 1 addition & 1 deletion sonny/src/acp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -911,7 +911,7 @@ impl Session {
};
let steer = state.running.is_some();
let mut asked = json!({"bot": bound.bot, "request_id": crate::unique("acp"), "prompt": text,
"delivery": if steer { json!("steer") } else { Value::Null },
"delivery": if steer { "steer" } else { "reject" },
"images": if images.is_empty() { Value::Null } else { json!(images) }});
if !steer && let Some(dir) = &bound.moving {
asked["workspace"] = json!(dir);
Expand Down
Loading
Loading