mirror of
https://github.com/coder/coder.git
synced 2026-09-22 21:22:17 +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.
245 lines
9.7 KiB
Go
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
|
|
}
|