Files
coder/agent/agentcontext/api.go
T
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

205 lines
6.0 KiB
Go

package agentcontext
import (
"context"
"encoding/hex"
"errors"
"net/http"
"net/url"
"strconv"
"github.com/go-chi/chi/v5"
"github.com/coder/coder/v2/coderd/httpapi"
"github.com/coder/coder/v2/codersdk"
)
// SourceResponse is the on-wire representation of a Source.
// Matches the path-only RFC schema; future additions (tags,
// labels) can land additively without breaking clients.
type SourceResponse struct {
Path string `json:"path"`
}
// SourceRequest is the request body for POST /sources.
type SourceRequest struct {
Path string `json:"path"`
}
// SnapshotResource is the on-wire representation of a Resource.
// Payloads are omitted; clients that need the bytes go through
// the drpc PushContextState path.
type SnapshotResource struct {
ID string `json:"id"`
Kind string `json:"kind"`
Source string `json:"source"`
SourcePath string `json:"source_path,omitempty"`
ContentHash string `json:"content_hash"`
SizeBytes uint64 `json:"size_bytes"`
Status string `json:"status"`
Error string `json:"error,omitempty"`
Description string `json:"description,omitempty"`
}
// SnapshotResponse is the on-wire representation of a Snapshot
// returned by the resync endpoint.
type SnapshotResponse struct {
Version uint64 `json:"version"`
SchemaVersion uint64 `json:"schema_version"`
AggregateHash string `json:"aggregate_hash"`
Resources []SnapshotResource `json:"resources"`
PayloadBytes uint64 `json:"payload_bytes"`
SnapshotError string `json:"snapshot_error,omitempty"`
}
// API exposes the Manager over HTTP. The routes match the RFC:
//
// GET /api/v0/context/sources
// POST /api/v0/context/sources { path }
// GET /api/v0/context/sources/{path}
// DELETE /api/v0/context/sources/{path}
// POST /api/v0/context/resync
//
// {path} is URL-encoded canonical path. Callers pass either the
// canonical or original path; the handler canonicalizes before
// matching.
type API struct {
manager *Manager
}
// NewAPI wraps the supplied Manager.
func NewAPI(m *Manager) *API {
return &API{manager: m}
}
// Routes returns the chi handler for /api/v0/context/*. Mount
// it at "/api/v0/context".
func (a *API) Routes() http.Handler {
r := chi.NewRouter()
r.Route("/sources", func(r chi.Router) {
r.Get("/", a.handleListSources)
r.Post("/", a.handleAddSource)
r.Get("/{path}", a.handleGetSource)
r.Delete("/{path}", a.handleRemoveSource)
})
r.Post("/resync", a.handleResync)
return r
}
func (a *API) handleListSources(rw http.ResponseWriter, r *http.Request) {
sources := a.manager.Sources()
out := make([]SourceResponse, 0, len(sources))
for _, s := range sources {
out = append(out, SourceResponse(s))
}
httpapi.Write(r.Context(), rw, http.StatusOK, out)
}
func (a *API) handleAddSource(rw http.ResponseWriter, r *http.Request) {
var req SourceRequest
if !httpapi.Read(r.Context(), rw, r, &req) {
return
}
s, err := a.manager.AddSource(Source(req))
if err != nil {
httpapi.Write(r.Context(), rw, http.StatusBadRequest, codersdk.Response{
Message: "Could not add context source.",
Detail: err.Error(),
})
return
}
httpapi.Write(r.Context(), rw, http.StatusCreated, SourceResponse(s))
}
func (a *API) handleGetSource(rw http.ResponseWriter, r *http.Request) {
raw := chi.URLParam(r, "path")
decoded, err := url.PathUnescape(raw)
if err != nil {
httpapi.Write(r.Context(), rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid context source path.",
Detail: err.Error(),
})
return
}
canonical, ok := a.manager.HasSource(decoded)
if !ok {
httpapi.Write(r.Context(), rw, http.StatusNotFound, codersdk.Response{
Message: "Context source not found.",
Detail: "No source registered for path " + strconv.Quote(decoded) + ".",
})
return
}
httpapi.Write(r.Context(), rw, http.StatusOK, SourceResponse{Path: canonical})
}
func (a *API) handleRemoveSource(rw http.ResponseWriter, r *http.Request) {
raw := chi.URLParam(r, "path")
decoded, err := url.PathUnescape(raw)
if err != nil {
httpapi.Write(r.Context(), rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid context source path.",
Detail: err.Error(),
})
return
}
if err := a.manager.RemoveSource(decoded); err != nil {
if errors.Is(err, ErrSourceNotFound) {
httpapi.Write(r.Context(), rw, http.StatusNotFound, codersdk.Response{
Message: "Context source not found.",
Detail: err.Error(),
})
return
}
httpapi.Write(r.Context(), rw, http.StatusBadRequest, codersdk.Response{
Message: "Could not remove context source.",
Detail: err.Error(),
})
return
}
rw.WriteHeader(http.StatusNoContent)
}
func (a *API) handleResync(rw http.ResponseWriter, r *http.Request) {
snap, err := a.manager.Resync(r.Context())
if err != nil {
status := http.StatusInternalServerError
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
status = http.StatusGatewayTimeout
}
httpapi.Write(r.Context(), rw, status, codersdk.Response{
Message: "Resync failed.",
Detail: err.Error(),
})
return
}
httpapi.Write(r.Context(), rw, http.StatusOK, snapshotResponse(snap))
}
// snapshotResponse converts a Snapshot to its on-wire form for
// the resync endpoint. Payloads are omitted; the per-resource
// payload bytes ship via the drpc PushContextState path.
func snapshotResponse(s Snapshot) SnapshotResponse {
out := SnapshotResponse{
Version: s.Version,
SchemaVersion: s.SchemaVersion,
AggregateHash: hex.EncodeToString(s.AggregateHash[:]),
Resources: make([]SnapshotResource, 0, len(s.Resources)),
PayloadBytes: s.PayloadBytes,
SnapshotError: s.SnapshotError,
}
for _, r := range s.Resources {
out.Resources = append(out.Resources, SnapshotResource{
ID: r.ID,
Kind: r.Kind.String(),
Source: r.Source,
SourcePath: r.SourcePath,
ContentHash: hex.EncodeToString(r.ContentHash[:]),
SizeBytes: r.SizeBytes,
Status: r.Status.String(),
Error: r.Error,
Description: r.Description,
})
}
return out
}