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
42 changes: 42 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,40 @@ Versioning and Keep a Changelog conventions.
cleanup of its hidden Codex thread can neither delay a turn nor replace a
completed turn's success with a write error. Code that constructs
`AdapterOutput` with struct syntax must now set `optional_writes`.
- `Provider::Acp` and `providers::acp::Acp` (default feature `acp`): one
adapter for any agent that speaks the Agent Client Protocol over stdio —
Gemini CLI (`gemini --acp`), GitHub Copilot (`copilot --acp`), Cursor
(`cursor-agent acp`) and the rest of the ACP registry. Agents live in a
shared, updatable `AcpAgents` registry and a turn selects one with the
`agent` harness option, advertised as a `HarnessControlKind::Agent`
control. Turns run `initialize`, `session/new` (or `session/resume`, else
`session/load` without its replay), optional mode and model selection, then
`session/prompt`; `session/request_permission` reaches the interaction
handler unless `AcceptEdits`/`FullAccess` decide it, cancellation sends
`session/cancel`, and `-32000` fails as an authentication error before the
prompt. ACP requires an absolute working directory; a relative one is
refused before the agent starts. `Acp::probe_agent` runs the handshake (and
optionally opens a session) to report capabilities and sign-in state. See
`docs/how-to/run-acp-agents.md`.
- ACP agents appear in harness discovery and readiness. `discover_harnesses`
now includes `Provider::Acp` when the adapter is registered and
`AgentRuntime::readiness(Provider::Acp)` resolves each registered agent's
own command inside the configured transport instead of a placeholder `acp`
executable: the harness is `Ready` when at least one agent is installed,
`readiness.detail` names every agent, and the `Agent` control group lists
only installed agents. New `AgentAdapter::selectable_executables` (default
`None`, so existing adapters are unchanged) and `SelectableExecutable`
expose that per-turn choice to the runtime.
- `TurnEvent::PlanUpdated` with `PlanEntry`/`PlanEntryStatus`: a complete
replacement of an agent's execution plan, emitted for ACP `plan` updates.
- `AgentAdapter::interaction_answered`: called by the turn loop right after
an approval or question reply is encoded, so an adapter that tracks
unanswered requests stops refusing one the application already answered
when the turn is cancelled. The default does nothing.
- `AgentAdapter::terminal_exit_grace`: a stdio provider that keeps running
after its terminal frame and end-of-input is stopped after this grace
period instead of holding the finished turn until its deadline. The default
(`None`) keeps reading to end-of-output as before.
- `AgentTask::model`: the model a Claude Code subagent runs on, as its own
messages report it (for example `claude-haiku-5-5`), including a subagent
that inherits the parent's model. It is `None` until the subagent's first
Expand Down Expand Up @@ -146,6 +180,14 @@ Versioning and Keep a Changelog conventions.

### Fixed

