Commit Graph
7 Commits
Author SHA1 Message Date
Kyle Carberry 1742003785 fix(agent): gate workspace context collection until the agent is ready (#26715)
## Problem

Workspace context surfaced in chat (Coder Agents) is incomplete and racy
on a fresh boot:

- The context panel is missing personal skills (only repo-level skills
under `.claude/skills` show up).
- The MCP section lists `.mcp.json` files but no MCP servers are
registered.
- The Issues panel reports instruction files as unreadable, e.g.
`CLAUDE.md (file: unreadable)` and `.cursorrules (file: unreadable)`
with `symlink resolve: lstat .../AGENTS.md: no such file or directory`.

## Root cause

`agentcontext.Manager` collected and pushed context too eagerly:

- `NewManager` ran an eager resolve at agent `init()`.
- `RunPush` starts as a normal connection routine (`startAgentAPI210`)
with no lifecycle gating, so the first snapshot was pushed
(`Initial=true`) as soon as the agent API connected.

Both happened **before startup scripts finish** and before the lifecycle
reaches `ready`. At that point:

- `CLAUDE.md` / `.cursorrules` symlinks to `AGENTS.md` don't resolve
yet, so `EvalSymlinks` fails and the resolver emits `StatusUnreadable`
"symlink resolve" issues.
- Personal skills haven't synced yet, so they're missing.
- MCP servers connect via `mcpManager.Reload(...)` only **after**
`ready`, so only `.mcp.json` configs appear, with no servers.

That partial, error-laden snapshot is persisted by coderd and can
hydrate a chat.

## Fix

Gate `agentcontext.Manager` until the agent is ready, unconditionally:

- The Manager always starts gated. `NewManager` leaves the zero-value
(version 0) snapshot in place and never walks the filesystem; `RunPush`
withholds version-0 snapshots, so nothing reaches coderd.
- The agent calls `Manager.SetReady()` from the lifecycle transition in
`handleManifest`, right after startup scripts finish (`ready`, or
terminal `start_error` / `start_timeout` so a failed startup still
surfaces whatever context exists).
- On `SetReady`, the Manager performs the first real resolve (version 1)
and broadcasts it; `RunPush` ships it with `Initial=true`. Later changes
(MCP connect, skill edits) re-resolve and push as before.

Eager resolution before `ready` was the bug, not a mode worth
preserving, so the gate is always on rather than an opt-in option. This
aligns the agent-side push with chatd, which already waits for agent
readiness before loading context. No proto/coderd/DB changes: coderd
simply never receives a pre-ready snapshot.

<details>
<summary>Design notes &amp; decisions</summary>

- **Unconditional, not opt-in.** An earlier iteration added the gate as
an opt-in `ManagerOptions.GateUntilReady`. Since the eager
resolve-on-construct was the defect, the option, the eager first
resolve, and the now-dead `resolveLocked` helper were all removed; the
Manager is always gated until `SetReady`.
- **Version 0 is the pre-ready sentinel.** The gated placeholder is just
the zero-value snapshot (version 0); the first real resolve is version
1, so the push loop withholds anything at version 0. An earlier revision
carried a dedicated `Snapshot.Initializing` bool plus an HTTP `/resync`
field, but the push loop was the only consumer and nothing read the HTTP
field, so both were dropped.
- **Defer, don't retry symlinks.** Transient "unreadable" symlinks are
an artifact of collecting before checkout. Deferring until `ready` fixes
all three symptom classes at once and avoids masking genuine post-ready
errors (a broken symlink at `ready` is still reported).
- **Release on terminal startup states too** (`start_error`,
`start_timeout`), so a failed startup still surfaces whatever context
exists instead of gating forever. On reconnect the Manager instance is
reused and stays ready.

</details>

## Tests

- `agentcontext.TestManager_WithholdsCollectionUntilReady` simulates
collection running before startup finishes (broken `CLAUDE.md` /
`.cursorrules` -> `AGENTS.md` symlinks): asserts the gated snapshot is
the empty version-0 placeholder with no resources and no `unreadable`
issues, and that after `SetReady` (target now present) the inventory
resolves cleanly to a single instruction file with no spurious issues.
- `agentcontext.TestRunPush_WaitsForReady` asserts the push loop ships
nothing while gated even when content exists, then ships the full
inventory with `Initial=true` after `SetReady`.
- `agentcontext.TestManager_SetReadyIsIdempotent` covers the version-0
placeholder before ready, the single resolve to version 1 on `SetReady`,
and idempotency across repeated calls.
- Updated `agent.TestAgent_ContextStatePushed`: the first push now
already contains `AGENTS.md` with `Initial=true` and no `UNREADABLE`
resources (no pre-startup empty/partial push).

Validated on the changed packages: `go test -race
./agent/agentcontext/...`, `go test ./agent/ -run
TestAgent_ContextStatePushed`, `golangci-lint run`, `go vet`, `gofmt`
(all clean).

---
🤖 Generated by Coder Agents on behalf of @kylecarbs.
2026-06-25 14:17:50 -06:00
Kyle Carberry bafc86310c fix(agent/agentcontext): identify context sources by lexical path (#26616)
## What

Identify agent workspace-context **sources** by their lexical
(configured) path so `coder exp chat context list` no longer shows the
same directory twice, and so a source is shown as the path the operator
actually configured.

## Why (the bug)

Source identity was the canonical path from `CanonicalizePath`, which
resolves symlinks via `EvalSymlinks` **only when the target exists**.
That makes canonicalization time-dependent:

- At boot the agent seeds sources from `CODER_AGENT_EXP_*_DIRS`. If
`~/.coder/skills -> ~/my-agent/agent-rules/skills` and the target does
not exist yet (a startup script creates it later), `~/.coder/skills`
canonicalizes to the lexical `/home/coder/.coder/skills`.
- After the manifest lands (or the target is added directly), the same
configured source canonicalizes to the resolved
`/home/coder/my-agent/agent-rules/skills`.

The same configured source produced two different strings, so dedupe
keyed on the string registered both and the list showed one directory
twice.

Resolving symlinks for identity is also misleading on its own (per
@mafredri's review): a source added by a symlink path appears in the
list as its resolved target, as if that target had been added
explicitly.

## How

Source identity is now the **lexical** path: cleaned, `~`-expanded,
absolute, with symlinks **not** resolved (new `lexicalPath`;
`CanonicalizePath` is refactored to build on it). `AddSource`,
`SeedSources`, `HasSource`, `RemoveSource`, and boot seeding all key on
this stable identity.

`AddSource` still **validates** the resolved (`CanonicalizePath`) path
against the allowed roots, so a symlink cannot escape them. Only the
identity/display path changed.

This replaces the earlier `os.SameFile`/inode dedupe, which was unstable
and failed on Windows runners.

## Testing

- `go test ./agent/agentcontext/` (full package) and `go vet` pass;
`gofmt` clean.
- `TestManager_SourceIdentityIsLexicalAndStable`: adds the same
symlinked source before and after its target exists and asserts one
source whose path is the lexical link (skipped on Windows, matching the
package's other symlink tests).
- Existing `TestCanonicalizePath_FollowsSymlinks` and
`TestValidateSourcePath_*` confirm symlink resolution and the security
boundary are unchanged.

<details>
<summary>Related review findings</summary>

Fixes the "duplicate symlinked paths in `context list`" issue from the
chat-context system review and the dedupe-ordering question (lexical
identity preserves first-come-first-served order). Showing the
configured path for **resources** (not just sources) and restoring
scope-based skill precedence are separate, larger changes tracked
elsewhere.

</details>

---

*This PR was created by Coder Agents on behalf of @kylecarbs.*
2026-06-23 15:46:26 +00:00
Kyle Carberry 27ecd17991 refactor: consolidate agent MCP onto a single persistent engine (#26599)
Two MCP code paths both spawned the servers declared in a workspace's
`.mcp.json`: the persistent engine in `agent/x/agentmcp` (which owns
tool-call execution via `CallTool`) and an ephemeral one-shot runner in
`agent/agentcontext` (`mcprunner.go`) that connected, listed tools, and
immediately closed each server purely for discovery. Every declared
server was launched twice, and the discovery path duplicated the
engine's `.mcp.json` parse, transport-build, env-resolve, and connect
logic.

This makes `agent/x/agentmcp` the single persistent MCP engine. The
`agentcontext` manager now reads that engine's per-server catalog
in-process through an injected `MCPCatalog` option and surfaces each
server as a `KindMCPServer` resource. The engine wires `SetOnReload` to
the manager's `Trigger`, so a reload (startup connect or `.mcp.json`
edit) re-resolves and re-pushes the pinned resources. Tool-call
execution is unchanged: it still flows through the engine's `CallTool`
over `POST /api/v0/mcp/call-tool`.

The now-dead HTTP discovery surface is removed: the agent `GET
/api/v0/mcp/tools` route with `agentmcp.API.handleListTools`, and
`workspacesdk.AgentConn.ListMCPTools` with `ListMCPToolsResponse` (mock
regenerated). The change nets roughly `-1370` lines, mostly the deleted
duplicate runner and its tests.

<details>
<summary>Decision log</summary>

The merge of #26585 made pinned `chat_context_resources` the sole source
of workspace context, which surfaced the duplicate spawning. Two options
were considered:

- **Option A + dependency injection (chosen):** keep `agent/x/agentmcp`
as the single persistent engine; `agentcontext` consumes its catalog
in-process and stays the orchestrator/owner at the API boundary (it
still pushes `KindMCPServer` resources). This is low-risk because
`agentcontext` already exposed the `resolver.MCPResources` seam, so the
change just rebinds it from the ephemeral runner to the shared engine.
- **Option B (rejected):** reimplement persistent pooling, reconnect,
singleflight, and race handling inside `agentcontext` and delete
`agentmcp`. Too broad, and it discards the engine's tested lifecycle for
no behavioral gain.

`agentcontext`'s discovery was never what kept servers alive; its runner
closed each server immediately after listing tools. The component
holding persistent connections was always `agentmcp`, which is why
execution already lived there. Consolidating onto it removes the
duplicated stack rather than a whole package: both packages survive with
distinct roles (`agentmcp` is the engine, `agentcontext` is the
orchestrator/owner).

</details>

Coder Agents generated on behalf of @kylecarbs
2026-06-22 22:21:58 -06:00
Kyle Carberry 0f37522e6f refactor(agent/agentcontext): fixed-location shallow context discovery (#26596)
## Problem

`agent/agentcontext` resolved workspace context by walking the working
directory recursively (depth 8) and matching files by basename. This
over-injected context:

- Instruction-file matching was case-insensitive, so the generated
`docs/reference/api/agents.md` was treated as an instruction file.
- Symlinked instruction files (`CLAUDE.md`, `.cursorrules` ->
`AGENTS.md`) shipped as duplicate resources.
- Nested `AGENTS.md` (e.g. `site/AGENTS.md`) were collected from
anywhere in the tree.
- Skills were discovered from *any* `skills/` directory anywhere in the
tree, and `.mcp.json` from any depth.

Resolving the repo root produced six instruction sources for what was
effectively one file of guidance, plus skills/MCP found by an open-ended
walk.

## What changed

Replace the recursive scan with **fixed-location, shallow** discovery.
Each scan root is inspected at its top level only: the resolver never
descends into subdirectories and never climbs to a parent. Additional
directories are added explicitly as sources (HTTP API) or via the
`CODER_AGENT_EXP_*_DIRS` seeding env vars.

- **Single working-dir scan root.** The working directory is one scan
root. Instruction files and `.mcp.json` are read only at its top level.
- **Fixed-location skills.** Skills are discovered only from `skills`,
`.agents/skills`, `.claude/skills`, `.codex/skills` (one skill per
immediate subdir with a `SKILL.md`), not from arbitrary `skills/`
directories.
- **Case-sensitive instruction names.** Exact
`AGENTS.md`/`CLAUDE.md`/`.cursorrules`; a lower-case `agents.md` is
ignored.
- **Symlink dedup.** Resources are attributed to their resolved target,
so symlinked `CLAUDE.md`/`.cursorrules` collapse into the single
`AGENTS.md`.
- **Watcher** mirrors the same fixed-location set instead of recursively
watching every scan root (no more walking `node_modules`).
- The recursive `walkDir`, `skipDirNames`, `MaxScanDepth`, and
`isSkillsContainer` are removed.

Resolving the repo root now yields `AGENTS.md`, `.mcp.json`, and the
`.agents/skills`/`.claude/skills` skills, with no nested
instruction-file noise.

## Behavior change

`site/AGENTS.md` is no longer auto-injected when the working dir is the
repo root. It loads when the working dir **is** `site/` (its top level),
or when `site/` is added as an explicit source. There is intentionally
**no walk-up** to a `.git` project root: an agent started in a
subdirectory does not auto-inherit ancestor `AGENTS.md`; those
directories are added explicitly.

<details>
<summary>Verification &amp; decision log</summary>

**codex research (confirmed via source).** Instruction files: codex
walks up to the first `.git` ancestor and reads root-&gt;cwd, one file
per directory, exact-cased names (`codex-rs/core/src/agents_md.rs`).
Skills: fixed roots (`.agents/skills`, `.codex/skills`,
`$CODEX_HOME/skills`, ...) with bounded in-root recursion
(`core-skills/src/loader.rs`). MCP: `.codex/config.toml` via walk-up; a
project `.mcp.json` is not a runtime source in codex. No resource type
triggers an unbounded downward walk.

**Decisions.**
- Adopt codex's fixed-location, shallow discovery (case-sensitive names,
symlink dedup, top-level-only files, container-only skills).
- **Deliberately omit codex's walk-up to the `.git` project root.** In
Coder the working dir is the scan root and extra directories are added
explicitly (HTTP API / `CODER_AGENT_EXP_*_DIRS`), so the implicit
ancestor climb added surprise without benefit (e.g. `context add ./site`
should scan `./site`, not the repo root).
- Keep `.mcp.json` (codex uses `config.toml`, intentionally not added).
- Include `.claude/skills` and `.codex/skills` in the container list so
the repo's existing `.claude/skills` skills are not regressed; skills
recurse one level inside a container.

**Tests.** `TestManager_WorkingDirScannedShallow` (working dir read at
top level; ancestor root and nested subdir both excluded);
`TestResolver_SkillsOnlyFromFixedContainers`,
`TestResolver_MCPConfigOnlyAtScanRoot`,
`TestResolver_SymlinkedInstructionFilesDeduplicated`,
`TestResolver_InstructionFilesOnlyAtScanRoot`,
`TestResolver_InstructionNamesAreCaseSensitive`. Cap tests use multiple
scan roots.

**Local checks.** `gofmt`, `go vet`, `golangci-lint`, `go test -race`,
and `make lint/emdash` pass for the package.
</details>

---
🤖 Generated by Coder Agents on behalf of @kylecarbs.
2026-06-23 03:42:40 +00:00
Kyle Carberry 2f8bba792a feat: push MCP server context and tools from agentcontext (#26533)
## What

Live MCP servers and their tools now flow into the `agentcontext`
snapshot and are pushed to coderd via `PushContextState`, stored
alongside instruction files and skills. Previously the resolver's MCP
seam was unimplemented, so live MCP tool lists never reached the pushed
snapshot.

`agentcontext` is now **fully self-contained** for MCP: it connects to
the MCP servers declared in the `.mcp.json` files its own watcher
already discovers, lists their tools, and emits `KindMCPServer`
resources. It does **not** depend on or modify `agent/x/agentmcp` — that
package is left pristine and keeps serving the agent's MCP HTTP API. The
two MCP paths run independently, which means the legacy package can be
deleted later without touching this code.

## How

- **Self-contained runner** (`agentcontext/mcprunner.go`): a one-shot
MCP client (connect → initialize → list tools → close) with its own
`.mcp.json` parser. A Manager goroutine (`runMCPSync`) reloads it
whenever the discovered `KindMCPConfig` `path:contenthash` set changes,
then re-resolves so the new tools are published. Per-server connects run
in parallel (bounded) with a per-server timeout; a server that fails to
connect is recorded as a failure rather than aborting the batch. Each
connect also force-kills its subprocess on close, because mcp-go's stdio
`Close()` closes stdin and then blocks on `cmd.Wait()` with no kill — a
server that ignores stdin-close would otherwise stall the whole reload
loop.
- **Resource production** (`agentcontext/mcp.go`):
`buildMCPServerResources` turns the runner's non-blocking per-server
snapshot into `KindMCPServer` resources. Connected servers carry their
sorted tools (`StatusOK`); failed servers surface as `StatusUnreadable`
issues instead of vanishing; connected-but-no-tools-yet are skipped
until a later reload. The content hash is tool-set sensitive. The
resolver consumes this through a plain `MCPResources func() []Resource`
field (no `MCPProvider` interface).
- **Tool names**: emitted exactly as the server reports them. Flattening
into a single namespace (e.g. `server__tool`) is left to the control
plane in the next step, since each resource already carries the server
name.
- **Drift**: MCP resources are excluded from the snapshot
aggregate/drift hash (`driftResources`). MCP servers connect
asynchronously after boot; without this, a server finishing its connect
would dirty every hydrated chat even though nothing the user pinned
changed.
- **Wiring** (`agent.go`): the manager is given
`ManagerOptions.MCPExecer`/`MCPUpdateEnv`; `agent/x/agentmcp` is
untouched.
- **Config validation**: a structurally broken `.mcp.json` surfaces as
`StatusInvalid` rather than silently dropping all its servers.

coderd already persists `mcp_server`/`mcp_config` resource bodies
(including tools), so no coderd or proto changes were required.

## Testing

- **Unit**: `buildMCPServerResources` (grouping/sort/skip/failed/hash
sensitivity), MCP resources applied via the resolver seam, MCP exclusion
from the aggregate hash, `.mcp.json` parsing (transport inference, env
expansion), `toolInputSchema`, and `mcpConfigSet` change detection.
- **Proto serialization**
(`TestDRPCPusher_HappyPathSerializesAllFields`): a `KindMCPServer`
resource (tools + input schema) round-trips through `PushContextState`
into the `MCPServerBody` wire form, asserting the server name, tool
name/description, and the decoded `input_schema`.
- **Manager-level, real subprocess**
(`TestManager_MCPServerToolsInSnapshot`): a `.mcp.json` points at a
re-exec'd fake stdio MCP server; the runner connects it and its `echo`
tool surfaces as a `KindMCPServer` resource in the Manager snapshot —
the same snapshot pushed to coderd — exercising `runMCPSync` and the
resolver wiring end to end.
- **Regression** (`TestManager_MCPServerHangingCloseDoesNotStall`): the
fake server ignores stdin-close; the test asserts its tool still
surfaces, proving the runner force-kills the subprocess instead of
stalling the reload. Verified to fail without the fix.
- All pass under `-race`; `go build ./...`, `go vet`, and
`golangci-lint` are clean on the touched packages.

## Scope / follow-ups

This is the agent-side production+push half. The chatd consumer (reading
the pinned MCP resources for prompt/tool injection, including any
server-prefix flattening of tool names) and removing the legacy
`workspaceMCPToolsCache` pull path remain follow-ups, per the RFC
rollout. While both `agent/x/agentmcp` and `agentcontext` exist, stdio
MCP servers are spawned by both; this is intentional and temporary until
`agentmcp` is removed.

<details>
<summary>Implementation plan and decisions</summary>

**Goal:** produce live MCP server resources (with tools) from
`agentcontext` and push them to coderd.

**Starting state (main):** proto (`PushContextState`, `MCPServerBody`,
`MCPTool`), the drpc adapter, coderd storage
(`workspace_agent_context_resources`, body kind `mcp_server`), and the
resolver's MCP seam already existed; nothing implemented the seam or fed
live tools into the snapshot.

**Decision (agentcontext fully separate from agentmcp):** `agentcontext`
starts and lists its own MCP servers using only the connect-and-list
half of an mcp-go client, driven by the `.mcp.json` files its existing
watcher discovers. It shares no state with `agent/x/agentmcp` and does
not import it. Two earlier revisions of this branch were discarded: (1)
relocating `agentmcp` into `agentcontext` (rejected — it duplicates
config parsing and file watching `agentcontext` already does); (2)
reading `agentmcp`'s cached server snapshot via new accessors (rejected
— unnecessary coupling between two packages that should simply run
independently while one is being retired). The temporary double-spawn of
stdio servers is the accepted cost of keeping the two paths cleanly
separated until `agentmcp` is removed.

**Decision (no tool-name prefixing, no MCPProvider interface):** the
agent pushes raw, unflattened data — server name plus verbatim tool
names — and lets the control plane own any `server__tool` flattening.
With a single self-contained producer, the `MCPProvider` interface was
collapsed into a `func() []Resource` field on the resolver.

**Invariants held:** no secrets (env/headers) in pushed resources, only
server/tool metadata; MCP excluded from the drift hash; the seam is
non-blocking so the resolver never stalls on MCP I/O.

</details>

---

*This PR was created by Coder Agents on behalf of @kylecarbs.*
2026-06-21 16:31:05 -06:00
Kyle Carberry b439b06ee6 feat: persist agent-pushed workspace context snapshots in coderd (#26145)
Replaces the v2.10 `PushContextState` stub with a real coderd write
path. Phase 1 of the chat-side persistence story; nothing reads these
rows yet.

Follows [#25983](https://github.com/coder/coder/pull/25983) and unblocks
[CODAGT-569](https://linear.app/codercom/issue/CODAGT-569/enable-agent-api-v210-pushcontextstate-bump-currentminor-wire-coderd).

## What ships

### Schema (`000517_workspace_agent_context.{up,down}.sql`)

Two new tables plus `api_key_scope` enum extensions:

- `workspace_agent_context_snapshots` (PK `workspace_agent_id` to
`workspace_agents(id) ON DELETE CASCADE`): one row per agent,
overwritten per push. Holds `version`, `schema_version`,
`aggregate_hash`, `snapshot_error`, `received_at`.
- `workspace_agent_context_resources` (PK `(workspace_agent_id,
source)`): per-resource state. `body_kind` and `status` are `TEXT` +
`CHECK` so adding new wire kinds (the RFC's reserved
PLUGIN/HOOK/SUBAGENT/COMMAND) is a one-line CHECK update plus a Go
switch case.

### SQLC queries (`coderd/database/queries/workspaceagentcontext.sql`)

- `UpsertWorkspaceAgentContextSnapshot`
- `UpsertWorkspaceAgentContextResource`
- `DeleteStaleWorkspaceAgentContextResources`
(delete-where-source-not-in)
- `GetLatestWorkspaceAgentContextSnapshot`
- `ListWorkspaceAgentContextResources`

### Handler (`coderd/agentapi/context.go`)

`ContextAPI` is a new sub-API. `PushContextState`:

1. Rejects `schema_version > 1` with a non-`Unimplemented` error so a
forward-incompatible agent fails loudly during rollout instead of
slipping into the permanent fallback path the `Unimplemented`
translation reserves for old coderd deployments.
2. Validates resources: no empty/duplicate sources, every variant maps
to a known body kind, every status maps to a known enum value, the
`Body` oneof is set (even when status is non-OK, mirroring the wire
guarantee so coderd can attribute failures to a known kind).
3. Inside `Database.InTx`, reads the existing snapshot. If the push is
not `initial` and `version` is not strictly greater, returns `accepted =
false` and leaves stored state untouched. Otherwise upserts the snapshot
row, upserts each resource, then runs the stale-source prune so the
snapshot and resource rows always agree.
4. Returns `accepted = true` on success.

Resource bodies are stored as `protojson(body oneof variant)` in `body
JSONB` with `body_kind` as the discriminator. Adding a new field to an
existing variant is zero work since `protojson` tolerates new fields;
adding a new variant is a CHECK + switch case.

### RBAC + dbauthz

- New `ResourceWorkspaceAgentContext` (Create/Read/Update/Delete).
- New `SubjectTypeAgentContext` plus `subjectAgentContext` system role
and `dbauthz.AsAgentContext` helper. The push handler elevates to this
subject; the agent's own role does not get direct write access to the
table.
- New `workspace_agent_context:*` API key scopes registered in the enum
migration; internal-only (not added to `externalLowLevel`).

### Audit

These rows are agent-pushed state, not user-authored. They are
intentionally not added to `AuditActionMap` and not enumerated in
`enterprise/audit/table.go`, matching `boundary_logs`,
`workspace_agent_memory_resource_monitor`, etc. `enterprise/audit` tests
pass unchanged.

## Tests

- `coderd/agentapi/context_test.go`: 12 subtests covering
accepts/rejects (schema version, empty/duplicate source, unknown status,
missing body), version semantics (stale dropped, same-version replay
dropped, `initial=true` overwrites lower version), variant coverage,
non-OK status persistence, and the empty-active-set prune case.
- `coderd/database/dbauthz/dbauthz_test.go`: 5 `MethodTestSuite` cases
covering the new queries.
- `coderd/rbac/roles_test.go`: `WorkspaceAgentContext` permission row
asserting no human role currently has access.
-
`coderd/database/migrations/testdata/fixtures/000517_workspace_agent_context.up.sql`:
one snapshot + one resource per known body kind plus a non-OK status, so
the migration test suite never lands with these tables empty.

## Out of scope (later phases)

- Chat hydration (`chats.context_aggregate_hash`,
`last_injected_context`).
- Dirty-bit fan-out and `PUT /chats/{id}/context`.
- Agent-side `POST /api/v0/context/resync` barrier and the `coder exp
chat context` CLI.
- `codersdk` chat-context wire types and the dashboard Sources drawer.
- Removal of the chatd per-turn pull fallback.

## Compat property

This is a pure write path. If anything here returns errors the agent's
`RunPush` loop backs off, no chat behavior changes, and the workspace
keeps behaving exactly like it did before v2.10.

<details>
<summary>Implementation plan and decision log</summary>

Key design calls:

1. **Concurrency**: Accept iff `req.Initial || req.Version >
existing.Version`. The strict RFC reading ("version comparison is
authoritative") locks restarted agents out because their per-process
counter resets to 1; honoring `initial=true` reflects the real reboot
reality while still rejecting steady-state replays/out-of-order pushes.
2. **Body encoding**: `protojson` over the oneof variant body proto,
stored in JSONB with `body_kind` discriminator. Structured at the API/Go
layer, schema-tolerant at the storage layer, and Phase 2 readers
round-trip back via `protojson.Unmarshal`.
3. **Schema version rejection**: returns a normal error, not
`Unimplemented`. The agent's `RunPush` loop only short-circuits on
`Unimplemented`; that escape hatch is reserved for old coderd
deployments. A forward-incompatible agent should retry-and-back-off, not
flip the connection into permanent fallback.
4. **Validation strictness**: empty sources, duplicate sources,
`STATUS_UNSPECIFIED`, and missing `Body` oneof variants are rejected
before any write so a misbehaving agent cannot poison the snapshot
table. Phase 2 readers can trust every row maps to a known proto
variant.

</details>

_This PR was authored by Coder Agents on Kyle Carberry's behalf._
2026-06-15 09:38:52 -07:00
Kyle Carberry cd3692c0c2 feat: add agent-side workspace context sources and Agent API v2.10 PushContextState (#25983)
Adds the agent half of the workspace context sources RFC. The agent now
resolves instruction files, skills, and MCP configs into a typed
`Snapshot`, watches the relevant paths recursively, exposes the source
list over a workspace-agent HTTP API, and pushes each `Snapshot` to
coderd over a new `PushContextState` RPC on Agent API v2.10.

The coderd-side handler is a stub returning `Unimplemented` for now.
Real persistence to `workspace_agent_context`, chatd hydration on dirty
events, and the `KindMCPServer` MCP provider are tracked by
[CODAGT-569](https://linear.app/codercom/issue/CODAGT-569/enable-agent-api-v210-pushcontextstate-bump-currentminor-wire-coderd).
This matches the pattern used for v2.7 `ReportBoundaryLogs` in
[#21293](https://github.com/coder/coder/pull/21293), which bumped the
version and shipped a stub server so the wire and client could iterate
before the persistence layer landed.

## What ships

### agent/agentcontext (new package)

- `Source`, `Resource` (kinds `instruction_file`, `skill`, `mcp_config`,
`mcp_server` plus reserved `plugin`/`hook`/`subagent`/`command`),
`ResourceStatus`, `Snapshot`, `ComputeAggregateHash`.
- `Manager` owns the in-memory source list, performs the initial resolve
synchronously in `NewManager`, runs a re-resolve/watcher loop in `Run`,
exposes
`AddSource`/`RemoveSource`/`Sources`/`HasSource`/`Snapshot`/`SubscribeChanges`/`Resync`/`SeedSources`/`Close`.
- `Resolver` walks scan roots, classifies recognized files, enforces 64
KiB per-resource, 2 MiB aggregate, and 500-resource caps with
`StatusOversize`/`StatusExcluded`/`StatusUnreadable`/`StatusInvalid`
outcomes, skips `node_modules`/`vendor`/etc., validates symlink targets
stay inside the scan root, stamps `SourcePath` on user-derived
resources, and optionally pulls MCP server tool lists via an
`MCPProvider` interface. MCP config resources ship metadata only (size,
hash) so secrets in env blocks never leave the agent.
- `Watcher` is a recursive `fsnotify` wrapper with a 250 ms debounce,
dynamic arming of newly created directories, and an ENOSPC-tolerant
degraded mode that no-ops further syncs until the manager resyncs
explicitly.
- HTTP API for `GET/POST /sources`, `GET/DELETE /sources/{path}`, `POST
/resync` mounted at `/api/v0/context`.
- `Pusher` interface plus `RunPush` goroutine with exponential backoff
capped at 30 s. `DRPCPusher` adapts the generated `DRPCAgentClient210`
to `Pusher` and translates `drpcerr.Unimplemented` to
`ErrPushUnimplemented` so the push loop exits cleanly when talking to
coderd deployments that have not enabled the real handler.

### agent/proto (v2.10)

- New messages `ContextResource`, `PushContextStateRequest`,
`PushContextStateResponse` and the `PushContextState` RPC on `service
Agent`.
- Generated `DRPCAgentClient210` interface and
`codersdk/agentsdk.Client.ConnectRPC210` / `ConnectRPC210WithRole`.
- `tailnet/proto.CurrentMinor` bumped from `9` to `10`.

### Agent wiring

- `agent.Options.Client` declares both v2.9 and v2.10 connectors;
`run()` dials with `ConnectRPC210WithRole`.
- `apiConnRoutineManager` holds a `DRPCAgentClient210`. Existing v2.8
routines keep their narrower `DRPCAgentClient28` signature thanks to
interface embedding.
- `startAgentAPI210` is the v2.10 counterpart to `startAgentAPI` for
routines that need the new client. The push context state routine uses
it.
- A `contextManager` is constructed in `agent.init()`, seeded from the
existing `CODER_AGENT_EXP_*_DIRS` env vars, started in its own goroutine
under `gracefulCtx`, and closed in `agent.Close`.
- `handleManifest` calls `Manager.SeedSources` for sources rooted at the
manifest directory, then `Resync` after `manifest.Swap`, so the snapshot
reflects the workspace working directory immediately instead of waiting
for the next filesystem event.
- HTTP routes mounted at `/api/v0/context` when the manager is up.

### Coderd stub

`coderd/agentapi/context.go` returns `drpcerr.Unimplemented` for
`PushContextState`. The real handler that persists
`workspace_agent_context` rows, hydrates chats, and emits dirty events
lives in CODAGT-569.

## Tests

24 tests across `agent/agentcontext` cover types, paths, resolver
behavior with file caps, skill containers, MCP secret omission, symlink
target validation, the recursive watcher firing on real fsnotify events,
manager source CRUD / `Resync` / `SeedSources` / `Run` lifetime, the
HTTP API, the DRPC adapter, and the push retry / initial-flag /
unimplemented paths. Passes `go test -race -count=2`.

`TestAgent_ContextStatePushed` boots a full agent against
`agenttest.FakeAgentAPI` (which now records `PushContextState` traffic)
and asserts the seeded `AGENTS.md` appears in a snapshot push with
`schema_version = 1`.

<details>
<summary>Notes for reviewers</summary>

- Source CRUD is workspace-agent-token only; coderd is not in the path
for source mutation.
- Per-resource cap 64 KiB, aggregate 2 MiB, count cap 500; resources
past the cap ship with `StatusExcluded` and an empty payload so the
aggregate hash still detects content edits. MCP-emitted resources
enforce both a per-provider count cap and the aggregate byte cap.
- Symlinks inside the scan root are followed; symlinks pointing outside
(or broken) are rejected with `StatusExcluded` so credentials reachable
via a stray symlink stay off the wire.
- The initial push gates `lifecycle = ready` in the eventual full
design. For this PR the `SeedSources` plus `handleManifest`-driven
`Resync` keeps the snapshot fresh; the live push loop ships now and
DRPCPusher translates the coderd `Unimplemented` stub into a clean exit.
- The `PLUGIN`/`HOOK`/`SUBAGENT`/`COMMAND` kinds are reserved in proto
and Go enums but unused; the Claude Code plugin resolver ships in a
follow-up that does not need a schema migration.
- Two follow-ups remain, both tracked by
[CODAGT-569](https://linear.app/codercom/issue/CODAGT-569/enable-agent-api-v210-pushcontextstate-bump-currentminor-wire-coderd):
(1) the chatd-side handler that persists snapshots and dirties chats;
(2) the `coder exp chat context` CLI command set for
`list`/`show`/`add`/`remove`/`refresh`.

</details>

_This PR was authored by Coder Agents on Kyle Carberry's behalf._
2026-06-08 12:08:40 -07:00