feat(agents): let a custom agent speak ACP (#3863)

Co-authored-by: Joao Manso <X363081@bcpcorp.net>
Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
This commit is contained in:
João Manso
2026-09-23 11:01:32 +01:00
committed by GitHub
parent f8708da1e0
commit f2d52f1826
55 changed files with 2303 additions and 274 deletions
+39
View File
@@ -0,0 +1,39 @@
---
created: 2026-09-22
status: in_progress
requirements:
- REQ-AGENTS-CUSTOM-ACP-001
system_design:
- ../../specs/agents/system-design/custom-acp-agents.md
legacy_specs: []
---
# Implementation Plan: Operator-Registered ACP Agents
## Overview
Let an operator register a CLI that speaks ACP and have Kandev drive it as a structured agent, and stop
the discovery sweep from reporting an agent list captured at boot. One work order owns both, because
the protocol change is not observable without the discovery change: a newly created ACP agent would
stay absent from Installed Agents until the backend restarted.
## Work orders
| Wave | Work order | Owns |
| --- | --- | --- |
| 1 | [Task 01: Operator-registered ACP agents](task-01-operator-registered-acp-agents.md) | Live discovery, the protocol field, `CustomACPAgent`, the probe exception, and the dialog |
## Sequencing
A single sequential work order. Splitting the discovery fix into its own wave was considered and
rejected: it would leave the ACP wave unable to verify its own acceptance criteria, since every
create/delete assertion depends on the live sweep.
## Out of scope
- Built-in agent declaration, detection, and launch are untouched.
- Renaming `tui_config` or `/api/v1/agents/tui`. The stored column and the route keep their names; a
migration and an API break to correct a name no operator sees is not a trade this plan makes.
- A login command for an operator-registered agent. A CLI whose ACP mode needs an interactive sign-in
must be signed in from a terminal; built-ins declare `LoginCommand()` and custom definitions have no
field for it. Recorded here because the gap is real and adjacent, not because this plan closes it.
@@ -0,0 +1,72 @@
---
id: "01-operator-registered-acp-agents"
title: "Let an operator-registered agent declare and run ACP"
status: in_progress
wave: 1
depends_on: []
plan: "plan.md"
requirements:
- REQ-AGENTS-CUSTOM-ACP-001
acceptance_criteria:
- AC-AGENTS-CUSTOM-ACP-001.1
- AC-AGENTS-CUSTOM-ACP-001.2
- AC-AGENTS-CUSTOM-ACP-001.3
- AC-AGENTS-CUSTOM-ACP-001.4
- AC-AGENTS-CUSTOM-ACP-001.5
- AC-AGENTS-CUSTOM-ACP-001.6
- AC-AGENTS-CUSTOM-ACP-001.7
system_design:
- ../../specs/agents/system-design/custom-acp-agents.md
---
# Task 01: Operator-registered ACP agents
## Summary
Make the discovery sweep read the live agent registry, add a protocol to the operator-registered
definition, and let the capability probe spawn the command that definition names.
## In scope
- `internal/agent/discovery`: resolve agents per sweep, generation-fence the result cache, drop the
dead `KnownAgent`/`Definitions()` capture.
- `internal/agent/settings/controller`: invalidate after each registry mutation; persist the protocol;
seed an ACP profile as non-passthrough and probe it; share one spec builder.
- `internal/agent/agents`: add `CustomACPAgent`; mark its command operator-defined.
- `internal/agent/registry`: build the agent the protocol names; reject unknown protocols and an
ACP-plus-strategy pair.
- `internal/agentctl/server/utility`: `resolveSpawnCommand` accepts an operator-defined command.
- `apps/web`: protocol selector; hide the model and MCP fields for ACP; stop calling the dialog a TUI
agent.
## Acceptance
- **AC-AGENTS-CUSTOM-ACP-001.1** Creating an agent makes it appear in Installed Agents and deleting it
makes it disappear, with no backend restart and without Rescan re-detecting the deleted binary.
- **AC-AGENTS-CUSTOM-ACP-001.2** A membership change is visible before the cache TTL, and a sweep that
started before the invalidation does not publish its results.
- **AC-AGENTS-CUSTOM-ACP-001.3** A definition with no protocol still registers a `*TUIAgent` and is
still classified passthrough-only.
- **AC-AGENTS-CUSTOM-ACP-001.4** An ACP definition registers an agent with `Runtime().Protocol = ACP`
that implements `InferenceAgent`, is not passthrough-only, and offers no passthrough config.
- **AC-AGENTS-CUSTOM-ACP-001.5** Creating an ACP agent seeds one profile with `CLIPassthrough = false`
and an empty model, and starts a capability probe for that agent only.
- **AC-AGENTS-CUSTOM-ACP-001.6** An unknown protocol and an ACP-plus-MCP-strategy pair are both
refused, at creation and on the MCP-strategy route, with no row written.
- **AC-AGENTS-CUSTOM-ACP-001.7** The probe spawns an operator-defined command and still refuses an
unlisted command that is not operator-defined.
## Verification
- RED/GREEN Go: `internal/agent/discovery`, `internal/agent/registry`, `internal/agent/agents`,
`internal/agent/settings/controller`, `internal/agentctl/server/utility`.
- Web: `add-tui-agent-dialog.test.tsx`, `custom-tui-mcp-mount.test.tsx`.
- E2E: `e2e/tests/settings/custom-acp-agent.spec.ts`.
- `make lint-backend`, web `typecheck`, web `i18n:check`.
## Not verified
No real ACP session has completed end to end. The CLI used during development fails `session/new`
against its own model-catalogue service in every mode, reproduced outside Kandev with `acpdbg` and with
a hand-written JSON-RPC client, so the spawn, `initialize`, `authenticate`, and `session/new` path is
confirmed correct but the last mile waits on that service.
+12 -9
View File
@@ -9,7 +9,7 @@ Choose the smallest integration that matches the CLI:
| Need | Path |
|---|---|
| Use a local interactive CLI without changing Kandev source | Add a custom TUI agent in Settings |
| Use a local CLI without changing Kandev source | Add a custom agent in Settings |
| Ship a passthrough-only CLI as a built-in | Add a declarative `TUIAgent` and registry entry |
| Show structured chat, tool calls, modes, and resume | Implement a built-in ACP agent |
@@ -17,26 +17,29 @@ ACP, REST, and MCP are different boundaries. ACP is the only structured agent pr
## Quick path
1. Use **Add TUI Agent** for a local terminal-only CLI.
1. Use **Add custom agent** for a local CLI, and pick its protocol there.
2. Add a built-in `TUIAgent` only when every Kandev install needs it.
3. Use a full ACP integration for structured chat, tools, models, modes, or resume.
4. Validate the path you chose:
- local TUI: installation discovery and exact command-token construction;
- built-in or ACP: declared permissions, credentials, resume, and MCP delivery.
## Register a local TUI agent
## Register a local agent
Open **Settings → Agents**, choose **Add TUI Agent**, and provide:
Open **Settings → Agents**, choose **Add custom agent**, and provide:
- a display name;
- an optional model/profile label;
- a command available on the executor's `PATH`.
- a protocol: **Terminal** or **ACP**;
- a command available on the executor's `PATH`;
- for Terminal only, an optional model/profile label and an MCP strategy.
Use `{{model}}` in the command to insert the optional model value. Kandev derives a stable slug from the display name, persists the definition, registers it at startup, and creates a default passthrough profile.
Kandev derives a stable slug from the display name, persists the definition, registers it at startup, and creates a default profile in the selected protocol's mode. The command is split into an executable and arguments on whitespace; it is not evaluated by a shell, and shell quoting/expansion is not supported.
This path is terminal passthrough only. It does not provide structured chat messages, tool-call visibility, ACP capability probing, or Kandev MCP injection. The command is split into an executable and arguments on whitespace; it is not evaluated by a shell, and shell quoting/expansion is not supported.
**Terminal** runs the command as a raw CLI under a PTY. Use `{{model}}` in the command to insert the optional model value. There are no structured chat messages, no tool-call visibility, and no capability probing; Kandev's MCP server reaches the CLI only through the selected MCP strategy, which writes the config file shape that CLI reads.
Use a source integration when the agent must ship for every installation, needs logos/discovery/auth/permissions, needs safe structured argument building, or supports ACP.
**ACP** drives the command over the Agent Client Protocol on standard input and output, so the agent gets structured chat, tool calls, and the models and modes learned by the host-utility capability probe. Give the command that starts the ACP server, typically behind a flag (`my-agent --acp`). Resolved MCP servers travel in `session/new`, so no MCP strategy applies and none is offered. The protocol is not probed from the command: a CLI's ACP server command is not the command that renders its terminal, so choosing wrong would launch a JSON-RPC server into a terminal tab.
Use a source integration when the agent must ship for every installation, needs logos/auth/permissions/install scripts, needs safe structured argument building, or needs a bridge because the CLI has no ACP server of its own.
## Ship a built-in passthrough agent
+7 -3
View File
@@ -187,9 +187,13 @@ uses the expected npm installation and configured registry. Run `npm config get
### Add a custom terminal agent
Use **Settings > Agents > Add TUI Agent** for a CLI that Kandev does not register. Enter a display name, command, and optional model label. `{{model}}` in the command is replaced by the selected model value, then the entire command is split on whitespace with Go's `strings.Fields`.
Use **Settings > Agents > Add custom agent** for a CLI that Kandev does not register. Enter a display name, a protocol, and a command. The entire command is split on whitespace with Go's `strings.Fields`.
That parser is not a shell and is not quote-aware: quotes and backslashes do not preserve a path or model containing spaces as one argument. Custom TUI agents always use terminal passthrough. They do not gain ACP features such as structured permission prompts, model discovery, modes, or session configuration merely by being added. Test the exact resulting argument split before assigning it to work.
That parser is not a shell and is not quote-aware: quotes and backslashes do not preserve a path or model containing spaces as one argument. Test the exact resulting argument split before assigning it to work.
A **Terminal** agent runs as passthrough. It also takes an optional model label, and `{{model}}` in the command is replaced by that value. It gains no ACP features: no structured permission prompts, no model discovery, no modes, no session configuration.
An **ACP** agent is driven over the Agent Client Protocol instead, so it does get structured chat, tool calls, permission prompts, and probed models and modes. Enter the command that starts the CLI's ACP server, usually behind a flag such as `--acp`. Its model comes from the capability probe rather than the command, and its MCP servers travel in `session/new`, so neither the model label nor the MCP strategy is offered.
</details>
@@ -445,7 +449,7 @@ Workspace automation selectors do not offer passthrough agent profiles. Local ex
ACP sessions can expose typed messages, tool updates, permission requests, models, modes, dynamic configuration, todos, usage, and resume metadata. Each capability depends on the agent's actual ACP implementation. ACP-only profile settings, including command prefixes and structured configuration, do not add those capabilities to a terminal-passthrough CLI.
Passthrough preserves the CLI's native PTY interface. It is useful when the native terminal has features that ACP does not expose, but Kandev cannot manufacture structured capabilities that are absent. Custom TUI profiles are locked to passthrough. Profile-specific MCP injection also varies by CLI; verify the command preview and the MCP section before depending on it.
Terminal custom profiles use passthrough and preserve the CLI's native PTY interface. This is useful when the native terminal has features that ACP does not expose, but Kandev cannot manufacture structured capabilities that are absent. Custom ACP profiles use structured ACP sessions. Profile-specific MCP injection also varies by CLI; verify the command preview and the MCP section before depending on it.
> **MCP credential exposure:** MCP headers and environment values are stored in profile configuration. Codex may place them in process arguments, and Cursor or Pi may leave them in project files after teardown. Use short-lived, narrowly scoped credentials and review persisted files.
+1 -1
View File
@@ -20,7 +20,7 @@ Use the smallest seam that owns the behavior, then follow the completion checkli
## Add an agent
For a local passthrough-only CLI, users can choose **Settings → Agents → Add TUI Agent**. That path persists a custom definition and default profile; no source patch is required.
For a local CLI, users can choose **Settings → Agents → Add custom agent** and pick Terminal or ACP. That path persists a custom definition and default profile; no source patch is required.
To ship a built-in integration, add an `agents.Agent` implementation under `apps/backend/internal/agent/agents/` and register it in `internal/agent/registry/registry.go`. Structured agents currently need an ACP-speaking process. Optional interfaces add inference, CLI passthrough, native-binary preference, or interactive login.
+1 -1
View File
@@ -174,7 +174,7 @@ Remote repository and issue/PR URLs are not added from this settings page. Use t
4. Complete the provider's authentication flow. Some agents expose a dedicated login terminal; others use their own CLI outside Kandev.
5. Open the agent and its profile. Verify the selected model and mode, permission switches, CLI flags, environment variables, MCP configuration, and CLI-passthrough setting. Save changes.
Detected capabilities come from the installed CLI and can change after an agent upgrade or login. A model or mode shown in documentation is not guaranteed for every account. If no built-in adapter fits, **Add TUI Agent** creates a passthrough integration; passthrough has a different resume, usage, and MCP contract from an ACP-capable agent.
Detected capabilities come from the installed CLI and can change after an agent upgrade or login. A model or mode shown in documentation is not guaranteed for every account. If no built-in adapter fits, **Add custom agent** registers the CLI as a Terminal or an ACP agent; Terminal passthrough has a different resume, usage, and MCP contract from an ACP-capable agent.
For the first task on an existing repository, keep the seeded **Worktree** executor profile. It creates a separate Git checkout so concurrent Kandev tasks do not edit the same working tree. A worktree is Git isolation, not operating-system isolation. Choose **Local** only when direct edits in the selected checkout are intentional. A repository initialized from **New Task** has one empty initial commit and no project files. Creation selects a direct Local profile for a single-row task when one is available and preserves the selected executor for multiple rows. Without a direct Local profile, single-row creation remains disabled. See [Agents and profiles](agents-and-profiles.md) and [Executors](executors.md) before using Docker, SSH, Sprites, custom scripts, or shared infrastructure.
@@ -0,0 +1,48 @@
---
status: draft
system: agents
created: 2026-09-22
updated: 2026-09-22
owners:
- jnmanso
---
# Operator-Registered Agent Requirements
## Overview
An operator can register a CLI as an agent from Settings without changing Kandev source. That
definition names the protocol Kandev drives the CLI with, and the registry, the discovery sweep, and
the capability probe all honour it for the lifetime of the backend process rather than only at boot.
## Requirements
### REQ-AGENTS-CUSTOM-ACP-001: Operator-Registered Agents Run In Their Declared Protocol
**Intent:** Let an operator register a CLI that speaks the Agent Client Protocol and have Kandev drive
it as a structured agent, while keeping every existing terminal definition working untouched and
keeping registry changes visible without a restart.
#### Acceptance criteria
- **AC-AGENTS-CUSTOM-ACP-001.1:** When an agent is registered, replaced, or unregistered after
startup, the discovery sweep shall resolve the agent list from the agent registry rather than from a
list captured at boot, so a created agent appears and a deleted agent disappears without restarting
the backend.
- **AC-AGENTS-CUSTOM-ACP-001.2:** When a registry membership change is committed, the cached sweep
results shall be dropped so the change is observable before the cache TTL expires, and a sweep that
began before that invalidation shall not publish its superseded results.
- **AC-AGENTS-CUSTOM-ACP-001.3:** When an operator-registered definition carries no protocol, the
system shall continue to run it as terminal passthrough, so definitions stored before the field
existed keep their behaviour with no migration.
- **AC-AGENTS-CUSTOM-ACP-001.4:** When a definition declares the ACP protocol, the system shall
register an agent whose runtime protocol is ACP, which advertises inference so the capability probe
covers it, and which is not classified as passthrough-only.
- **AC-AGENTS-CUSTOM-ACP-001.5:** When an ACP definition is created, the system shall seed its default
profile as non-passthrough with no model and shall start a capability probe, so the profile editor
offers the agent's probed models without a manual refresh.
- **AC-AGENTS-CUSTOM-ACP-001.6:** When a definition names an unknown protocol, or pairs the ACP
protocol with a passthrough MCP strategy, the system shall reject it with a client error rather than
registering a downgraded agent.
- **AC-AGENTS-CUSTOM-ACP-001.7:** When the capability probe spawns an operator-registered command, it
shall accept that command even though no compiled-in literal can cover it, while a command that
claims to be built-in shall still resolve against the allow-list.
@@ -0,0 +1,83 @@
---
status: draft
system: agents
requirements:
- REQ-AGENTS-CUSTOM-ACP-001
created: 2026-09-22
updated: 2026-09-22
owners:
- jnmanso
---
# Operator-Registered Agent System Design
## Purpose and boundaries
This design owns the technical contract for REQ-AGENTS-CUSTOM-ACP-001: the stored shape of an
operator-registered definition, which agent type each protocol builds, and the three places that
previously assumed every such definition was terminal passthrough.
It does not change how built-in agents are declared, detected, or launched.
## Requirement mapping
| Acceptance criterion | Owner |
| --- | --- |
| AC-AGENTS-CUSTOM-ACP-001.1 | `internal/agent/discovery` |
| AC-AGENTS-CUSTOM-ACP-001.2 | `internal/agent/discovery`, `internal/agent/settings/controller` |
| AC-AGENTS-CUSTOM-ACP-001.3 | `internal/agent/registry`, `internal/agent/settings/models` |
| AC-AGENTS-CUSTOM-ACP-001.4 | `internal/agent/agents` |
| AC-AGENTS-CUSTOM-ACP-001.5 | `internal/agent/settings/controller` |
| AC-AGENTS-CUSTOM-ACP-001.6 | `internal/agent/registry`, `internal/agent/settings/handlers` |
| AC-AGENTS-CUSTOM-ACP-001.7 | `internal/agentctl/server/utility` |
## Discovery reads the registry per sweep
`discovery.Registry` holds `*registry.Registry` and resolves enabled, non-virtual agents inside
`detectAll`. The boot-time capture is removed, along with the `KnownAgent`/`Definitions()` pair that
carried it and the `IsInstalled` pass it ran at startup.
Detection results stay cached for `defaultCacheTTL`. Because a sweep can outlive an invalidation, the
cache is generation-fenced: `InvalidateCache` bumps a counter, `Detect` captures it before sweeping,
and the write at the end is skipped when the captured generation is stale. Without the fence a sweep
that started before a delete would publish the deleted agent back into the cache.
`CreateCustomTUIAgent`, `DeleteAgent`, and `SetCustomTUIAgentMCPStrategy` invalidate after their
registry mutation commits.
## Protocol lives in the stored definition
`models.TUIConfigJSON.protocol` carries `registry.CustomAgentProtocol`. The zero value is
`CustomAgentProtocolTerminal`, which is what every row written before the field existed decodes to, so
no migration runs. `CustomAgentProtocolACP` is the only other accepted value.
`registry.buildCustomAgent` switches on it:
- terminal builds an `agents.TUIAgent` with the resolved MCP strategy, exactly as before;
- ACP builds an `agents.CustomACPAgent` and rejects a non-empty MCP strategy;
- anything else returns `ErrUnknownCustomAgentProtocol`.
`controller.CustomAgentSpecFromStored` is the single spec builder shared by the MCP-strategy change and
the boot replay, so a field added to the stored config cannot reach one path and miss the other.
## CustomACPAgent is deliberately not a TUIAgent
`agents.IsPassthroughOnly` is a type assertion on `*TUIAgent`, and the host-utility probe covers only
`InferenceAgent` implementers. `CustomACPAgent` therefore implements `Agent` plus `InferenceAgent`,
reports `agent.ProtocolACP` from `Runtime()`, and deliberately does not implement `PassthroughAgent`: a
definition carries one command, and the command that starts an ACP server is not the command that
renders an interactive terminal.
`IsInstalled` reports `SupportsMCP = true` because resolved servers travel in ACP `session/new` rather
than through a config-file strategy.
## The probe accepts an operator-registered command
`acp_executor.go` resolved every spawn against `allowedProbeCommands`, a map of compiled-in literals
that exists so static analysis can follow a literal to `exec.Command`. An operator-registered command
cannot be in that map.
`resolveSpawnCommand` keeps that path for built-ins and returns the command unchanged when the agent
marked it operator-defined (`InferenceConfig.OperatorDefined`, carried to agentctl on
`InferenceConfigDTO`). The exception is bounded to a command the install operator typed in Settings:
the session path already spawns that exact string, and agentctl's piped runner already accepts an
arbitrary command from its request, so the allow-list denied the probe without denying execution.