feat: acp-first profiles, models, and modes (#566)

This commit is contained in:
Carlos Florêncio
2026-04-15 13:22:32 +01:00
committed by GitHub
parent f757e07460
commit 204fac1cca
148 changed files with 5984 additions and 14990 deletions
@@ -0,0 +1,54 @@
# 0002: Host Utility Agentctl for Sessionless ACP Flows
**Status:** accepted
**Date:** 2026-04-08
**Area:** backend
## Context
Utility agent calls (enhance-prompt, commit-message, etc.) historically required an active task session: `lifecycle.Manager.ExecuteInferencePrompt` took a `sessionID`, used `GetOrEnsureExecution` to locate that task's agentctl instance, and spawned a one-shot ACP subprocess inside it. This coupled *any* one-shot inference to an existing workspace, blocking three emerging needs:
1. Boot-time capability probes (list models/modes/auth for each installed agent so the UI can populate settings).
2. On-demand "refresh" of those capabilities from settings.
3. Running `enhance-prompt` in the new-task modal — before any task or workspace exists.
Built-in utility prompt templates are self-contained (they inject context as template variables rather than reading files), so the task workspace cwd was incidental — the coupling was structural, not semantic.
## Decision
Introduced a **host utility** subsystem (`internal/agent/hostutility/Manager`) that maintains one long-lived, workspace-only agentctl instance per ACP-native inference agent type, each bound to a process-scoped tmp directory. The manager is started after `waitForAgentctlControlHealthy` in `cmd/kandev/main.go`, runs parallel boot probes via `errgroup`, and owns an in-memory capability cache.
Key pieces:
- **Probe path.** New `ACPInferenceExecutor.Probe` in `internal/agentctl/server/utility/acp_executor.go` runs `initialize` + `session/new` against an ephemeral subprocess and returns the parsed `ProbeResponse` (agent info, auth methods, models, modes, prompt capabilities). Exposed over HTTP as `POST /api/v1/inference/probe` with a matching `Client.Probe` client method.
- **Warm instances, ephemeral ACP.** The per-agent-type instance is workspace-only — no persistent agent subprocess. Each probe/prompt call still spawns its own short-lived ACP subprocess via the existing `/api/v1/inference/prompt` and new `/api/v1/inference/probe` routes. The "warm" part is the agentctl instance host, not the ACP session.
- **Sessionless utility execute.** `POST /api/v1/utility/execute` now treats `session_id` as optional. When absent, `internal/utility/handlers/handlers.go:executeSessionless` routes through `HostUtilityExecutor.ExecutePrompt`, template substitution uses `MissingAsEmpty: true` for variables that are meaningless without a session (GitDiff, TaskTitle, etc.), and the `UtilityAgentCall` record is still written.
- **Public HTTP surface** (`internal/agent/capabilities/handlers/`, prefix `/api/v1/agent-capabilities` — separate from `/api/v1/agents/:id/...` to avoid a Gin wildcard name collision):
- `GET /api/v1/agent-capabilities` — list all cached
- `GET /api/v1/agent-capabilities/:type` — single agent
- `POST /api/v1/agent-capabilities/:type/probe` — re-probe + refresh cache
- `POST /api/v1/agent-capabilities/:type/prompt` — raw one-off inference (no utility agent record)
- **Tmp dir lifetime.** Each kandev process creates `kandev-host-utility-<pid>-*` with per-agent subdirs; `Stop()` removes only dirs owned by this process. Concurrent kandev processes never share or clobber each other's tmp state.
- **Scope.** v1 probes only agents whose `Runtime().Protocol == ProtocolACP` (claude-acp, codex-acp, copilot-acp, amp-acp, opencode-acp, auggie). Non-ACP adapters (streamjson, codex, copilot, amp, opencode) are explicitly out of scope; they can be extended later.
- **Model resolution** for sessionless calls: explicit arg → cached probe `currentModelId`. Static per-agent model lists have been removed; `InferenceAgent` no longer exposes an `InferenceModels()` method.
- **Failure isolation.** Per-agent-type probe failures land in the cache as `{Status: failed|auth_required|not_installed, Error}` without aborting other probes or blocking boot.
## Consequences
**Easier:**
- Settings can show per-agent status/auth/models driven by a single endpoint.
- Enhance-prompt works before a task exists without synthesizing a throwaway session.
- Probe and sessionless prompt share the same underlying path — one mechanism, three features.
- Cold-start is bounded to the ACP subprocess spawn (~100–300ms); the agentctl host is already warm.
**Harder:**
- One more long-lived subsystem to supervise. Crashes are lazy-repaired on next use; there is no background health loop.
- Host instances run with backend privileges in a tmp dir. The capabilities handlers do not expose shell/files endpoints, but raw agent-level permissions on the underlying agentctl instance are not fenced off in code — discipline is required if future endpoints are added.
- Utility templates that genuinely need to read workspace files cannot use the sessionless path. For v1 this is fine (all built-ins are self-contained); a `requires_workspace` flag on `UtilityAgent` is the future escape hatch.
## Alternatives Considered
1. **Ephemeral per-call agentctl.** Spawn agentctl, run inference, tear down. Cleanest isolation but adds cold-start latency (agentctl boot + `npx` resolution) to every Enhance click. Rejected — feels sluggish for UI-driven flows.
2. **Direct vendor SDK calls from Go.** Skip ACP entirely for inference and call Anthropic/OpenAI/etc. APIs directly. Simpler runtime but requires reimplementing auth and model listing per vendor, and diverges from the ACP-first design. Rejected as the default; may still be the right call for vendor-specific model listing in a later iteration.
3. **Reserved execution IDs in `ExecutionStore` with a `GetOrEnsureExecution` bypass.** Would have forced host utility through `lifecycle.Manager` and required a new `host-utility:` ID prefix branch in `manager_execution.go`. Rejected once it became clear that `ControlClient.CreateInstance` already does everything needed without touching the execution store.
4. **One host agentctl (not per-agent-type).** A single instance that gets re-used for all ACP agents. Rejected because probes would either have to serialize or share subprocess state; per-agent-type gives natural isolation and cheaper refresh.
+1
View File
@@ -7,3 +7,4 @@ Read individual ADRs for full context. Create new ones via `/record decision` or
| # | Title | Status | Area | Date |
|---|-------|--------|------|------|
| 0001 | [File-based knowledge system](0001-file-based-knowledge-system.md) | accepted | infra | 2026-03-28 |
| 0002 | [Host utility agentctl for sessionless ACP flows](0002-host-utility-agentctl-for-sessionless-flows.md) | accepted | backend | 2026-04-08 |
+110
View File
@@ -0,0 +1,110 @@
# Host Utility Agentctl — Boot Probes, Capability API, Sessionless Utility Prompts
**Date:** 2026-04-08
**Status:** implemented
**PR:** TBD
**Decision:** ADR-0002
## Problem
Utility agent calls (enhance-prompt, commit-message, etc.) required an active task session because `lifecycle.Manager.ExecuteInferencePrompt` looked up the session's agentctl via `GetOrEnsureExecution` and spawned a one-shot ACP subprocess inside it. Three features could not fit that mold:
1. **Boot-time probes.** List models/modes/auth for each installed agent so the settings UI can render them and detect misconfiguration early.
2. **Settings refresh.** User clicks "refresh" to re-check auth/models after installing or re-authing a CLI.
3. **Sessionless enhance-prompt.** Enhance button in the new-task modal — no task or workspace exists yet.
Built-in utility prompts are self-contained (context is injected as template vars), so running them in a neutral tmp dir is semantically fine.
## Design
### Architecture
```text
Backend boot
↓
waitForAgentctlControlHealthy
↓
HostUtilityManager.Start(ctx) [goroutine]
├─ mktemp kandev-host-utility-<pid>-*/
├─ filter registry → ACP-native inference agents
└─ errgroup (parallel):
for each agent type:
├─ IsInstalled? (skip → not_installed)
├─ ControlClient.CreateInstance(workdir=tmp/<type>)
├─ waitForClientHealthy
└─ Probe() → cache
```
Per-call path (both probe and sessionless prompt):
```text
caller → HostUtilityManager.getInstance(type) [singleflight per agent type]
→ per-type warm Client (one-shot ACP subprocess)
→ agentctl utility.{Probe|Execute}
→ initialize → session/new → [set_mode?] → [prompt?] → kill
```
### Packages and files
**New**
- `internal/agent/hostutility/` — `Manager`, `cache`, public API (`GetAll`, `Get`, `Refresh`, `ExecutePrompt`), types (`AgentCapabilities`, `Model`, `Mode`, `AuthMethod`, `PromptResult`, `Status` enum).
- `internal/agent/capabilities/handlers/` — HTTP routes (mounted under `/api/v1/agent-capabilities` rather than under `/api/v1/agents/:id/...` to avoid a Gin wildcard-name collision with the existing settings routes):
- `GET /api/v1/agent-capabilities`
- `GET /api/v1/agent-capabilities/:type`
- `POST /api/v1/agent-capabilities/:type/probe`
- `POST /api/v1/agent-capabilities/:type/prompt` (raw one-off)
**Extended**
- `internal/agentctl/server/utility/types.go` — `ProbeRequest`/`ProbeResponse`, `Mode` field on `PromptRequest`, `ProbeAuthMethod`/`ProbeModel`/`ProbeMode`/`ProbePromptCapabilities`.
- `internal/agentctl/server/utility/acp_executor.go` — `Probe()` method, `executeACPSession` honors `mode` via `conn.SetSessionMode`. Parsing split into `buildInitProbeFields` + `applySessionProbeFields` helpers.
- `internal/agentctl/server/utility/handler.go` — `POST /api/v1/inference/probe` route.
- `internal/agentctl/client/utility.go` — factored `doLongRunningJSON` helper shared by `InferencePrompt` and new `Probe`.
- `internal/utility/template/engine.go` — `ResolveWithOptions` + `ResolveOptions{MissingAsEmpty bool}`.
- `internal/utility/service/service.go` — `PreparePromptRequest` takes a `sessionless bool` flag.
- `internal/utility/controller/controller.go` — threads the flag through.
- `internal/utility/handlers/handlers.go` — `session_id` optional; sessionless branch calls `HostUtilityExecutor.ExecutePrompt`; new `HostUtilityExecutor` interface.
- `cmd/kandev/main.go` — constructs `HostUtilityManager` after `waitForAgentctlControlHealthy`, starts it in a goroutine, registers `Stop()` cleanup via `addCleanup` with a 10s timeout. Threaded through `buildHTTPServer` → `routeParams`.
- `cmd/kandev/helpers.go` — `routeParams.hostUtilityMgr`; registers both the new capabilities routes and the updated utility routes.
### Config shapes (internal)
```go
// ProbeConfig — implicit, built per-call by the manager from registry + workdir.
// No Model — probes must omit --model so session/new returns the agent default
// plus the full availableModels list.
//
// PromptConfig — uses the existing InferenceConfigDTO; Model is required,
// Mode is optional and when set triggers session/set_mode after session/new.
```
### Caller responsibilities
| Param | Probe | Sessionless prompt | Task-scoped prompt |
|---------|-------|--------------------|---------------------|
| WorkDir | host tmp dir (manager-owned) | host tmp dir | task workspace (unchanged) |
| Model | **omitted** | required (utility record → user default → cached currentModelId) | from utility record / user default |
| Mode | unused | optional | optional |
### Scope (v1)
- ACP-native agents only (`Runtime().Protocol == ProtocolACP`): claude-acp, codex-acp, copilot-acp, amp-acp, opencode-acp, auggie.
- Non-ACP protocols (streamjson/codex/copilot/amp/opencode adapters) excluded — later iteration.
- Cache is in-memory, populated at boot and on explicit `/probe` refresh. No TTL.
- Tmp dirs process-scoped (`kandev-host-utility-<pid>-*`); cleanup only removes dirs owned by this process.
## Implementation Notes
- **Lazy instance recreation.** `Manager.getInstance` recreates a warm instance if it's missing (never bootstrapped or crashed). No separate health loop.
- **Failure isolation.** Per-agent bootstrap failures land in the cache as `Status = not_installed | auth_required | failed`. `isAuthError` is a coarse string-match heuristic on the ACP SDK error — the SDK doesn't expose a distinct auth error code.
- **Env inheritance.** The agentctl instance is workspace-only and inherits the backend's env. The ephemeral ACP subprocess spawned by `Probe`/`Execute` also inherits (no `cmd.Env` override in `acp_executor.go`), so `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, etc. flow through naturally for v1.
- **`ExecutionStore` bypass deferred.** The original plan called for a `host-utility:` ID prefix branch in `manager_execution.go`. Once it became clear that `ControlClient.CreateInstance` + a regular `Client` does everything we need without touching the lifecycle manager's execution store, that branch was removed from the plan — host utility sits alongside the lifecycle manager rather than through it.
- **Worktree access.** Sessionless utility calls cannot read workspace files by design. All current built-ins are self-contained; a `requires_workspace` flag on `UtilityAgent` is the documented future escape hatch for custom templates that need file access.
- **Lint wrinkle.** `acp_executor.go` has one `//nolint:unconvert` on `string(m.Id)` where `m.Id` is `acp.AuthMethodId` (a named string type). The linter flags it as unnecessary, but Go requires the explicit conversion.
- **Frontend wiring is explicitly deferred.** Settings page, new-task modal enhance button, and startup capabilities fetch land in a follow-up PR. The API surface was designed to support all three.
### Verification
- `make fmt` clean
- `go build ./...` success
- `make test` all packages green
- `make lint` 0 issues
+1
View File
@@ -6,3 +6,4 @@ Create new plans via `/record plan` or manually following the format below.
| Plan | Status | Date | PR |
|------|--------|------|----|
| [Host utility agentctl](2026-04-host-utility-agentctl.md) | implemented | 2026-04-08 | TBD |