Files
coder/coderd/x/chatd/context_hydration.go
T
Kyle Carberry cd56ab9e33 refactor: remove legacy live-read and injected-history chat context paths (#26585)
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.
2026-06-22 19:26:34 -06:00

245 lines
9.7 KiB
Go

package chatd
import (
"context"
"database/sql"
"errors"
"time"
"github.com/google/uuid"
"golang.org/x/xerrors"
"cdr.dev/slog/v3"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbauthz"
"github.com/coder/coder/v2/codersdk"
)
// latestAgentSnapshot looks up an agent's pinned context snapshot; ok is false
// (with a nil error) when the agent has not pushed one yet.
func latestAgentSnapshot(ctx context.Context, db database.Store, agentID uuid.UUID) (aggregateHash []byte, snapshotError string, ok bool, err error) {
snapshot, err := db.GetLatestWorkspaceAgentContextSnapshot(ctx, agentID)
switch {
case errors.Is(err, sql.ErrNoRows):
return nil, "", false, nil
case err != nil:
return nil, "", false, xerrors.Errorf("get latest snapshot: %w", err)
default:
return snapshot.AggregateHash, snapshot.SnapshotError, true, nil
}
}
// HydrateAndMarkChatsDirty implements agentapi.ContextDirtyMarker. It runs
// inside the PushContextState transaction: it stamps the pushed snapshot hash
// on chats for the agent that have not been hydrated yet (no dirty event),
// then flips already-pinned chats whose hash differs to dirty. It returns a
// callback that publishes the dirty watch events; the caller invokes it only
// after the transaction commits, and the callback is a no-op when nothing
// transitioned to dirty.
//
// The pinned hash on dirtied chats is intentionally left unchanged; the
// refresh endpoint re-pins it.
func (p *Server) HydrateAndMarkChatsDirty(ctx context.Context, tx database.Store, agentID uuid.UUID, aggregateHash []byte, snapshotError string, now time.Time) (func(), error) {
//nolint:gocritic // An agent does not own the chats bound to it.
ctx = dbauthz.AsChatd(ctx)
// Chats created before the agent's first push land with a NULL pinned
// hash. Stamp them now so they start clean; this is their first
// hydration, so no dirty event is emitted.
if err := tx.HydrateAgentChatsContext(ctx, database.HydrateAgentChatsContextParams{
AgentID: agentID,
AggregateHash: aggregateHash,
ContextError: snapshotError,
}); err != nil {
return nil, xerrors.Errorf("hydrate agent chats context: %w", err)
}
dirtied, err := tx.MarkChatsContextDirtyByAgent(ctx, database.MarkChatsContextDirtyByAgentParams{
AgentID: agentID,
AggregateHash: aggregateHash,
DirtySince: sql.NullTime{Time: now, Valid: true},
})
if err != nil {
return nil, xerrors.Errorf("mark chats context dirty: %w", err)
}
if len(dirtied) == 0 {
return func() {}, nil
}
// Read the dirtied chats inside the transaction and capture their rows so
// the post-commit callback needs no database access: the published payload
// reflects the just-committed dirty state (no re-read a concurrent refresh
// could race), and the callback does not depend on the request-scoped
// context surviving past commit. Only the transitioned chats are read.
dirtyChats := make([]database.Chat, 0, len(dirtied))
for _, d := range dirtied {
chat, err := tx.GetChatByID(ctx, d.ID)
if err != nil {
return nil, xerrors.Errorf("get dirtied chat %s: %w", d.ID, err)
}
dirtyChats = append(dirtyChats, chat)
}
return func() {
p.publishChatPubsubEvents(dirtyChats, codersdk.ChatWatchEventKindContextDirty)
}, nil
}
// hydrateAgentChatsFromSnapshot stamps every chat bound to agentID that still
// carries a NULL pinned hash with the agent's latest pushed snapshot and copies
// that snapshot's resources. It runs in one repeatable-read transaction so a
// concurrent push cannot commit between the hash read and the resource copy and
// leave a chat stamped with one snapshot's hash but another snapshot's
// resources. It is the shared core of first-time pinning: idempotent because
// HydrateAgentChatsContext only touches NULL-hash chats (a concurrent push that
// already hydrated the chat is not clobbered), and snapshot-gated so it does
// nothing when the agent has not pushed yet, never stamping empty state that
// would keep a later push from hydrating.
func (p *Server) hydrateAgentChatsFromSnapshot(ctx context.Context, agentID uuid.UUID) error {
return database.ReadModifyUpdate(p.db, func(tx database.Store) error {
aggregateHash, snapshotError, ok, err := latestAgentSnapshot(ctx, tx, agentID)
if err != nil {
return err
}
if !ok {
return nil
}
return tx.HydrateAgentChatsContext(ctx, database.HydrateAgentChatsContextParams{
AgentID: agentID,
AggregateHash: aggregateHash,
ContextError: snapshotError,
})
})
}
// hydrateChatContextOnCreate pins a newly created chat to its agent's latest
// context snapshot when one already exists. Best-effort: a chat whose agent
// has not pushed yet is hydrated later by that agent's next push. Failures
// are logged and swallowed so they never block chat creation.
//
// A concurrent push that already hydrated the chat is not clobbered with a
// stale hash.
func (p *Server) hydrateChatContextOnCreate(ctx context.Context, chat database.Chat) {
if !chat.AgentID.Valid {
return
}
//nolint:gocritic // Chatd stamps chats it does not own as the daemon subject.
ctx = dbauthz.AsChatd(ctx)
if err := p.hydrateAgentChatsFromSnapshot(ctx, chat.AgentID.UUID); err != nil {
p.logger.Warn(ctx, "hydrate chat context on create",
slog.F("chat_id", chat.ID), slog.Error(err))
}
}
// ensureChatContextPinnedOnFirstTurn pins a chat to its freshly bound agent's
// latest pushed snapshot when the chat is still unpinned. API-created chats
// carry no agent at create, so hydrateChatContextOnCreate is a no-op for them;
// they bind their agent lazily on the first turn. Without this, such a chat
// reads empty pinned context on its first turn whenever the agent pushed before
// the chat existed, because that push could not hydrate a chat that did not yet
// exist. It reuses the create-path hydration, which is idempotent and
// snapshot-gated, so it never clobbers an already-pinned chat and never stamps
// empty state. The NULL-hash gate also leaves dirtied chats alone: their stale
// pinned hash is non-NULL until the refresh endpoint re-pins. Best-effort:
// failures are logged and swallowed so they never fail the turn.
func (p *Server) ensureChatContextPinnedOnFirstTurn(ctx context.Context, chat database.Chat) {
if !chat.AgentID.Valid || chat.ContextAggregateHash != nil {
return
}
//nolint:gocritic // Chatd stamps chats it does not own as the daemon subject.
ctx = dbauthz.AsChatd(ctx)
if err := p.hydrateAgentChatsFromSnapshot(ctx, chat.AgentID.UUID); err != nil {
p.logger.Warn(ctx, "ensure chat context pinned on first turn",
slog.F("chat_id", chat.ID),
slog.F("agent_id", chat.AgentID.UUID),
slog.Error(err))
}
}
// repinChatContext re-pins a single chat to its agent's latest context
// snapshot: it sets the pinned hash and error and rewrites the chat's pinned
// resources (clear-then-copy) so the two always agree. A chat with no bound
// agent, or whose agent has no snapshot, has its pinned hash, dirty marker,
// and resources cleared. Callers run this inside a transaction.
func repinChatContext(ctx context.Context, db database.Store, chatID uuid.UUID, agentID uuid.NullUUID) error {
var (
aggregateHash []byte
snapshotError string
hasSnapshot bool
)
if agentID.Valid {
hash, snapErr, ok, err := latestAgentSnapshot(ctx, db, agentID.UUID)
if err != nil {
return err
}
if ok {
aggregateHash = hash
snapshotError = snapErr
hasSnapshot = true
}
}
if err := db.SetChatContextSnapshot(ctx, database.SetChatContextSnapshotParams{
ID: chatID,
AggregateHash: aggregateHash,
ContextError: snapshotError,
}); err != nil {
return xerrors.Errorf("set chat context snapshot: %w", err)
}
// Clear-then-copy so the pinned resources always match the pinned hash.
// A single delete+insert statement cannot see its own delete under
// snapshot isolation, so overlapping sources would collide.
if err := db.DeleteChatContextResourcesByChatID(ctx, chatID); err != nil {
return xerrors.Errorf("clear chat context resources: %w", err)
}
if hasSnapshot {
if err := db.InsertAgentContextResourcesIntoChat(ctx, database.InsertAgentContextResourcesIntoChatParams{
ChatID: chatID,
AgentID: agentID.UUID,
}); err != nil {
return xerrors.Errorf("copy agent context resources: %w", err)
}
}
return nil
}
// RefreshChatContext re-pins a chat to its agent's latest context snapshot
// (hash, error, and resource bodies) and clears the dirty marker. It backs
// PUT /chats/{chat}/context (no body). A chat with no bound agent, or whose
// agent has no snapshot, simply has its pinned hash, dirty marker, and
// resources cleared.
//
// The snapshot read and the re-pin run in one repeatable-read transaction so a
// concurrent push cannot land between them and leave the chat pinned to a
// stale hash with the dirty marker cleared.
func (p *Server) RefreshChatContext(ctx context.Context, chat database.Chat) (database.Chat, error) {
//nolint:gocritic // Chatd re-pins the chat as the daemon subject.
ctx = dbauthz.AsChatd(ctx)
var updated database.Chat
err := database.ReadModifyUpdate(p.db, func(tx database.Store) error {
// Re-read the chat inside the transaction so a serialization-conflict
// retry re-pins against the chat's current agent. Using the AgentID
// captured before the transaction would re-pin to a stale agent if a
// concurrent rebind landed between that read and the retry.
current, err := tx.GetChatByID(ctx, chat.ID)
if err != nil {
return xerrors.Errorf("get chat for refresh: %w", err)
}
if err := repinChatContext(ctx, tx, current.ID, current.AgentID); err != nil {
return err
}
got, err := tx.GetChatByID(ctx, chat.ID)
if err != nil {
return xerrors.Errorf("get chat after refresh: %w", err)
}
updated = got
return nil
})
if err != nil {
return database.Chat{}, err
}
return updated, nil
}