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._
198 lines
6.7 KiB
Go
198 lines
6.7 KiB
Go
package agentcontext_test
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/require"
|
|
"storj.io/drpc/drpcerr"
|
|
|
|
"github.com/coder/coder/v2/agent/agentcontext"
|
|
agentproto "github.com/coder/coder/v2/agent/proto"
|
|
)
|
|
|
|
// fakeDRPCClient stubs out the DRPCAgentClient210 surface for
|
|
// the parts of the interface the adapter exercises. Only
|
|
// PushContextState is implemented; every other method panics
|
|
// because the adapter never calls them.
|
|
type fakeDRPCClient struct {
|
|
agentproto.DRPCAgentClient210
|
|
lastReq *agentproto.PushContextStateRequest
|
|
resp *agentproto.PushContextStateResponse
|
|
err error
|
|
}
|
|
|
|
func (f *fakeDRPCClient) PushContextState(_ context.Context, req *agentproto.PushContextStateRequest) (*agentproto.PushContextStateResponse, error) {
|
|
f.lastReq = req
|
|
if f.err != nil {
|
|
return nil, f.err
|
|
}
|
|
if f.resp == nil {
|
|
return &agentproto.PushContextStateResponse{Accepted: true}, nil
|
|
}
|
|
return f.resp, nil
|
|
}
|
|
|
|
func TestDRPCPusher_HappyPathSerializesAllFields(t *testing.T) {
|
|
t.Parallel()
|
|
client := &fakeDRPCClient{}
|
|
pusher := agentcontext.NewDRPCPusher(client)
|
|
|
|
req := &agentcontext.PushRequest{
|
|
Version: 7,
|
|
AggregateHash: [32]byte{0xaa, 0xbb, 0xcc},
|
|
Initial: true,
|
|
SchemaVersion: 1,
|
|
SnapshotError: "watcher degraded",
|
|
Resources: []agentcontext.Resource{
|
|
{
|
|
ID: "instruction_file:/tmp/AGENTS.md",
|
|
Kind: agentcontext.KindInstructionFile,
|
|
Source: "/tmp/AGENTS.md",
|
|
ContentHash: [32]byte{0x01, 0x02},
|
|
Payload: []byte("body"),
|
|
SizeBytes: 4,
|
|
Status: agentcontext.StatusOK,
|
|
Description: "tagline",
|
|
SourcePath: "/tmp",
|
|
},
|
|
{
|
|
ID: "skill:/tmp/.agents/skills/foo",
|
|
Kind: agentcontext.KindSkill,
|
|
Source: "/tmp/.agents/skills/foo",
|
|
Status: agentcontext.StatusInvalid,
|
|
Error: "bad frontmatter",
|
|
SizeBytes: 99,
|
|
},
|
|
{
|
|
ID: "skill:/tmp/.agents/skills/code-review",
|
|
Kind: agentcontext.KindSkill,
|
|
Source: "/tmp/.agents/skills/code-review",
|
|
ContentHash: [32]byte{0x03},
|
|
Payload: []byte("---\nname: code-review\n---\nbody\n"),
|
|
SizeBytes: 31,
|
|
Status: agentcontext.StatusOK,
|
|
Name: "code-review",
|
|
Description: "Critical review for Go PRs.",
|
|
SourcePath: "/tmp",
|
|
},
|
|
{
|
|
ID: "mcp_config:/tmp/.mcp.json",
|
|
Kind: agentcontext.KindMCPConfig,
|
|
Source: "/tmp/.mcp.json",
|
|
ContentHash: [32]byte{0x04},
|
|
SizeBytes: 412,
|
|
Status: agentcontext.StatusOK,
|
|
SourcePath: "/tmp",
|
|
},
|
|
{
|
|
ID: "mcp_server:github",
|
|
Kind: agentcontext.KindMCPServer,
|
|
Source: "github",
|
|
Name: "github",
|
|
ContentHash: [32]byte{0x05},
|
|
SizeBytes: 138,
|
|
Status: agentcontext.StatusOK,
|
|
Description: "GitHub MCP server (1 tool)",
|
|
SourcePath: "/tmp/.mcp.json",
|
|
Tools: []agentcontext.MCPTool{{
|
|
Name: "create_issue",
|
|
Description: "Create a GitHub issue",
|
|
InputSchema: map[string]any{
|
|
"type": "object",
|
|
"required": []any{"title"},
|
|
},
|
|
}},
|
|
},
|
|
},
|
|
}
|
|
|
|
resp, err := pusher.PushContextState(context.Background(), req)
|
|
require.NoError(t, err)
|
|
require.True(t, resp.Accepted)
|
|
|
|
pb := client.lastReq
|
|
require.NotNil(t, pb)
|
|
require.Equal(t, uint64(7), pb.Version)
|
|
require.Equal(t, []byte{0xaa, 0xbb, 0xcc, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}, pb.AggregateHash)
|
|
require.True(t, pb.Initial)
|
|
require.Equal(t, uint64(1), pb.SchemaVersion)
|
|
require.Equal(t, "watcher degraded", pb.SnapshotError)
|
|
|
|
require.Len(t, pb.Resources, 5)
|
|
|
|
// Instruction file: wire-flat fields plus typed body.
|
|
instr := pb.Resources[0]
|
|
require.Equal(t, "/tmp/AGENTS.md", instr.Source)
|
|
require.Equal(t, agentproto.ContextResource_OK, instr.Status)
|
|
require.NotNil(t, instr.SourcePath)
|
|
require.Equal(t, "/tmp", *instr.SourcePath)
|
|
instrBody := instr.GetInstructionFile()
|
|
require.NotNil(t, instrBody, "instruction_file body must be set")
|
|
require.Equal(t, []byte("body"), instrBody.GetContent())
|
|
require.Nil(t, instr.GetSkill())
|
|
require.Nil(t, instr.GetMcpConfig())
|
|
require.Nil(t, instr.GetMcpServer())
|
|
|
|
// Skill with INVALID status still has the skill body set so
|
|
// coderd can attribute the failure to the correct kind.
|
|
invalidSkill := pb.Resources[1]
|
|
require.Equal(t, agentproto.ContextResource_INVALID, invalidSkill.Status)
|
|
require.Equal(t, "bad frontmatter", invalidSkill.Error)
|
|
require.NotNil(t, invalidSkill.GetSkill(), "skill body must be set even when status != OK")
|
|
require.Nil(t, invalidSkill.SourcePath, "empty user source must remain optional/nil")
|
|
|
|
// OK skill: meta + name + description populated.
|
|
skill := pb.Resources[2]
|
|
skillBody := skill.GetSkill()
|
|
require.NotNil(t, skillBody)
|
|
require.Equal(t, []byte("---\nname: code-review\n---\nbody\n"), skillBody.GetMeta())
|
|
require.Equal(t, "code-review", skillBody.GetName())
|
|
require.Equal(t, "Critical review for Go PRs.", skillBody.GetDescription())
|
|
|
|
// MCP config: body present but empty. SizeBytes / ContentHash
|
|
// on the outer resource still detect changes.
|
|
mcpCfg := pb.Resources[3]
|
|
require.Equal(t, uint64(412), mcpCfg.SizeBytes)
|
|
require.NotNil(t, mcpCfg.GetMcpConfig(), "mcp_config body must be set")
|
|
|
|
// MCP server: structured tool list with input schema.
|
|
mcpSrv := pb.Resources[4]
|
|
srvBody := mcpSrv.GetMcpServer()
|
|
require.NotNil(t, srvBody)
|
|
require.Equal(t, "github", srvBody.GetServerName())
|
|
require.Equal(t, "GitHub MCP server (1 tool)", srvBody.GetDescription())
|
|
require.Len(t, srvBody.GetTools(), 1)
|
|
tool := srvBody.GetTools()[0]
|
|
require.Equal(t, "create_issue", tool.GetName())
|
|
require.Equal(t, "Create a GitHub issue", tool.GetDescription())
|
|
require.NotNil(t, tool.GetInputSchema(), "input_schema must be set when supplied")
|
|
require.Equal(t, "object", tool.GetInputSchema().GetFields()["type"].GetStringValue())
|
|
}
|
|
|
|
func TestDRPCPusher_UnimplementedTranslated(t *testing.T) {
|
|
t.Parallel()
|
|
client := &fakeDRPCClient{err: drpcerr.WithCode(drpcerr.WithCode(context.Canceled, 0), drpcerr.Unimplemented)}
|
|
pusher := agentcontext.NewDRPCPusher(client)
|
|
|
|
_, err := pusher.PushContextState(context.Background(), &agentcontext.PushRequest{})
|
|
require.ErrorIs(t, err, agentcontext.ErrPushUnimplemented)
|
|
}
|
|
|
|
func TestDRPCPusher_PropagatesOtherErrors(t *testing.T) {
|
|
t.Parallel()
|
|
want := drpcerr.WithCode(context.DeadlineExceeded, 42)
|
|
client := &fakeDRPCClient{err: want}
|
|
pusher := agentcontext.NewDRPCPusher(client)
|
|
|
|
_, err := pusher.PushContextState(context.Background(), &agentcontext.PushRequest{})
|
|
require.ErrorIs(t, err, context.DeadlineExceeded)
|
|
}
|
|
|
|
func TestDRPCPusher_NilClientErrors(t *testing.T) {
|
|
t.Parallel()
|
|
pusher := agentcontext.NewDRPCPusher(nil)
|
|
_, err := pusher.PushContextState(context.Background(), &agentcontext.PushRequest{})
|
|
require.Error(t, err)
|
|
}
|