- A finished turn, probe or metadata query no longer waits for a descendant
of the provider that kept its stderr open after the process ended, which
turned a completed ACP turn into a timeout when the agent's terminal grace
stopped it. The runtime waits a bounded 2 seconds for stderr to close and
keeps the tail that had arrived for diagnostics. A turn keeps draining the
pipe in the background past that point, so a tool left running by
`ToolProcessPolicy::PreserveOnCompletion` never gets a broken pipe; probes,
which have already stopped the process, stop reading.
- Changing a Claude turn's model, effort or permission mode no longer
replaces the retained process and stops its background subagents and
shells. The process switches them in place with `set_model`,
Expand Down
6 changes: 4 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ repository = "https://github.com/gotempsh/agent-runtime-sdk"
homepage = "https://github.com/gotempsh/agent-runtime-sdk"
documentation = "https://docs.rs/temps-agent-runtime"
readme = "README.md"
keywords = ["agents", "claude", "codex", "opencode", "sandbox"]
keywords = ["agents", "claude", "codex", "acp", "sandbox"]
categories = ["api-bindings", "asynchronous", "development-tools"]
include = [
"/src/**",
Expand All @@ -29,11 +29,13 @@ include = [
]

[features]
default = ["claude", "codex", "opencode", "pi", "nono", "ssh", "docker", "tailnet"]
default = ["claude", "codex", "opencode", "pi", "acp", "nono", "ssh", "docker", "tailnet"]
claude = []
codex = []
opencode = []
pi = []
# Any agent that speaks the Agent Client Protocol over stdio.
acp = []
nono = []
ssh = []
# Run provider CLIs inside an existing container through `docker exec`.
Expand Down
123 changes: 123 additions & 0 deletions docs/how-to/run-acp-agents.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
# Run Agent Client Protocol agents

`providers::acp::Acp` runs any agent that speaks the
[Agent Client Protocol](https://agentclientprotocol.com) (ACP) over stdio:
Gemini CLI, GitHub Copilot, Cursor, Kimi, goose, Qwen Code and the rest of the
[ACP registry](https://agentclientprotocol.com/registry). One adapter,
`Provider::Acp`, serves them all; the application registers each agent's
command and picks one per turn.

```rust
use temps_agent_runtime::providers::acp::AGENT_OPTION;
use temps_agent_runtime::providers::{Acp, AcpAgent, AcpAgents};
use temps_agent_runtime::{AgentRuntime, PermissionMode, Provider, TurnRequest};

let agents = AcpAgents::new();
agents.insert(AcpAgent::new("gemini", "Gemini CLI", "gemini", ["--acp"]))?;

let mut builder = AgentRuntime::builder();
builder.register(Acp::new(agents.clone()));
let runtime = builder.build()?;

let mut request = TurnRequest::new(Provider::Acp, "/path/to/project", "Fix the failing test");
request.harness_options.insert(AGENT_OPTION.into(), "gemini".into());
request.permission_mode = PermissionMode::Default;
let result = runtime.run(request, &events, Some(&approvals)).await?;
```

The working directory must be absolute: ACP sends it to the agent as `cwd`,
and the agent starts inside it, so a relative path would resolve a second time.
A relative one fails the turn before the agent starts.

`AcpAgents` is shared: inserting, replacing or removing an agent on any clone
takes effect for the next turn of every runtime holding it.

## What a turn does

1. `initialize` with protocol version 1. The client offers no `fs` or
`terminal` capability, so the agent uses its own tools in the working
directory, inside whatever sandbox wraps the process.
2. `session/new`, or for `TurnRequest::session_id`: `session/resume` when the
agent advertises it, else `session/load` (its history replay is dropped).
An agent with neither starts a new session and emits a warning. A resumed
session the agent no longer knows fails with `acp::session_not_found`.
3. `PermissionMode::Custom(mode)` selects that session mode
(`session/set_mode`); `TurnRequest::model` selects a model through the
session's `model` config option (or `session/set_model`). An unknown mode
or model fails before the prompt, naming the choices.
4. `session/prompt`. `session/update` notifications become `TextDelta`,
`ReasoningDelta`, `ToolCall`, `PlanUpdated`, `Usage` and `Warning` events;
the prompt's `stopReason` ends the turn.

`launch_context.system_prompt_append` is sent as a leading text block of a new
session's first prompt (ACP has no system prompt). Stdio and HTTP MCP servers
are passed in the session request with their referenced variables resolved
from `TurnRequest::environment`; HTTP servers are dropped with a warning when
the agent does not support them.

## Permissions

| `PermissionMode` | `session/request_permission` |
| --- | --- |
| `Default`, `Custom(_)` | Every request reaches the `InteractionHandler` |
| `AcceptEdits` | `read`, `edit`, `delete`, `move` and `search` kinds are allowed; others reach the handler |
| `FullAccess` | Every request is allowed |
| `Plan` | Rejected: ACP has no client-enforceable read-only mode |

An agent asks only when it decides to; an agent configured to run tools
without asking never sends a request.

## Offer the installed agents

Register the adapter and `discover_harnesses` lists `Provider::Acp` like any
other harness. The runtime resolves each registered agent's own command inside
the configured transport (local, SSH, container or sandbox), concurrently:

- `HarnessReadiness::status` is `Ready` when at least one agent is installed
and `NotInstalled` when none is (or none is registered).
- `readiness.detail` names every agent, installed or not found, with its
version when it reports one.
- The `agent` control group (`HarnessControlKind::Agent`) lists only the
installed agents, so its options are exactly what a turn can run. A group
with no installed agent is omitted.
- `runtime.readiness(Provider::Acp)` reports the same aggregate instead of
looking for a program named `acp`.

Authentication stays `Unknown` here: opening a session can leave an empty
session in an agent's history, so the sign-in check is the explicit
`Acp::probe_agent` below. Registry changes apply to the next discovery without
rebuilding the runtime.

Custom adapters that choose among several executables per turn opt in through
`AgentAdapter::selectable_executables`.

## Check an agent

```rust
let probe = Acp::probe_agent(&agent, &scratch_dir, true, Duration::from_secs(30)).await?;
match probe.authentication {
AcpAuthentication::Authenticated => { /* ready */ }
AcpAuthentication::Required { message } => { /* show the agent's sign-in hint */ }
_ => { /* unknown */ }
}
```

`probe_agent` starts the agent locally, runs `initialize` and, with
`check_session`, `session/new` in the given (absolute) directory, then stops
it. It
reports the protocol version, agent name and version, sign-in methods and
session capabilities. Agents may record that empty session in their history,
so run it on an explicit user action.

## Limits

- One process per turn; the process is not retained across turns.
- An agent that keeps running after the turn ends is stopped after
`terminal_exit_grace` (3 seconds), which also stops tool processes it left
running. A descendant that escaped the agent's process group and still
holds its stderr no longer delays the finished turn: the runtime waits at
most 2 seconds for stderr to close, keeps what had arrived, and goes on
draining the pipe in the background so a preserved tool is never given a
broken pipe.
- Questions (elicitation) and client-side file system and terminal methods
are not offered.
1 change: 1 addition & 0 deletions docs/reference/events.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ status transition.
| Event | Meaning | Durable projection |
| --- | --- | --- |
| `TasksChanged` | Complete bounded task snapshot for the current run | Replace the materialized task collection |
| `PlanUpdated` | Complete execution plan an agent published (ACP `plan`) | Replace the materialized plan |
| `TaskActivity` | One ordered native task transition | Append to the run timeline |

Claude currently emits these events for native Task/Agent work. `AgentTask`
Expand Down
73 changes: 73 additions & 0 deletions src/adapter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,36 @@ pub struct AuthenticationProbeSpec {
pub command: CommandSpec,
}

/// One executable an adapter may run, chosen per turn rather than fixed.
///
/// See [`AgentAdapter::selectable_executables`].
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct SelectableExecutable {
/// Value that selects this executable. It is the id of the matching option
/// in the adapter's [`crate::HarnessControlKind::Agent`] control group.
pub id: String,
/// User-facing name, used in readiness diagnostics.
pub label: String,
/// Executable name or path meaningful inside the selected transport.
pub executable: PathBuf,
}

impl SelectableExecutable {
/// Describe one selectable executable.
pub fn new(
id: impl Into<String>,
label: impl Into<String>,
executable: impl Into<PathBuf>,
) -> Self {
Self {
id: id.into(),
label: label.into(),
executable: executable.into(),
}
}
}

impl CommandSpec {
/// Create a process specification for an executable.
pub fn new(program: impl Into<PathBuf>) -> Self {
Expand Down Expand Up @@ -327,9 +357,24 @@ pub trait AgentAdapter: Send + Sync {
Provider::Codex => "codex",
Provider::OpenCode => "opencode",
Provider::Pi => "pi",
Provider::Acp => "acp",
})
}

/// Executables this adapter can run when a turn chooses among several,
/// such as the agents registered with the ACP adapter.
///
/// `None`, the default, means [`Self::executable`] is the one program the
/// adapter runs. `Some` replaces it for readiness: the runtime resolves
/// every entry inside the configured transport, reports the provider as
/// installed when at least one resolves, names each entry in the
/// readiness detail, and keeps only the installed entries as options of
/// the [`crate::HarnessControlKind::Agent`] control group in discovery.
/// An empty `Some` means nothing is registered yet.
fn selectable_executables(&self) -> Option<Vec<SelectableExecutable>> {
None
}

/// Static and interactive permission behavior implemented by this adapter.
fn permission_support(&self) -> PermissionSupport;

Expand Down Expand Up @@ -657,6 +702,19 @@ pub trait AgentAdapter: Send + Sync {
Ok(AdapterOutput::default())
}

/// How long a stdio provider may take to exit on its own after its
/// terminal frame and the closing of its stdin.
///
/// `None` — the default — reads the provider to end-of-output and waits
/// for it to exit, because closing stdin is what makes it exit. `Some`
/// stops reading at the terminal frame and terminates a provider that is
/// still running when the grace period ends; the protocol already decided
/// the turn, so that stop is not a failure. Protocol servers such as ACP
/// agents need this: nothing obliges them to exit at end-of-input.
fn terminal_exit_grace(&self) -> Option<std::time::Duration> {
None
}

/// Encode a provider-native cooperative interrupt for the running turn.
///
/// Returning `Some` makes the runtime write the frame on cancellation and
Expand Down Expand Up @@ -685,6 +743,21 @@ pub trait AgentAdapter: Send + Sync {
original: &Value,
answer: Option<QuestionAnswer>,
) -> Result<Option<Vec<u8>>>;

/// Record that the reply to the approval or question `original` was
/// encoded and is being written to the provider.
///
/// Called by the turn loop right after
/// [`AgentAdapter::approval_response`] or
/// [`AgentAdapter::question_response`], with the same `original`. An
/// adapter that tracks requests still waiting for an answer — to refuse
/// them when it encodes an [`AgentAdapter::interrupt_request`] — drops
/// this one here, so a cancellation that arrives before the provider
/// reacts to the answer does not answer the same request twice. The
/// default does nothing.
fn interaction_answered(&self, original: &Value, state: &mut AdapterState) {
let _ = (original, state);
}
}

/// Resolve an executable override or search common user-local `PATH` roots.
Expand Down
10 changes: 5 additions & 5 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ pub mod tailnet;
pub use adapter::{
AccountUsageProbeSpec, AdapterOutput, AdapterState, AgentAdapter, AuthenticationProbeSpec,
CatalogProbeSpec, CommandSpec, IdleFrame, InteractionRequest, OversizedFrame, ProtocolStreams,
ProviderTerminalFailure,
ProviderTerminalFailure, SelectableExecutable,
};
pub use chat::{
Chat, ChatApproval, ChatAttachment, ChatCommit, ChatEvent, ChatEventData, ChatMessage,
Expand Down Expand Up @@ -111,10 +111,10 @@ pub use types::{
AgentWorkflowAgentState, AgentWorkflowPhase, ApprovalDecision, ApprovalRequest,
AutoCompactionPolicy, CompactionTrigger, ContextCompaction, ContextWindowUsage, DenyAll,
EventSink, InteractionHandler, LaunchContext, LaunchContextCapabilities, McpServerConfig,
NoopEventSink, PermissionMode, PermissionSupport, Provider, ProviderProcessStatus,
ProviderReadiness, QuestionAnswer, QuestionOption, QuestionPrompt, QuestionRequest, RunStatus,
SecretString, ToolCallStatus, ToolImage, ToolProcessPolicy, TurnCapabilities, TurnEvent,
TurnProvenance, TurnRequest, TurnResult, Usage,
NoopEventSink, PermissionMode, PermissionSupport, PlanEntry, PlanEntryStatus, Provider,
ProviderProcessStatus, ProviderReadiness, QuestionAnswer, QuestionOption, QuestionPrompt,
QuestionRequest, RunStatus, SecretString, ToolCallStatus, ToolImage, ToolProcessPolicy,
TurnCapabilities, TurnEvent, TurnProvenance, TurnRequest, TurnResult, Usage,
};

pub use startup::{StartupObserver, StartupStage, StartupTiming};
Loading
Loading