mirror of
https://github.com/coder/coder.git
synced 2026-09-23 22:20:22 +08:00
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._
205 lines
6.0 KiB
Go
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
|
|
}
|