Acp
acp
¶
Per-task host-side ACP (Agent Client Protocol) aggregator.
Bridges a single ACP client (Zed, Toad, …) to one of several
in-container agents (claude, codex, copilot, …) by namespacing models
as agent:model (e.g. claude:opus-4.6) under ACP's standard
category: "model" configOption.
Module map:
daemon— Unix-socket server, container lifecycle supervision, and the standaloneterok-executor acpentry point. Ownsserve_acpand theacp_socket_is_liveprobe used to distinguish live daemons from stale socket files.roster— per-task aggregation: walks the image'sai.terok.agentslabel, probes each agent, and answers "what models does this container offer?" OwnsACPRosterand the vault-sidelist_authenticated_agents.proxy— the typed bidirectional ACP mediator: implements bothacp.Agent(toward the connected client) andacp.Client(toward the bound backend wrapper) on one object. Drives the bind handshake on first model pick.probe— the minimalinitialize + session/newhandshake that extracts an agent's model roster.cache— thread-safe per-agent model cache; survives reconnects, invalidated on credential rotation.endpoint— theACPEndpointStatusenum the host CLI uses to classify endpoints interok acp list.model_options— theagent:modelnamespace vocabulary and the typed builders + rewriter that keep the proxy's frames schema-valid.
Bind-trigger surfaces: explicit session/set_model /
session/set_config_option(configId="model"), or — for clients
that trust the advertised currentModelId — lazily on the first
backend-needing method (e.g. session/prompt). Cross-agent
switching mid-session is out of scope for v1; subsequent picks against
a different agent are rejected at the protocol level.
The exports below are re-exported from terok_executor so the
host-side caller (terok) doesn't have to reach into the submodules.
__all__ = ['ACPEndpointStatus', 'ACPRoster', 'AgentBindError', 'AgentRosterCache', 'CacheKey', 'ProbeError', 'acp_socket_is_live', 'list_authenticated_agents', 'probe_agent_models', 'serve_acp']
module-attribute
¶
AgentRosterCache()
¶
Thread-safe map from CacheKey to a tuple of model ids.
Models are stored as a tuple so cache entries are immutable once inserted — callers can return them directly without defensive copying. Empty tuples are valid and signal "probe ran but yielded nothing" (saved to avoid hammering a misconfigured agent on every session).
Source code in src/terok_executor/acp/cache.py
get(key)
¶
put(key, models)
¶
invalidate_auth(auth_identity)
¶
Drop every entry tied to auth_identity.
Used when credentials for an identity rotate — the next
session/new re-probes affected agents.
Source code in src/terok_executor/acp/cache.py
CacheKey(image_id, auth_identity, agent_id)
dataclass
¶
Composite key for one agent's roster within one auth scope.
auth_identity is the constant "global" today (terok auth is
process-wide); the field exists from day one so per-project auth can
slot in without a key-schema migration.
ACPEndpointStatus
¶
Bases: StrEnum
Live state of a per-task ACP endpoint.
The host classifier (Project.acp_endpoints) attaches one of
these to every running task; the value drives both the rendered
row in acp list and the decision acp connect makes about
whether to spawn a daemon.
ACTIVE = 'active'
class-attribute
instance-attribute
¶
Daemon up, socket bound, ready for client connections.
READY = 'ready'
class-attribute
instance-attribute
¶
Task running with at least one authenticated agent — a daemon
will spawn on first terok acp connect.
UNSUPPORTED = 'unsupported'
class-attribute
instance-attribute
¶
Task running but no in-image agents are authenticated. Connect would fail; surface honestly so the user knows to authenticate.
ProbeError
¶
Bases: RuntimeError
Raised when an agent fails to respond to the probe handshake.
The cache stores empty rosters for failed probes (so we don't hammer
a misconfigured agent on every session) — callers should treat
ProbeError as "this agent is currently unusable" rather than
bubble it to the user.
AgentBindError
¶
Bases: RuntimeError
Surface error raised when the proxy fails to bind a backend agent.
Always converted to a JSON-RPC error response on the wire — never
bubbles to the caller of run.
ACPRoster(*, container_name, image_id, sandbox, auth_identity=DEFAULT_AUTH_IDENTITY, cache=None)
¶
Per-task ACP aggregator.
Construct one per running task — the roster owns the per-agent
probe cache lookups and the attach loop that brokers a connected
ACP client. It probes every agent declared in the image's
ai.terok.agents label; failed probes (missing wrapper, no
credentials, agent crashed) cache empty so a misbehaving agent
doesn't get re-probed every session/new. The roster
deliberately does not consult the credential vault: that view
is incomplete (file-mounted creds aren't there) and the proxy
has nothing useful to do with the answer anyway — a probe that
succeeds is, by definition, an authed agent.
Source code in src/terok_executor/acp/roster.py
configured_agents
cached
property
¶
Agents declared in the image's ai.terok.agents label.
Parsed once per roster instance — the image label is stable for
the lifetime of the running task. The label is a comma-
separated list (see AGENTS_LABEL).
acp_capable_agents
cached
property
¶
Subset of configured_agents that ship a terok-{agent}-acp wrapper.
The image label lists every agent in the runtime — claude,
opencode, gh, sonar, blablador, etc. Of those, only the
ones that actually install an ACP wrapper script (currently
claude, codex, copilot, opencode, vibe) can be probed by the
proxy; the rest are tools or LLM gateways that don't speak
the protocol at all. Probing them anyway costs a full
probe_timeout per agent for nothing — and worse, leaves
their wrappers as zombie subprocess threads in the executor
pool until exec_stdio's own timeout kills them.
Resolved by a single in-container shell call at first use
(command -v is built-in to bash, near-zero cost). The
property is cached for the roster's lifetime; new wrappers
installed mid-task aren't picked up without a daemon restart.
list_available_agents()
async
¶
Return agent:model ids ready to surface to a client.
Probes every agent in the image's ai.terok.agents label
(filtered through the cache) and concatenates the namespaced
model ids of those that responded. Cold-cache agents are
probed in parallel via gather, so first-call
latency is max(probe_time) rather than sum(probe_time).
Successful probes cache the model tuple for the daemon's
lifetime; failed probes are not cached so a transient cold
start (Node wrapper warming up, OAuth refresh in flight) can
recover on the next session/new instead of wedging the
roster empty until the daemon restarts.
Source code in src/terok_executor/acp/roster.py
warm(agent_id)
async
¶
Probe agent_id and cache the result on success only.
Returns the probed model tuple (possibly empty on failure).
Failures are deliberately not cached: a transient cold-
start failure (slow Node start, OAuth refresh racing the
probe timeout) would otherwise pin the agent at empty for
the daemon's lifetime. The trade-off is paid in cold-start
latency: an agent that's genuinely unavailable gets re-
probed every session/new and adds its full timeout to
the response. Successful probes are cached per-daemon and
reused across reconnects.
Source code in src/terok_executor/acp/roster.py
attach(reader, writer)
async
¶
Run the proxy loop for one connected client until disconnect.
Delegates the JSON-RPC state machine to ACPProxy. The
roster owns the data (cache + live walk); the proxy owns the
protocol.
Source code in src/terok_executor/acp/roster.py
wrapper_argv(agent_id)
¶
Return the argv that runs terok-{agent_id}-acp in this container.
Hands back something a caller can pass directly to
create_subprocess_exec — both
the bind path and the probe path use this so they can attach
asyncio's pipe transports to the wrapper subprocess. Currently
podman-specific; a krun runtime would need a different shape
(which is why this method lives on the roster, not on the proxy
or probe).
Source code in src/terok_executor/acp/roster.py
acp_socket_is_live(path)
¶
Return True when a peer is currently accepting on path.
Distinguishes a live ACP daemon from a stale socket file left
behind by a crash: a successful connect means a peer is
listening, while ECONNREFUSED (and any other OSError)
means the file is safe to unlink.
Source code in src/terok_executor/acp/daemon.py
serve_acp(container_name, socket_path, *, sandbox=None, poll_interval_sec=CONTAINER_POLL_INTERVAL_SEC)
¶
Bind socket_path and run the ACP host-proxy until container_name stops.
Returns the process exit code. When sandbox is None, builds
one from the layered config.yml + env (the same
SandboxConfig() defaults executor uses everywhere).
Source code in src/terok_executor/acp/daemon.py
probe_agent_models(*, agent_id, wrapper_argv, timeout=DEFAULT_PROBE_TIMEOUT_SEC, cwd='/workspace')
async
¶
Drive the minimal ACP handshake against terok-{agent_id}-acp.
Spawns the wrapper via acp.spawn_agent_process
(which owns the asyncio stdio bridging and the graceful subprocess
shutdown dance), sends initialize and session/new, reads
the models block, and returns the bare model ids.
Raises ProbeError on
timeout, transport failure, or any handshake error. Callers (the
roster cache) typically catch it, cache an empty roster, and skip
the agent until the container is restarted.
Source code in src/terok_executor/acp/probe.py
list_authenticated_agents(*, db_path=None, scope=DEFAULT_CREDENTIAL_SCOPE)
¶
Return provider names that have stored credentials in scope.
Pure query against CredentialDB — no probing,
no container exec. Used by the host-side acp list to classify
endpoints in its status display; the roster itself doesn't gate
probing on this anymore (file-based auth like Claude's OAuth lives
outside the vault, so a vault-only filter would silently hide
working agents).