Files
kandev/docs/decisions/0047-plugin-host-conversation-reads.md
T
João Salavisa 78cae96bb1 feat(plugins): host-side conversation reads + utility-agent invoke (#1852)
* feat(plugins): add capability-gated Host reader for conversation content

Plugins can now read historical user/agent message content over the Host
data API, gated by a new `api_read:messages` capability (ADR 0047). This
unblocks "summarize yesterday"-style plugins that the live `message.added`
bus event alone cannot serve.

- proto: `ListMessages` RPC + `Message`/`MessageFilter` on `service Host`.
- SDK: `Host.Messages() MessageReader`, Go-native `Message`/`MessageFilter`.
- host: `api_read:messages` accessor + denied reader; content sanitized via
  `sysprompt.StripSystemContent` (no raw system prompts); RFC3339 `since`/
  `until` → `InvalidArgument` on bad input.
- data: new `ListMessagesForPlugin` filtered query (session/task ids, type,
  created_at range) through the task service, never the DB file.
- tests: in-process + wire capability gating, content stripping, filter/time
  passthrough, pagination, and the SQLite query.
- docs: ADR 0047, spec, plugins-manifest, plugins-authoring.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(plugins): add Host.InvokeUtilityAgent for settings-selectable utility agent

Plugins can now delegate a one-shot LLM step to an operator-configured agent,
gated by a new `agent_invoke` capability (ADR 0048), so a plugin needs no
provider API key of its own.

- setting: new `utility_agent_profile_id` user setting (Settings > System >
  Utility Agent), a profile dropdown cloned from the existing utility-agent
  path; selectable in the UI.
- capability: boolean `agent_invoke`, enforced like `state`/`secrets`.
- proto/SDK: `InvokeUtilityAgent(prompt) -> text` RPC and
  `Host.InvokeUtilityAgent(ctx, prompt)`.
- backend: reuses the sessionless host-utility inference tier (ADR 0002) via a
  thin adapter — no agent loop, no task/session/workspace; resolves the
  configured profile to (agent type, model). Unconfigured / deleted profile ->
  gRPC FailedPrecondition.
- tests: capability gating, completion path (asserts the configured profile is
  used and text returned), no-utility-agent and stale-profile error paths,
  SDK/wire round-trips, and the settings apply/trim behavior.
- docs: ADR 0048, spec, plugins-manifest, plugins-authoring.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(plugins): resolve utility-agent deps live and bound message filter size

Address CI + review feedback on the host plugin capabilities:

- docs: register the new /settings/system/utility-agent route in
  docs/public/coverage.json (fixes the "Validate public docs" CI failure).
- utility agent (ADR 0048): hostUtilityMgr is wired late in boot — after
  StartActivePlugins has already spawned boot-active plugins — so snapshotting
  the utility deps in hostForPlugin stranded those hosts with nil deps and made
  InvokeUtilityAgent return Unimplemented for their lifetime. Read the deps live
  from the Service (mutex-guarded) at call time instead; add a regression test
  for late wiring reaching an already-spawned host.
- messages (ADR 0047): cap the combined session/task/type filter values at 400
  so an oversized filter fails fast with InvalidArgument instead of hitting
  SQLite's host-parameter limit at query execution; add a test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 00:20:09 +01:00

5.7 KiB

0047 — Plugins read conversation content via a capability-gated Host RPC

Context

The Host data API (ADR 0043) lets a plugin read kandev's domain data — tasks, sessions, workspaces, workflows, agent profiles, repositories — over a capability-gated Host gRPC channel. It deliberately stopped short of message content: Sessions() exposes session metadata (identity, agent, timestamps, acp_session_id, code stats) but no transcript.

A downstream "My Daily Standup" plugin needs to summarize yesterday's user/agent conversations and surface issues worth mentioning. Today a plugin cannot read historical message content at all:

  • The only place message content is exposed is the message.added bus event (internal/task/service/service_events.go publishMessageEvent). Live event capture is a poor fit for "summarize yesterday" — a plugin would have to be running and recording continuously, and gets nothing for the window before it was installed.
  • The message repository has no task-scoped or time-range query. ListMessages is session-scoped only; nothing filters by task id or a created_at window.

Reusing the ADR 0043 escape hatch (open the SQLite file directly) is the exact failure ADR 0043 exists to close: schema coupling, breaks on Postgres, no scoping, only possible because the subprocess is unsandboxed.

Decision

Add one read RPC to the existing service Host, following ADR 0043 exactly.

  1. New RPC + DTO. ListMessages(ListMessagesRequest) returns (ListMessagesResponse) on service Host in apps/backend/proto/kandev/plugin/v1/plugin.proto. Message DTO fields: id, session_id, task_id, turn_id, author_type, content, type, created_at (RFC3339). MessageFilter: session_ids, task_ids, since, until (RFC3339, since inclusive / until exclusive), types. Opaque cursor pagination (Page/PageInfo) like every other reader.

  2. SDK accessor. Host.Messages() MessageReader with List(ctx, MessageFilter, Page) ([]Message, *PageInfo, error), plus Go-native Message/MessageFilter mirrors in pkg/pluginsdk/data_types.go and an UnimplementedHostData.Messages() default. No proto types leak past the package boundary.

  3. Capability gate api_read:messages. A new resourceMessages const and a Messages() accessor in internal/plugins/host_data.go that returns a real, service-backed reader when the manifest declares api_read:messages, else a denied reader whose List returns gRPC PermissionDenied with capability 'api_read:messages' not declared — identical to the other resources. A non-RFC3339 since/until returns gRPC InvalidArgument.

  4. Content is sanitized; system prompts never leave kandev. The reader maps models.Message → DTO through messageModelToDTO, which runs sysprompt.StripSystemContent on content — the same <kandev-system> stripping the message.added event applies. Raw system content and the event path's raw_content/has_hidden_prompts are not exposed. author_type is only ever user or agent: there is no system author in kandev's model — system context is inline markup inside an otherwise user/agent message, and it is stripped at read time.

  5. Reads go through the service layer. A new Service.ListMessagesForPlugin(ctx, models.PluginMessageFilter) on the task service delegates to a new MessageRepository.ListMessagesForPlugin / SQLite ListMessagesForPlugin — a single filtered query (task_session_id IN, task_id IN, type IN, created_at >= since, created_at < until), ordered created_at ASC, id ASC, with SQL LIMIT/OFFSET. The reader requests page-limit+1 rows to derive HasMore without a second count query; NextCursor is offset+limit, matching the other readers' opaque-offset cursor. The internal/plugins package reaches this via a narrow messageDataSource interface wired by Service.SetDataSources, never importing the task service directly (the same import-cycle avoidance the existing readers use).

Consequences

  • A plugin can read a window of conversation content (e.g. "everything on this workspace's tasks since yesterday 00:00") without touching the DB file, and without the plugin having to be running when the messages were created.
  • The Message proto is a public contract: fields are additive-only thereafter.
  • The new query is the one net-new piece of data access; every other layer is the established ADR 0043 pattern (accessor + denied reader + narrow data source + DTO mapper + in-process/wire capability tests).
  • Scoping stays ADR 0043 v1: reads are global to the instance, filters narrow results, and the single server-side data-source hook remains the place a future per-plugin/per-user restriction would attach.

Alternatives considered

  • Subscribe to message.added and accumulate. Rejected: only captures messages created while the plugin runs, needs durable plugin-side storage, and gives nothing for the pre-install / "yesterday" window the feature is about.
  • Expose the raw message row (including raw_content). Rejected: leaks kandev's injected system prompts to plugins. The event path already strips them; the read path must match.
  • A session-only reader (reuse ListMessages). Rejected: the standup use case is task/time scoped ("yesterday, across these tasks"), which the session-only query cannot express without an N+1 fan-out and client-side time filtering. One filtered query is simpler and cheaper.