Files
kandev/docs/decisions/0048-plugin-host-utility-agent-invoke.md
2026-09-15 09:40:39 +01:00

101 lines
5.5 KiB
Markdown

# 0048 — Plugins invoke a selected direct profile or utility agent
- Status: superseded by [explicit plugin utility selection](2026-09-14-explicit-plugin-utility-selection.md)
- Date: 2026-07-21
- Area: backend, frontend, protocol
- Related: [0043 — Plugin host data API](0043-plugin-host-data-api.md),
[0002 — Host utility agentctl for sessionless ACP flows](0002-host-utility-agentctl-for-sessionless-flows.md)
(the inference tier this reuses),
[0018 — Runtime settings overrides](0018-runtime-settings-overrides.md)
## Context
A plugin that wants to do an LLM step (summarize a conversation, classify an
issue) has no sanctioned way to run a completion: it would need to ship and
manage its own provider API key. Kandev already runs LLM completions on the
operator's configured agents; a plugin should be able to borrow one.
Kandev also already has the right primitive. ADR 0002's **host-utility tier**
(`internal/agent/hostutility.Manager.ExecutePrompt`) runs a one-shot,
non-interactive, sessionless completion against a warm agentctl instance —
exactly what title generation, commit messages, and the Slack assistant use.
There is no need for a new agent loop, and the interactive agent runtime
(`internal/agent/runtime`, `internal/office`) is the wrong tool: it is
streaming, stateful, and requires an executor + workspace + worktree.
## Decision
Add a capability-gated `Host.InvokeUtilityAgent`, backed by each plugin's
configuration and the existing host-utility tier.
1. **Plugin config: direct profile first.** Plugins declaring `agent_invoke`
can declare an `agent_profile` field with `type: string` and
`format: agent-profile`. Settings > Plugins renders enabled, global,
non-CLI profiles whose agent supports sessionless inference and stores the
selected stable ID. A missing, deleted, disabled, CLI-passthrough,
workspace-scoped, or non-inference profile is a
`FailedPrecondition`. A non-empty declared `agent_profile` is the direct
execution selection and takes precedence over `utility_agent`; when it is
unset, the legacy selection remains available if the active manifest
declares that selector, or if a verified manifest transition recorded
`LegacyUtilityAgentFallback` for a retained value. Arbitrary undeclared
submitted `utility_agent` values are ignored.
Existing plugins may continue declaring `utility_agent` with
`format: utility-agent`; that legacy selector resolves the utility agent's
effective profile as before.
2. **New capability `agent_invoke`.** A boolean `Capabilities.AgentInvoke`,
enforced exactly like `state`/`secrets`: `Host.InvokeUtilityAgent` returns
gRPC `PermissionDenied` (`capability 'agent_invoke' not declared`) when the
manifest doesn't declare it.
3. **New RPC + SDK method.**
`InvokeUtilityAgent(InvokeUtilityAgentRequest{prompt}) returns
(InvokeUtilityAgentResponse{text})` on `service Host`; SDK
`Host.InvokeUtilityAgent(ctx, prompt string) (string, error)`. The request
message is the forward-compatible extension point (a future `system_prompt`
or `max_tokens` is an added proto field, no SDK signature change).
4. **Reuse the host-utility tier (ADR 0002).** The kandev-side handler:
gate `agent_invoke` → use a non-empty declared direct profile, otherwise
resolve the legacy utility agent → validate direct-profile
eligibility → call the narrow profile-aware utility runner and return the
text. `utilityRunner` is a thin
`pluginsHostUtilityAdapter` over `hostutility.Manager` wired in `backendapp`,
so `internal/plugins` never imports the agent runtime (the same
cycle-avoidance as the Slack assistant's adapter and ADR 0043's data
sources). No task, session, workspace, or worktree is involved.
5. **Typed configuration failures.** An absent selection with no usable
legacy selector, or a deleted/ineligible non-empty direct profile, returns
gRPC `FailedPrecondition` (`no agent profile configured` / `configured
agent profile "<id>" not found`). Legacy utility-agent failures retain their
existing messages.
Capability denial is evaluated before either lookup; runner failures remain
operational errors rather than configuration failures.
## Consequences
- A plugin declares `capabilities.agent_invoke: true` and calls
`host.InvokeUtilityAgent(ctx, prompt)` — no API key, no provider wiring. This
is what unblocks the "My Daily Standup" plugin's summarization step.
- The operator stays in control: nothing runs until each plugin selects a
configured direct profile or utility agent, including the effective
profile's cost, availability, launcher, and permission characteristics.
- We reused the sessionless inference path instead of building an agent loop;
the only net-new machinery is a plugin config picker and one gated RPC handler.
- The `InvokeUtilityAgentRequest`/`Response` proto is a public contract, extended
additively.
## Alternatives considered
- **Let plugins bring their own API key.** Rejected: every plugin re-implements
key management and secret storage, and the operator loses cost control. The
whole point is to delegate to kandev's already-configured agent.
- **Run the completion through `internal/agent/runtime` / `office`.** Rejected:
both are streaming, stateful, and require an executor + workspace; neither
offers a synchronous prompt→text call. ADR 0002's host-utility tier already
does exactly this, sessionlessly.
- **Reuse `default_utility_agent_id`.** Rejected: that default serves Kandev's
internal utility calls and cannot express a plugin-specific selection.