mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +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._
179 lines
5.7 KiB
Go
179 lines
5.7 KiB
Go
package agentcontext
|
|
|
|
import (
|
|
"context"
|
|
|
|
"golang.org/x/xerrors"
|
|
"google.golang.org/protobuf/types/known/structpb"
|
|
"storj.io/drpc/drpcerr"
|
|
|
|
agentproto "github.com/coder/coder/v2/agent/proto"
|
|
)
|
|
|
|
// DRPCPusher adapts a generated DRPCAgentClient to the
|
|
// agentcontext.Pusher interface. The adapter is the only place
|
|
// that knows about the wire protobuf types; the rest of the
|
|
// package operates on the Go Snapshot/Resource value types.
|
|
//
|
|
// Use NewDRPCPusher to construct an instance. The pusher's
|
|
// behavior is identical to invoking PushContextState directly:
|
|
// per-request retries are handled by Manager.RunPush.
|
|
type DRPCPusher struct {
|
|
client agentproto.DRPCAgentClient210
|
|
}
|
|
|
|
// NewDRPCPusher wraps the supplied drpc client. The client must
|
|
// implement the v2.10 Agent API.
|
|
func NewDRPCPusher(client agentproto.DRPCAgentClient210) *DRPCPusher {
|
|
return &DRPCPusher{client: client}
|
|
}
|
|
|
|
// PushContextState satisfies the Pusher interface.
|
|
//
|
|
// drpc returns an Unimplemented error when the peer's service
|
|
// definition does not include the RPC. The adapter translates
|
|
// that into ErrPushUnimplemented so RunPush stops gracefully
|
|
// when an old coderd is on the other end.
|
|
func (p *DRPCPusher) PushContextState(ctx context.Context, req *PushRequest) (*PushResponse, error) {
|
|
if p == nil || p.client == nil {
|
|
return nil, xerrors.New("agentcontext: DRPCPusher has no client")
|
|
}
|
|
resp, err := p.client.PushContextState(ctx, pushRequestToProto(req))
|
|
if err != nil {
|
|
if drpcerr.Code(err) == drpcerr.Unimplemented {
|
|
return nil, ErrPushUnimplemented
|
|
}
|
|
return nil, err
|
|
}
|
|
return &PushResponse{Accepted: resp.GetAccepted()}, nil
|
|
}
|
|
|
|
// pushRequestToProto converts the Go push payload to its
|
|
// generated protobuf equivalent. The Kind on each Resource
|
|
// selects which body variant of the proto oneof is set; a body
|
|
// is always set (zero-valued if necessary) so coderd can tell
|
|
// the kind even when Status != OK.
|
|
func pushRequestToProto(req *PushRequest) *agentproto.PushContextStateRequest {
|
|
pb := &agentproto.PushContextStateRequest{
|
|
Version: req.Version,
|
|
AggregateHash: append([]byte(nil), req.AggregateHash[:]...),
|
|
Initial: req.Initial,
|
|
SchemaVersion: req.SchemaVersion,
|
|
SnapshotError: req.SnapshotError,
|
|
Resources: make([]*agentproto.ContextResource, 0, len(req.Resources)),
|
|
}
|
|
for i := range req.Resources {
|
|
r := req.Resources[i]
|
|
entry := &agentproto.ContextResource{
|
|
Source: r.Source,
|
|
ContentHash: append([]byte(nil), r.ContentHash[:]...),
|
|
Status: resourceStatusToProto(r.Status),
|
|
SizeBytes: r.SizeBytes,
|
|
Error: r.Error,
|
|
}
|
|
setResourceBody(entry, r)
|
|
if r.SourcePath != "" {
|
|
sp := r.SourcePath
|
|
entry.SourcePath = &sp
|
|
}
|
|
pb.Resources = append(pb.Resources, entry)
|
|
}
|
|
return pb
|
|
}
|
|
|
|
// setResourceBody picks the proto oneof variant for r's Kind and
|
|
// populates the kind-specific fields from r. A body is set even
|
|
// when status is not OK so coderd can attribute the failure to a
|
|
// known kind. Unknown kinds leave the body unset; the recipient
|
|
// can surface that as "kind not recognized".
|
|
func setResourceBody(entry *agentproto.ContextResource, r Resource) {
|
|
switch r.Kind {
|
|
case KindInstructionFile:
|
|
entry.Body = &agentproto.ContextResource_InstructionFile{
|
|
InstructionFile: &agentproto.InstructionFileBody{
|
|
Content: append([]byte(nil), r.Payload...),
|
|
},
|
|
}
|
|
case KindSkill:
|
|
entry.Body = &agentproto.ContextResource_Skill{
|
|
Skill: &agentproto.SkillMetaBody{
|
|
Meta: append([]byte(nil), r.Payload...),
|
|
Name: r.Name,
|
|
Description: r.Description,
|
|
},
|
|
}
|
|
case KindMCPConfig:
|
|
// MCPConfigBody is intentionally empty: secrets in env
|
|
// blocks must not leave the agent.
|
|
entry.Body = &agentproto.ContextResource_McpConfig{
|
|
McpConfig: &agentproto.MCPConfigBody{},
|
|
}
|
|
case KindMCPServer:
|
|
entry.Body = &agentproto.ContextResource_McpServer{
|
|
McpServer: &agentproto.MCPServerBody{
|
|
ServerName: serverNameOrSource(r),
|
|
Description: r.Description,
|
|
Tools: mcpToolsToProto(r.Tools),
|
|
},
|
|
}
|
|
}
|
|
}
|
|
|
|
// serverNameOrSource returns r.Name when populated and falls
|
|
// back to r.Source so providers that have not yet adopted the
|
|
// Name field still produce a usable wire value.
|
|
func serverNameOrSource(r Resource) string {
|
|
if r.Name != "" {
|
|
return r.Name
|
|
}
|
|
return r.Source
|
|
}
|
|
|
|
// mcpToolsToProto converts the Go MCPTool slice to its wire
|
|
// representation. InputSchema is marshaled via structpb.NewStruct;
|
|
// schemas that fail to convert are dropped from the wire copy
|
|
// (the resource ContentHash still detects the change) and the
|
|
// tool ships with InputSchema unset rather than failing the
|
|
// whole push.
|
|
func mcpToolsToProto(in []MCPTool) []*agentproto.MCPTool {
|
|
if len(in) == 0 {
|
|
return nil
|
|
}
|
|
out := make([]*agentproto.MCPTool, 0, len(in))
|
|
for _, t := range in {
|
|
entry := &agentproto.MCPTool{
|
|
Name: t.Name,
|
|
Description: t.Description,
|
|
}
|
|
if len(t.InputSchema) > 0 {
|
|
if s, err := structpb.NewStruct(t.InputSchema); err == nil {
|
|
entry.InputSchema = s
|
|
}
|
|
}
|
|
out = append(out, entry)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// resourceStatusToProto maps a ResourceStatus to its proto enum.
|
|
func resourceStatusToProto(s ResourceStatus) agentproto.ContextResource_Status {
|
|
switch s {
|
|
case StatusOK:
|
|
return agentproto.ContextResource_OK
|
|
case StatusOversize:
|
|
return agentproto.ContextResource_OVERSIZE
|
|
case StatusUnreadable:
|
|
return agentproto.ContextResource_UNREADABLE
|
|
case StatusInvalid:
|
|
return agentproto.ContextResource_INVALID
|
|
case StatusExcluded:
|
|
return agentproto.ContextResource_EXCLUDED
|
|
default:
|
|
return agentproto.ContextResource_STATUS_UNSPECIFIED
|
|
}
|
|
}
|
|
|
|
// Ensure DRPCPusher continues to satisfy the Pusher interface
|
|
// even if the interface gains methods in the future.
|
|
var _ Pusher = (*DRPCPusher)(nil)
|