mirror of
https://github.com/coder/coder.git
synced 2026-09-22 05:05:20 +08:00
This PR makes the agent-pushed pinned snapshot (`chat_context_resources`) the sole source of workspace context for chats, completing the "Release 5" cleanup. It removes legacy mechanisms now superseded by the snapshot that agents push over dRPC (`PushContextState`) and refresh via `chat-context/refresh`. Removed: - **Live-read at turn time.** MCP tool discovery, skill live-body reads, and the instruction/skill history fallback that dialed the workspace on every turn. - **Context injected as message history.** The `persist_workspace_context` generation action and its decision-loop guard. - **The legacy write path.** `POST`/`DELETE /api/v2/workspaceagents/me/experimental/chat-context`, the agentsdk `AddChatContext`/`ClearChatContext` methods, and the CLI one-shot writer. - **The `chats.last_injected_context` column** and all of its plumbing (migration `000529`, queries, `db2sdk`, `dbauthz`, audit table, and the frontend `ContextUsageIndicator` fallback). Subagent context inheritance no longer copies parent context messages; children now hydrate the parent's pinned `chat_context_resources` on create, which yields an identical pin for the same workspace and agent. What stays (still served by the live agent connection, not the snapshot): `read_skill_file` supporting-file reads, `read_skill` supporting-file listing, and MCP tool execution. > [!NOTE] > Migration `000529` drops `chats.last_injected_context` and recreates the `chats_expanded` view without it. The down migration restores both. <details> <summary>Decision log (D1-D5)</summary> - **D1 (subagent inheritance):** Re-point inheritance from the legacy message copy to a pinned hydrate. Children call `hydrateChatContextOnCreate` instead of copying parent context messages. - **D2 (`persist_workspace_context`):** Remove the generation action entirely along with the decision-loop guard it existed to satisfy, since context is never injected into history anymore. - **D3 (legacy HTTP + CLI):** Remove the experimental `chat-context` POST/DELETE endpoints, the agentsdk methods, and the CLI one-shot. The dRPC push + `chat-context/refresh` replace them. - **D4 (frontend fallback):** Remove the `last_injected_context` fallback in `ContextUsageIndicator`; pinned `resources` are the sole source. - **D5 (sequencing):** Ship as a single PR rather than a stacked pair. </details> --- Coder Agents generated on behalf of @kylecarbs.
342 lines
12 KiB
Go
342 lines
12 KiB
Go
package cli
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"path"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/google/uuid"
|
|
"golang.org/x/xerrors"
|
|
|
|
"github.com/coder/coder/v2/agent/agentsocket"
|
|
"github.com/coder/coder/v2/cli/cliui"
|
|
"github.com/coder/coder/v2/codersdk"
|
|
"github.com/coder/serpent"
|
|
)
|
|
|
|
func (r *RootCmd) chatCommand() *serpent.Command {
|
|
return &serpent.Command{
|
|
Use: "chat",
|
|
Short: "Manage agent chats",
|
|
Long: "Commands for interacting with chats from within a workspace.",
|
|
Handler: func(i *serpent.Invocation) error {
|
|
return i.Command.HelpHandler(i)
|
|
},
|
|
Children: []*serpent.Command{
|
|
r.chatContextCommand(),
|
|
},
|
|
}
|
|
}
|
|
|
|
func (r *RootCmd) chatContextCommand() *serpent.Command {
|
|
// socketPath is shared by the in-workspace source commands (list, show,
|
|
// add, remove) and the no-argument refresh, which all talk to the agent's
|
|
// local IPC socket.
|
|
var socketPath string
|
|
return &serpent.Command{
|
|
Use: "context",
|
|
Short: "Manage workspace context",
|
|
Long: "Inspect and manage the workspace context sources (instruction files, " +
|
|
"skills, and MCP configs) the agent resolves, and refresh a chat to the " +
|
|
"agent's latest snapshot.\n\nThe list, show, add, and remove commands manage " +
|
|
"agent-local sources and must be run from inside the workspace.",
|
|
Handler: func(i *serpent.Invocation) error {
|
|
return i.Command.HelpHandler(i)
|
|
},
|
|
Children: []*serpent.Command{
|
|
r.chatContextListCommand(&socketPath),
|
|
r.chatContextShowCommand(&socketPath),
|
|
r.chatContextAddCommand(&socketPath),
|
|
r.chatContextRemoveCommand(&socketPath),
|
|
r.chatContextRefreshCommand(&socketPath),
|
|
},
|
|
Options: serpent.OptionSet{{
|
|
Flag: "socket-path",
|
|
Env: "CODER_AGENT_SOCKET_PATH",
|
|
Description: "Path to the agent socket used by the in-workspace source commands.",
|
|
Value: serpent.StringOf(&socketPath),
|
|
}},
|
|
}
|
|
}
|
|
|
|
// resolveContextSourcePath makes a user-supplied source path absolute so the
|
|
// agent (which requires absolute, canonical paths) accepts it. A leading ~ is
|
|
// preserved for the agent to expand against its own home directory. A path that
|
|
// is already absolute on the agent's POSIX filesystem (a leading /) is cleaned
|
|
// and passed through; filepath.Abs is host-OS specific and would mangle such a
|
|
// path on a Windows CLI host, so it is reserved for resolving relative paths
|
|
// against the CLI's working directory, which shares the workspace filesystem
|
|
// with the agent.
|
|
func resolveContextSourcePath(p string) (string, error) {
|
|
p = strings.TrimSpace(p)
|
|
if p == "" {
|
|
return "", xerrors.New("path is empty")
|
|
}
|
|
if p == "~" || strings.HasPrefix(p, "~/") {
|
|
return p, nil
|
|
}
|
|
if strings.HasPrefix(p, "/") {
|
|
return path.Clean(p), nil
|
|
}
|
|
abs, err := filepath.Abs(p)
|
|
if err != nil {
|
|
return "", xerrors.Errorf("resolve path %q: %w", p, err)
|
|
}
|
|
return abs, nil
|
|
}
|
|
|
|
// dialAgentContextSocket connects to the workspace agent's local IPC socket.
|
|
// It is only reachable from inside the workspace.
|
|
func dialAgentContextSocket(ctx context.Context, socketPath string) (*agentsocket.Client, error) {
|
|
opts := []agentsocket.Option{}
|
|
if socketPath != "" {
|
|
opts = append(opts, agentsocket.WithPath(socketPath))
|
|
}
|
|
client, err := agentsocket.NewClient(ctx, opts...)
|
|
if err != nil {
|
|
return nil, xerrors.Errorf("connect to agent socket (run this from inside the workspace): %w", err)
|
|
}
|
|
return client, nil
|
|
}
|
|
|
|
func (*RootCmd) chatContextListCommand(socketPath *string) *serpent.Command {
|
|
formatter := cliui.NewOutputFormatter(
|
|
cliui.TableFormat([]agentsocket.ContextSource{}, []string{"path"}),
|
|
cliui.JSONFormat(),
|
|
)
|
|
cmd := &serpent.Command{
|
|
Use: "list",
|
|
Short: "List the workspace context sources registered on the agent",
|
|
Long: "List the additional scan roots registered on this workspace's agent. " +
|
|
"Built-in defaults (the working directory, ~/.coder, ~/.claude) are always " +
|
|
"scanned and are not shown here.\n\nMust be run from inside the workspace.",
|
|
Middleware: serpent.RequireNArgs(0),
|
|
Handler: func(inv *serpent.Invocation) error {
|
|
ctx := inv.Context()
|
|
client, err := dialAgentContextSocket(ctx, *socketPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer client.Close()
|
|
|
|
sources, err := client.ContextSources(ctx)
|
|
if err != nil {
|
|
return xerrors.Errorf("list context sources: %w", err)
|
|
}
|
|
if len(sources) == 0 && formatter.FormatID() == "table" {
|
|
cliui.Info(inv.Stdout, "No context sources registered.")
|
|
return nil
|
|
}
|
|
out, err := formatter.Format(ctx, sources)
|
|
if err != nil {
|
|
return xerrors.Errorf("format output: %w", err)
|
|
}
|
|
_, _ = fmt.Fprintln(inv.Stdout, out)
|
|
return nil
|
|
},
|
|
}
|
|
formatter.AttachOptions(&cmd.Options)
|
|
return cmd
|
|
}
|
|
|
|
func (*RootCmd) chatContextShowCommand(socketPath *string) *serpent.Command {
|
|
formatter := cliui.NewOutputFormatter(
|
|
cliui.TableFormat(
|
|
[]agentsocket.ContextResource{},
|
|
[]string{"kind", "name", "source", "status", "size bytes", "error"},
|
|
),
|
|
cliui.JSONFormat(),
|
|
)
|
|
cmd := &serpent.Command{
|
|
Use: "show <path>",
|
|
Short: "Show a context source and the resources it contributes",
|
|
Long: "Show a registered context source and the resources the agent currently " +
|
|
"resolves from it (instruction files, skills, MCP configs), including any " +
|
|
"that failed to read or parse.\n\nMust be run from inside the workspace.",
|
|
Middleware: serpent.RequireNArgs(1),
|
|
Handler: func(inv *serpent.Invocation) error {
|
|
ctx := inv.Context()
|
|
client, err := dialAgentContextSocket(ctx, *socketPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer client.Close()
|
|
|
|
path, err := resolveContextSourcePath(inv.Args[0])
|
|
if err != nil {
|
|
return err
|
|
}
|
|
src, err := client.GetContextSource(ctx, path)
|
|
if err != nil {
|
|
return xerrors.Errorf("get context source: %w", err)
|
|
}
|
|
snap, err := client.GetContextSnapshot(ctx)
|
|
if err != nil {
|
|
return xerrors.Errorf("get context snapshot: %w", err)
|
|
}
|
|
resources := make([]agentsocket.ContextResource, 0, len(snap.Resources))
|
|
for _, res := range snap.Resources {
|
|
if res.SourcePath == src.Path {
|
|
resources = append(resources, res)
|
|
}
|
|
}
|
|
|
|
if formatter.FormatID() == "table" {
|
|
cliui.Infof(inv.Stdout, "Source: %s (%d resources)", src.Path, len(resources))
|
|
}
|
|
out, err := formatter.Format(ctx, resources)
|
|
if err != nil {
|
|
return xerrors.Errorf("format output: %w", err)
|
|
}
|
|
_, _ = fmt.Fprintln(inv.Stdout, out)
|
|
return nil
|
|
},
|
|
}
|
|
formatter.AttachOptions(&cmd.Options)
|
|
return cmd
|
|
}
|
|
|
|
func (*RootCmd) chatContextAddCommand(socketPath *string) *serpent.Command {
|
|
cmd := &serpent.Command{
|
|
Use: "add <path>",
|
|
Short: "Register a workspace context source",
|
|
Long: "Register a path as an additional context source on this workspace's agent. " +
|
|
"The agent treats it as an extra scan root, applying the same discovery rules " +
|
|
"it uses for the working directory: AGENTS.md / CLAUDE.md / .cursorrules, " +
|
|
".agents/skills/<name>/SKILL.md, and .mcp.json are picked up now and as they " +
|
|
"appear. Any change to a recognized file dirties this workspace's chats until " +
|
|
"you refresh.\n\nA path may be a file or a directory. Must be run from inside " +
|
|
"the workspace.",
|
|
Middleware: serpent.RequireNArgs(1),
|
|
Handler: func(inv *serpent.Invocation) error {
|
|
ctx := inv.Context()
|
|
ctx, stop := inv.SignalNotifyContext(ctx, StopSignals...)
|
|
defer stop()
|
|
|
|
path, err := resolveContextSourcePath(inv.Args[0])
|
|
if err != nil {
|
|
return err
|
|
}
|
|
client, err := dialAgentContextSocket(ctx, *socketPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer client.Close()
|
|
|
|
src, err := client.AddContextSource(ctx, path)
|
|
if err != nil {
|
|
return xerrors.Errorf("add context source: %w", err)
|
|
}
|
|
_, _ = fmt.Fprintf(inv.Stdout, "Registered context source %s\n", src.Path)
|
|
return nil
|
|
},
|
|
}
|
|
return cmd
|
|
}
|
|
|
|
func (*RootCmd) chatContextRemoveCommand(socketPath *string) *serpent.Command {
|
|
cmd := &serpent.Command{
|
|
Use: "remove <path>",
|
|
Short: "Remove a workspace context source",
|
|
Long: "Remove a previously-registered context source from this workspace's agent " +
|
|
"and re-resolve. Built-in default scan roots cannot be removed.\n\nMust be run " +
|
|
"from inside the workspace.",
|
|
Middleware: serpent.RequireNArgs(1),
|
|
Handler: func(inv *serpent.Invocation) error {
|
|
ctx := inv.Context()
|
|
client, err := dialAgentContextSocket(ctx, *socketPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer client.Close()
|
|
|
|
path, err := resolveContextSourcePath(inv.Args[0])
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := client.RemoveContextSource(ctx, path); err != nil {
|
|
return xerrors.Errorf("remove context source: %w", err)
|
|
}
|
|
_, _ = fmt.Fprintf(inv.Stdout, "Removed context source %s\n", path)
|
|
return nil
|
|
},
|
|
}
|
|
return cmd
|
|
}
|
|
|
|
func (r *RootCmd) chatContextRefreshCommand(socketPath *string) *serpent.Command {
|
|
agentAuth := &AgentAuth{}
|
|
cmd := &serpent.Command{
|
|
Use: "refresh [<chat>]",
|
|
Short: "Refresh chat context to the agent's latest snapshot",
|
|
Long: "Re-pin a chat to the workspace agent's latest context snapshot and clear " +
|
|
"its drift marker. The chat's next turn uses the refreshed context.\n\nWith a " +
|
|
"<chat> argument, refreshes that chat and works from anywhere.\n\nWith no " +
|
|
"argument, run from inside the workspace: forces the agent to re-resolve its " +
|
|
"sources (catching freshly-cloned repos and startup-script writes the watcher " +
|
|
"has not seen yet), then refreshes every drifted chat. This path authenticates " +
|
|
"with the agent token, so it does not require 'coder login'.",
|
|
Middleware: serpent.RequireRangeArgs(0, 1),
|
|
Handler: func(inv *serpent.Invocation) error {
|
|
ctx := inv.Context()
|
|
|
|
// With a <chat> argument: refresh that specific chat through the
|
|
// user-facing API. Works from anywhere with a logged-in CLI.
|
|
if len(inv.Args) == 1 {
|
|
chatID, err := uuid.Parse(inv.Args[0])
|
|
if err != nil {
|
|
return xerrors.Errorf("invalid chat ID %q: %w", inv.Args[0], err)
|
|
}
|
|
client, err := r.InitClient(inv)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
exp := codersdk.NewExperimentalClient(client)
|
|
chat, err := exp.RefreshChatContext(ctx, chatID)
|
|
if err != nil {
|
|
return xerrors.Errorf("refresh chat context: %w", err)
|
|
}
|
|
_, _ = fmt.Fprintf(inv.Stdout, "Refreshed context for chat %s.\n", chatID)
|
|
if chat.Context != nil && chat.Context.Error != "" {
|
|
_, _ = fmt.Fprintf(inv.Stdout, "Snapshot reported an error: %s\n", chat.Context.Error)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// No argument: in-workspace. Re-resolve the agent's sources over
|
|
// the local context socket, then ask the agent (using its own
|
|
// token) to re-pin every drifted chat. Neither step needs a
|
|
// logged-in user session.
|
|
sock, err := dialAgentContextSocket(ctx, *socketPath)
|
|
if err != nil {
|
|
return xerrors.Errorf("connect to agent context socket "+
|
|
"(run inside the workspace, or pass a <chat> ID): %w", err)
|
|
}
|
|
defer sock.Close()
|
|
snap, err := sock.ResyncContext(ctx)
|
|
if err != nil {
|
|
return xerrors.Errorf("re-resolve agent context: %w", err)
|
|
}
|
|
_, _ = fmt.Fprintf(inv.Stdout, "Re-resolved agent context (version %d, %d resources).\n", snap.Version, len(snap.Resources))
|
|
if snap.SnapshotError != "" {
|
|
_, _ = fmt.Fprintf(inv.Stdout, "Snapshot reported an error: %s\n", snap.SnapshotError)
|
|
}
|
|
|
|
agentClient, err := agentAuth.CreateClient()
|
|
if err != nil {
|
|
return xerrors.Errorf("create agent client: %w", err)
|
|
}
|
|
resp, err := agentClient.RefreshChatContext(ctx)
|
|
if err != nil {
|
|
return xerrors.Errorf("refresh chat context: %w", err)
|
|
}
|
|
_, _ = fmt.Fprintf(inv.Stdout, "Refreshed %d drifted chat(s).\n", resp.Refreshed)
|
|
return nil
|
|
},
|
|
}
|
|
agentAuth.AttachOptions(cmd, false)
|
|
return cmd
|
|
}
|