feat: acp-first profiles, models, and modes (#566)
This commit is contained in:
@@ -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.
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user