Skip to content

Release: stable, embedder-neutral extension points (#593) - #737

Merged
lexoliu merged 105 commits into
mainfrom
dev
Nov 11, 2026
Merged

lexoliu merged 105 commits into
mainfrom
dev

Conversation

@lexoliu

@lexoliu lexoliu commented Nov 11, 2026

Copy link
Copy Markdown
Collaborator

Ships the #593 redesign as one breaking release: aither carries the agent loop and the sandbox's general capabilities, and the embedder supplies its policy through typed extension points (shell assembly and policies, IPC router and environment, SSH preparation, output treatment, job events and output appenders, subagent shells, checkpoints). Terminal-first product pieces that lived here (the tool distribution, the IPC command bridge, wording) moved to the embedder. Also: the job registry is keyed by task id, container shims come from heel shims (heel 0.9.5), and the CI drops jobs whose code left the repository.

A tool can now ask whether an action may go ahead and observe its call's
cancellation without depending on an agent loop. `Approver` answers an
`ApprovalRequest` with `Approval::Approved` or `Approval::Denied`, and
`ToolContext::approve` routes the request to the approver its dispatcher
attached with `with_approver`, attributing it to the call. A context with
no approver fails with `ApprovalError::NoApprover` instead of allowing the
action.

`Canceller` and `CancelSignal` carry cancellation: the dispatcher attaches
a signal with `with_cancellation`, a tool watches `cancellation()` for work
that outlives its future, and a pending approval ends with
`ApprovalError::Cancelled` when the call is cancelled.
`Approver::approve` now returns `Result<Approval, ApproverFailure>`. An
approver that cannot decide, for example because the store it records
requests in fails, returns `ApproverFailure` carrying its own error as the
source, and `ToolContext::approve` surfaces it as `ApprovalError::Failed`
instead of letting it pass for a denial.
feat(core): approval and cancellation on ToolContext
`Sandbox` starts a process on a backend (`Host`, `Container`, `Ssh`) under
the sandbox's grants and network policy and hands back a `SandboxedChild`:
byte-chunk streams, a `kill` that ends the whole tree and a repeatable
`wait`. A host spawn runs under heel, a container spawn through the exec
channel with a kill switch, and an SSH spawn records its process group on
the server so `kill` reaches the remote tree and not only the local client.

`Grants` is one live handle (`add_readable`, `add_writable`, `deny_read`,
`revoke`, `derived`). A change applies to the next spawn; a derived view
follows its parent's readable and denied paths but never widens them.

The network lists give a verdict first and `DomainApprover::approve` decides
what they leave open, with host, port and agent. The terminal builds its
sandbox, grants, environment source, network lists, approver and audit log
through these, and its container route is a `HostRoute`.

BREAKING CHANGE: removed `TerminalGrants`, `GrantSet`,
`SessionSandboxPolicy`, `ContainerIpcRoute` and `BridgeAddress`.
`TerminalEnvSource` is now `EnvSource`. `PermissionHandler` loses
`check_domain`, `domain_needs_approval`, `remember_domain`, `review` for
domains and `authorize_domain`; `PermissionRequest::Domain` is gone.
`TerminalTool::with_network_policy` and `with_domain_approver` take a
`CompiledNetworkPolicy` and a `DomainApprover`.
External-hook reviewers move from `ReviewedPermission`'s domain methods to
`ReviewedDomains`, a `DomainApprover` that asks the reviewers first and
passes what they pass to an inner approver. The reviewer input keeps its
`"kind":"domain"` shape. The CLI prompts for open domains through its own
`DomainApprover`.

BREAKING CHANGE: `ReviewedPermission` no longer answers domain checks;
`ReviewRequest::request` is a `ReviewSubject`, and a reviewer's approval of
a domain is no longer remembered by the inner route.
…rom aither-agent

aither-agent is the agent loop and nothing else. The terminal builder, the
`compact` and `subagent` tools with their definitions and router, the
ask_user / workspace_request / tool_request protocols, the language, secret,
runaway, host-load, disk, working-document, external-hook, command-catalog,
output-url, browser-backend and judgment modules, the notices layer and the
default system prompt are gone from aither entirely; the embedder supplies
its own. The model tiers collapse to the one conversation model and the
optional compaction model, `AgentKind` and the built-in instruction blocks
leave the layout (instructions render in the order the embedder adds them),
and the user-message time stamp, the language guard and the sample system
prompt leave with them.

A compaction finds the files tool results name through the embedder's
`FileReferences` instead of scanning terminal output directories.

BREAKING CHANGE: removes `TerminalAgentBuilder`, `Prepared`, `Unprepared`,
`CompactTool`, `SubagentTool`, `SubagentArgs`, the subagent definition files,
`AgentKind`, `BackgroundJobPolicy`, `CacheKeepAlive`, `ModelTier`,
`TieredModels`, `AgentOptions`, `AgentSettings`, `Notices`, `TERMINAL_SYSTEM_PROMPT`,
`wait_background` / `BackgroundWake` and the re-exported aither-sandbox crate.
`AgentBuilder` is generic over `<M, H, C, Schema>`; `balanced_model`,
`fast_model`, `tier`, `system_prompt`, `persona_prompt`, `agent_kind`,
`terminal` and `dyn_terminal` are removed and `instruction`,
`tagged_instruction`, `restore_dir` and `file_references` are added.
Execution of a readable file is not a property grants hold: what a process can read it can copy and run, and Linux runs it in place.
The loop reports through one event set (runs, turns, requests, blocks,
tool calls, pauses, compaction, context injection, checkpoints), every
event stamped with the path of the agent that emitted it. Embedder policy
moves behind a single `Hooks` trait (`before_request`, `before_tool`,
`after_tool`, `on_stop`, `on_turn_boundary`); a tool call's decision is
`Allow`, `Deny`, `Rewrite`, `Ask` or `Abort`, and `Ask` goes to the
agent's `Approver`, with `Paused`/`Resumed` emitted while it is pending.
A run is cancelled through `Run::cancel` or a `CancelHandle` and ends
with `RunEnded { Cancelled }`.

State, event and live-content sources are unified into one
`ContextSource` that returns stable or tail `ContextBlock`s at a
`Moment`, and learns which tail blocks went out through `committed`.

BREAKING CHANGE: `Hook`, `HCons`, `StopHook`, `AnyStopHook`,
`StateSource`, `EventSource`, `StateChange`, `ToolUseContext`,
`ToolResultContext`, `StopContext`, `CheckpointContext` and `StopReason`
are removed; `AgentBuilder::hook`, `stop_hook`, `state_source` and
`event_source` are replaced by `hooks` and `approver`; `AgentEvent` is
replaced by the new event set and `ContextSource` by the block-returning
trait.
A ScriptPolicy now owns the whole decision about a script. It sees where the
script runs (sandbox, location) and the directory it starts in (ScriptDir:
host, container or remote), answers Pass, Allow, Ask or Refuse, and is told
how the script ended, including the directory the shell carries to the next
one. The strictest decision wins, and every agent's terminal binds each
policy to state of its own, fresh for a subagent.

A script that crosses the sandbox boundary, unsandboxed or on a server, runs
only after a policy allows it or an approver approves it. An ask goes to the
approver of the tool call, or to the terminal's own, and a denial reaches the
model with the reason the approver gave. The start directory is fixed once at
admission and the end directory is settled under the carried-directory lock,
so the end of one script is the start of the next under parallel calls.

External hook reviewers become a ScriptPolicy (ReviewedScripts) that reads
the calling agent's role from the call.

BREAKING CHANGE: PermissionHandler, PermissionRequest, Review, CommandLocation,
LocationApproval, PermissionError, NoopPermissionHandler and the permission
event stream are removed; TerminalTool no longer takes a handler type, and
approvals go through TerminalTool::with_approver or the call's ToolContext.
BREAKING CHANGE: CommandPolicy, PolicyDecision and PolicyRefusal are replaced
by ScriptPolicy, ScriptDecision and ScriptRefusal; ScriptCheck loses
working_dir and role and gains sandbox, location, start and call; ScriptEnd
carries the end directory and a Carry. The DirectoryReset job alert is
removed; the reset is reported through ScriptEnd.
BREAKING CHANGE: the SSH server first-use approval and its memory are removed;
a script on a server is asked about like any script outside the sandbox.
BREAKING CHANGE: aither-agent's ReviewedPermission is replaced by
ReviewedScripts, and TerminalAgentBuilder and Agent::terminal lose the handler
type parameter.
…ed events

`SubagentRuns` records the children a tool starts, lists them and cancels
one or all. `SubagentRun` hands the child its identity and cancel signal
and brackets it on the parent's stream with `SubagentStarted` and
`SubagentEnded`; the child's own events follow, tagged with its
`AgentPath`. `ChildAgent` covers an in-process `Agent` and any external
agent an embedder wraps. `AgentBuilder::subagents` joins the registry's
events to the agent's stream while its tools run.
… assembly

Tests for the terminal assembly, the subagent tool, the sandbox helpers and
the old event set go with the code they exercised. New tests cover a run's
events, denied and asked tool calls (Paused and Resumed while an approval
is pending), cancellation, the stop hook, context sources, instruction
order, and a child's events reaching its parent.
The CLI builds its agent with `Agent::builder`, asks for approval through a
terminal `Approver` before every `command` call, and renders a run from its
events. The ACP adapter and session map the new event set; ACP sessions
read files and the web only, since their agent has no approval hooks. The
browser tool keeps its host request channel in its own crate instead of
borrowing it from aither-agent.

BREAKING CHANGE: aither-browser no longer depends on aither-agent;
`aither_agent::tool_request` is gone, and the channel types live in
aither-browser. The CLI drops `--skills` and the `subagent` command.
…icy and ShellLimits

The shell no longer ships a budget. `OutputPolicy` is a live handle holding the
inline and preview bytes, the default and ceiling line counts, the read size,
the media bounds and the job-list preview length; it has no `Default`, and the
`OutputStore` the shell writes through carries it. `ShellLimits` holds the
foreground wait, the timeout cap, the long-job threshold, the subagent import
budget and the response window as live handles, also with no `Default`.
`MonitorSettings` gives the monitor tool its name, description, cap and default
interval.

`max_lines` and the read tool's `max_bytes` are optional in their arguments and
fall back to the policy. Every `DEFAULT_*` limit constant and
`INLINE_OUTPUT_LIMIT` is removed.

BREAKING CHANGE: `TerminalTool::new_in` and `new_exact`, `OutputStore::new`,
`job_registry_channel`, `ReadTerminalDeltaTool::new`, `MonitorTool::new` and
`output_saved_text` take the policy or limits they now read; the `DEFAULT_*`
constants and `INLINE_OUTPUT_LIMIT` are gone; `TerminalArgs::max_lines` and
`MonitorArgs::interval_secs` are `Option`; `MonitorRefusal::TimeoutOutOfRange`
carries the cap.
AgentEvent, TimedEvent, AgentPath and every payload and id type derive
Serialize and Deserialize under the new `serde` feature, so an embedder
can send them across a process boundary without a mirror type. An approval
error travels as its message.

BREAKING CHANGE: AgentError::HookRejected::hook is a Cow<'static, str>.
preview_request_messages_with showed the previous request's tail. It now
asks each context source through the new ContextSource::peek and the
before_request hook for the blocks the next request carries, appends the
stable ones and renders the tail fresh, without consuming a one-shot block.

BREAKING CHANGE: ContextSource gains a required `peek` method.
The terminal arguments are renamed ShellArgs. `mode` becomes `sandbox`
(`sandboxed` | `unsandboxed`, SandboxMode), `language` becomes `interpreter`
(Interpreter), and `expect` is removed from the model-facing schema.
TerminalMode, TerminalExecutionMode and ScriptLanguage leave the crate; the
external reviewer's request carries `sandbox` and `interpreter`.

BREAKING CHANGE: TerminalArgs, TerminalMode, TerminalExecutionMode and
ScriptLanguage are replaced by ShellArgs, SandboxMode and Interpreter; the
`mode`, `language` and `expect` argument keys are gone, and
MonitorSource::Command takes `interpreter`.
…e loop

Rebuilds the three suites against Agent, Hooks and ContextSource on a
scripted model and an injected clock, without the terminal assembly: the
idle refresh and its refusals, one idle compaction per gap and its events,
the warmth handle, and the identity every call carries for the main agent
and for a subagent started through SubagentRuns.
A job's ToolRegistry carries its JobOutput, and the registry lives as long
as the IPC channels serving it: heel's router stays in a detached dispatch
task until its accept thread winds down after the sandbox drops, and the
container bridge's teardown is detached the same way. JobOutput held a
strong lease, so a foreground command's task id could still be leased when
its call returned, depending on how quickly that teardown was scheduled.

JobOutput now refers to the lease weakly. The job's flow and its registry
entry hold the word for as long as the job can be read; a report made after
both let go is dropped, and can no longer reach a later job leasing the
same word.
Tool::arguments_schema defaults to the schema derived from Arguments.
ToolDefinition::new and the sandbox command wrappers read it through the
tool, so the provider request, Tools::definitions and the command line
see the override.
The container and SSH backends run what the embedder prepared as plain
data: ContainerLaunch (tools directory and shell inside the container) and
RemoteLaunch (tools directory, shell, exports and a writable directory on
the server). Backend::Ssh carries the server's heel path and its launch
instead of PreparedSsh. No backend type names PreparedTools or the tool
distribution; PreparedTools converts to the launches from the outside.

Refs #716

BREAKING CHANGE: Backend::Container { runtime, tools } is now
{ runtime, launch: ContainerLaunch }; Backend::Ssh { server, prepared } is
now { server, heel, launch: RemoteLaunch }; remote_command takes a
RemoteLaunch and returns askama::Error; PreparedSsh is no longer exported
from spawn.
feat(agent)!: a general agent loop with events, hooks, approval and subagents
feat(core): a tool can supply its argument schema at runtime
…r connection

The client owns a ControlMaster per server with its control socket in a
private per-client directory, opened on first use under a lock so
concurrent first commands start one master, and ended when the last clone
of the client drops. An upload and its run no longer pay two handshakes.

Closes #729
One lock across every server made all commands wait behind a slow or
unreachable server's opening and spent an `ssh -O check` on each command.
A server's master now opens once under its own once-only cell, a failed
opening is retried by the next command, and an opened master goes straight
to ControlMaster=auto.
…, and an SSH server is prepared on its first command

`ShellBuilder::ipc(impl IpcProvider)` gives every script the shell runs
on the host or in a container the router the provider returns for that
launch's `LaunchInfo`; subagent shells share the provider. A server has no
way back to this machine, so the shell asks for no router there and the SSH
backend still refuses one with `SpawnError::Unsupported`.

`add_ssh_server` takes an `impl PrepareSsh` instead of a `PreparedSsh`, so
adding a server no longer waits on the remote. The preparation runs on the
server's first command and is cached; commands that name the server
together share it, and a failure fails the command with
`TerminalError::SshPrepare` and is retried by the next one. `PreparedSsh`
is itself a `PrepareSsh`.

BREAKING CHANGE: `ShellParts` gains an input and `TerminalError` gains
the `SshPrepare` variant.

BREAKING CHANGE: `ShellSessionRegistry::add_ssh_server` takes an
`impl PrepareSsh`; implement it where a server was prepared before adding.
perf(sandbox): commands to a server share one ssh connection
Jobs::output_appender hands out a JobOutputAppender that refers to the job
weakly and appends into the same stream the process writes to, so a command
served over IPC can show progress in the job that ran it. Appending to an
ended job, or to a later job that reused the id, reports JobEnded.
A job registers under its pid, so it could not exist before its process did,
and an IPC command called at the script's first instruction found no job to
append to. The shell now records the launch before spawning: the job's
lease is held while it starts, appends park until it registers and land
ahead of the process's output, and a launch that never registers releases
them as ended. Jobs::output_appender finds a launching job like a
registered one.
The job registry keyed jobs by process id, so a new job whose pid the
operating system (or a container backend) reused overwrote a finished job
still held for its completion or a watcher, losing its output, completion
and kill handle without an error. The registry now keys every job by the
task id it leases, which is unique among live jobs, and keeps the pid as
data on the job record.

BREAKING CHANGE: the JobRegistry methods that took a pid to find a job
(attach_terminal_input, attach_kill_switch, append_stdout, append_stderr,
close_stdout, close_stderr, complete, fail, terminal_output,
terminal_streams_closed, kill_timed_out, kill_runaway) take the task id
instead; JobRegistry::get and JobRegistry::kill, which took a pid, are
removed in favour of get_by_task_id and kill_by_task_id.
…bagent shell's jobs

Every ShellEvent now carries the AgentPath of the shell that raised it
(ShellEvent::agent and an accessor on each event struct): the main shell
raises as AgentPath::main, a subagent shell as its parent's path plus the
name given to Shell::subagent. AgentPath moves to aither-core beside
AgentIdentity so aither-sandbox can name it; aither-agent re-exports it.

A shell made by Shell::subagent kills its running jobs when its last
handle is dropped, through a guard only the subagent's authority holds. A
main shell's jobs keep running when it is dropped.

BREAKING CHANGE: Shell::subagent takes the subagent's name as a third
argument.
A run holds the agent, so an embedder could not export a checkpoint while it
was driven, and a crash mid-run lost the turn's progress. The run now records
a checkpoint at every AgentEvent::Checkpoint, before the event is queued, and
Run::checkpoints returns a Checkpoints handle (like cancel_handle) whose
latest() the event handler reads.

BREAKING CHANGE: aither-agent depends on arc-swap, and Run gains a field.
…ell's sandbox

A sandbox that only spawns programs needs no shell, so Backend::Host carries
the heel binary alone. The shell a host Shell runs scripts under is an input
of Shell::builder_on_host; Shell::builder stays for a container sandbox, and
build refuses a constructor that does not fit the sandbox's backend.
SpawnCommand::Script on a host sandbox stays SpawnError::Unsupported.

Shell::sandbox returns the sandbox the shell runs in, so an embedder spawns
its own processes in it without keeping a second copy.

BREAKING CHANGE: Backend::Host loses its launch field, and a shell on a host
sandbox is built with Shell::builder_on_host(sandbox, HostLaunch).
aither-sandbox gates what only the jobserver reads (container launch, the
resolved re-export, heel grant imports); aither-agent and aither-acp declare
the executor-core and tokio features their tests use; aither-webfetch
imports its headless-only test policy under that feature.
…cache nothing reads, and install fish on Linux
The output pipeline collapsed every run of blank lines and turned JSON
record arrays into TSV for every script, so a script that only reads a
file handed the model text the file does not hold. Which scripts print
content rather than command output is the embedder's judgement, so the
shell now asks it.

`ShellBuilder::treat_output` takes a `TreatOutput`, which sees each
script before it runs through the same `ScriptCheck` the script policies
get (the shell parsed once for all of them) and returns an
`OutputTreatment`: `Command` runs today's pipeline, `Verbatim` keeps the
terminal rendering alone (escape sequences and `\r` frames resolved,
nothing else changed). Subagent shells share the embedder's choice.
Without one every output is `Command`, as before. The treatment travels
with the admitted script to `compress_text`; the internal `raw` flag
becomes a `StdoutTreatment` that holds either the call's `raw` request
or the chosen treatment, so the two cannot disagree.

A verbatim output over the inline budget is still cut: the cut path
writes the whole rendered text to disk, names the file, and shows the
model a folded view or a preview. Nothing the model reads claims to be
the file itself and the file on disk is exact, which is the same
guarantee the command path gives for its lossy stages, so the cut stages
stay for both treatments.

`compress_text` takes the treatment as a third argument.

Closes #735
The step's filter matched no test since the tool-distribution tests left, so it passed without checking anything. It now runs one test that downloads the pinned heel, checks its digest and verifies it against the linked library.
refactor(sandbox)!: the IPC bridge and the tool distribution leave aither
…sk-id

# Conflicts:
#	sandbox/src/terminal.rs
fix(sandbox)!: the job registry keys a job by its task id, not its pid
@lexoliu
lexoliu merged commit fdff35b into main Nov 11, 2026
62 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant