mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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.
This commit is contained in:
Generated
-7
@@ -16651,13 +16651,6 @@ const docTemplate = `{
|
||||
"last_error": {
|
||||
"$ref": "#/definitions/codersdk.ChatError"
|
||||
},
|
||||
"last_injected_context": {
|
||||
"description": "LastInjectedContext holds the most recently persisted\ninjected context parts (AGENTS.md files and skills). It\nis updated only when context changes, on first workspace\nattach or agent change.",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/codersdk.ChatMessagePart"
|
||||
}
|
||||
},
|
||||
"last_model_config_id": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
|
||||
Generated
-7
@@ -14957,13 +14957,6 @@
|
||||
"last_error": {
|
||||
"$ref": "#/definitions/codersdk.ChatError"
|
||||
},
|
||||
"last_injected_context": {
|
||||
"description": "LastInjectedContext holds the most recently persisted\ninjected context parts (AGENTS.md files and skills). It\nis updated only when context changes, on first workspace\nattach or agent change.",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/codersdk.ChatMessagePart"
|
||||
}
|
||||
},
|
||||
"last_model_config_id": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
|
||||
@@ -1794,8 +1794,6 @@ func New(options *Options) *API {
|
||||
r.Post("/log-source", api.workspaceAgentPostLogSource)
|
||||
r.Get("/reinit", api.workspaceAgentReinit)
|
||||
r.Route("/experimental", func(r chi.Router) {
|
||||
r.Post("/chat-context", api.workspaceAgentAddChatContext)
|
||||
r.Delete("/chat-context", api.workspaceAgentClearChatContext)
|
||||
r.Post("/chat-context/refresh", api.workspaceAgentRefreshChatContext)
|
||||
})
|
||||
r.Route("/tasks/{task}", func(r chi.Router) {
|
||||
|
||||
@@ -1752,17 +1752,6 @@ func Chat(c database.Chat, diffStatus *database.ChatDiffStatus, files []database
|
||||
})
|
||||
}
|
||||
}
|
||||
if c.LastInjectedContext.Valid {
|
||||
var parts []codersdk.ChatMessagePart
|
||||
// Internal fields are stripped at write time in
|
||||
// chatd.updateLastInjectedContext, so no
|
||||
// StripInternal call is needed here. Unmarshal
|
||||
// errors are suppressed — the column is written by
|
||||
// us with a known schema.
|
||||
if err := json.Unmarshal(c.LastInjectedContext.RawMessage, &parts); err == nil {
|
||||
chat.LastInjectedContext = parts
|
||||
}
|
||||
}
|
||||
// Report pinned-context state when the chat is context-tracked
|
||||
// (has a pinned hash), dirty, or carries a snapshot error.
|
||||
if len(c.ContextAggregateHash) > 0 || c.ContextDirtySince.Valid || c.ContextError != "" {
|
||||
|
||||
@@ -720,13 +720,6 @@ func TestChat_AllFieldsPopulated(t *testing.T) {
|
||||
PlanMode: database.NullChatPlanMode{ChatPlanMode: database.ChatPlanModePlan, Valid: true},
|
||||
MCPServerIDs: []uuid.UUID{uuid.New()},
|
||||
Labels: database.StringMap{"env": "prod"},
|
||||
LastInjectedContext: pqtype.NullRawMessage{
|
||||
// Use a context-file part to verify internal
|
||||
// fields are not present (they are stripped at
|
||||
// write time by chatd, not at read time).
|
||||
RawMessage: json.RawMessage(`[{"type":"context-file","context_file_path":"/AGENTS.md"}]`),
|
||||
Valid: true,
|
||||
},
|
||||
DynamicTools: pqtype.NullRawMessage{
|
||||
RawMessage: json.RawMessage(`[{"name":"tool1","description":"test tool","inputSchema":{"type":"object"}}]`),
|
||||
Valid: true,
|
||||
|
||||
@@ -7123,17 +7123,6 @@ func (q *querier) UpdateChatLabelsByID(ctx context.Context, arg database.UpdateC
|
||||
return q.db.UpdateChatLabelsByID(ctx, arg)
|
||||
}
|
||||
|
||||
func (q *querier) UpdateChatLastInjectedContext(ctx context.Context, arg database.UpdateChatLastInjectedContextParams) (database.Chat, error) {
|
||||
chat, err := q.db.GetChatByID(ctx, arg.ID)
|
||||
if err != nil {
|
||||
return database.Chat{}, err
|
||||
}
|
||||
if err := q.authorizeContext(ctx, policy.ActionUpdate, chat); err != nil {
|
||||
return database.Chat{}, err
|
||||
}
|
||||
return q.db.UpdateChatLastInjectedContext(ctx, arg)
|
||||
}
|
||||
|
||||
func (q *querier) UpdateChatLastModelConfigByID(ctx context.Context, arg database.UpdateChatLastModelConfigByIDParams) (database.Chat, error) {
|
||||
chat, err := q.db.GetChatByID(ctx, arg.ID)
|
||||
if err != nil {
|
||||
|
||||
@@ -1899,19 +1899,6 @@ func (s *MethodTestSuite) TestChats() {
|
||||
dbm.EXPECT().UpdateChatMCPServerIDs(gomock.Any(), arg).Return(chat, nil).AnyTimes()
|
||||
check.Args(arg).Asserts(chat, policy.ActionUpdate).Returns(chat)
|
||||
}))
|
||||
s.Run("UpdateChatLastInjectedContext", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
|
||||
chat := testutil.Fake(s.T(), faker, database.Chat{})
|
||||
arg := database.UpdateChatLastInjectedContextParams{
|
||||
ID: chat.ID,
|
||||
LastInjectedContext: pqtype.NullRawMessage{
|
||||
RawMessage: json.RawMessage(`[{"type":"text","text":"test"}]`),
|
||||
Valid: true,
|
||||
},
|
||||
}
|
||||
dbm.EXPECT().GetChatByID(gomock.Any(), chat.ID).Return(chat, nil).AnyTimes()
|
||||
dbm.EXPECT().UpdateChatLastInjectedContext(gomock.Any(), arg).Return(chat, nil).AnyTimes()
|
||||
check.Args(arg).Asserts(chat, policy.ActionUpdate).Returns(chat)
|
||||
}))
|
||||
s.Run("UpdateChatLastTurnSummary", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
|
||||
chat := testutil.Fake(s.T(), faker, database.Chat{})
|
||||
arg := database.UpdateChatLastTurnSummaryParams{
|
||||
|
||||
-8
@@ -5114,14 +5114,6 @@ func (m queryMetricsStore) UpdateChatLabelsByID(ctx context.Context, arg databas
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) UpdateChatLastInjectedContext(ctx context.Context, arg database.UpdateChatLastInjectedContextParams) (database.Chat, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.UpdateChatLastInjectedContext(ctx, arg)
|
||||
m.queryLatencies.WithLabelValues("UpdateChatLastInjectedContext").Observe(time.Since(start).Seconds())
|
||||
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "UpdateChatLastInjectedContext").Inc()
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) UpdateChatLastModelConfigByID(ctx context.Context, arg database.UpdateChatLastModelConfigByIDParams) (database.Chat, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.UpdateChatLastModelConfigByID(ctx, arg)
|
||||
|
||||
Generated
-15
@@ -9636,21 +9636,6 @@ func (mr *MockStoreMockRecorder) UpdateChatLabelsByID(ctx, arg any) *gomock.Call
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateChatLabelsByID", reflect.TypeOf((*MockStore)(nil).UpdateChatLabelsByID), ctx, arg)
|
||||
}
|
||||
|
||||
// UpdateChatLastInjectedContext mocks base method.
|
||||
func (m *MockStore) UpdateChatLastInjectedContext(ctx context.Context, arg database.UpdateChatLastInjectedContextParams) (database.Chat, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "UpdateChatLastInjectedContext", ctx, arg)
|
||||
ret0, _ := ret[0].(database.Chat)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// UpdateChatLastInjectedContext indicates an expected call of UpdateChatLastInjectedContext.
|
||||
func (mr *MockStoreMockRecorder) UpdateChatLastInjectedContext(ctx, arg any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateChatLastInjectedContext", reflect.TypeOf((*MockStore)(nil).UpdateChatLastInjectedContext), ctx, arg)
|
||||
}
|
||||
|
||||
// UpdateChatLastModelConfigByID mocks base method.
|
||||
func (m *MockStore) UpdateChatLastModelConfigByID(ctx context.Context, arg database.UpdateChatLastModelConfigByIDParams) (database.Chat, error) {
|
||||
m.ctrl.T.Helper()
|
||||
|
||||
Generated
-2
@@ -1999,7 +1999,6 @@ CREATE TABLE chats (
|
||||
agent_id uuid,
|
||||
pin_order integer DEFAULT 0 NOT NULL,
|
||||
last_read_message_id bigint,
|
||||
last_injected_context jsonb,
|
||||
dynamic_tools jsonb,
|
||||
organization_id uuid NOT NULL,
|
||||
plan_mode chat_plan_mode,
|
||||
@@ -2114,7 +2113,6 @@ CREATE VIEW chats_expanded AS
|
||||
c.agent_id,
|
||||
c.pin_order,
|
||||
c.last_read_message_id,
|
||||
c.last_injected_context,
|
||||
c.dynamic_tools,
|
||||
c.organization_id,
|
||||
c.plan_mode,
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
-- Restores the last_injected_context column on chats and recreates the
|
||||
-- view with that column in its original position between
|
||||
-- last_read_message_id and dynamic_tools.
|
||||
DROP VIEW IF EXISTS chats_expanded;
|
||||
|
||||
ALTER TABLE chats ADD COLUMN last_injected_context jsonb;
|
||||
|
||||
CREATE VIEW chats_expanded AS
|
||||
SELECT c.id,
|
||||
c.owner_id,
|
||||
c.workspace_id,
|
||||
c.title,
|
||||
c.status,
|
||||
c.worker_id,
|
||||
c.started_at,
|
||||
c.heartbeat_at,
|
||||
c.created_at,
|
||||
c.updated_at,
|
||||
c.parent_chat_id,
|
||||
c.root_chat_id,
|
||||
c.last_model_config_id,
|
||||
c.archived,
|
||||
c.last_error,
|
||||
c.mode,
|
||||
c.mcp_server_ids,
|
||||
c.labels,
|
||||
c.build_id,
|
||||
c.agent_id,
|
||||
c.pin_order,
|
||||
c.last_read_message_id,
|
||||
c.last_injected_context,
|
||||
c.dynamic_tools,
|
||||
c.organization_id,
|
||||
c.plan_mode,
|
||||
c.client_type,
|
||||
c.last_turn_summary,
|
||||
c.snapshot_version,
|
||||
c.history_version,
|
||||
c.queue_version,
|
||||
c.generation_attempt,
|
||||
c.retry_state,
|
||||
c.retry_state_version,
|
||||
c.runner_id,
|
||||
c.requires_action_deadline_at,
|
||||
COALESCE(root.user_acl, c.user_acl) AS user_acl,
|
||||
COALESCE(root.group_acl, c.group_acl) AS group_acl,
|
||||
owner.username AS owner_username,
|
||||
owner.name AS owner_name,
|
||||
c.context_aggregate_hash,
|
||||
c.context_dirty_since,
|
||||
c.context_dirty_resources,
|
||||
c.context_error
|
||||
FROM ((chats c
|
||||
LEFT JOIN chats root ON ((root.id = COALESCE(c.root_chat_id, c.parent_chat_id))))
|
||||
JOIN visible_users owner ON ((owner.id = c.owner_id)));
|
||||
@@ -0,0 +1,55 @@
|
||||
-- Drops an unused column from chats. The view must be dropped before
|
||||
-- the column it references can be removed, then recreated without it. A
|
||||
-- view cannot have a column removed from the middle of its column list
|
||||
-- in place.
|
||||
DROP VIEW IF EXISTS chats_expanded;
|
||||
|
||||
ALTER TABLE chats DROP COLUMN last_injected_context;
|
||||
|
||||
CREATE VIEW chats_expanded AS
|
||||
SELECT c.id,
|
||||
c.owner_id,
|
||||
c.workspace_id,
|
||||
c.title,
|
||||
c.status,
|
||||
c.worker_id,
|
||||
c.started_at,
|
||||
c.heartbeat_at,
|
||||
c.created_at,
|
||||
c.updated_at,
|
||||
c.parent_chat_id,
|
||||
c.root_chat_id,
|
||||
c.last_model_config_id,
|
||||
c.archived,
|
||||
c.last_error,
|
||||
c.mode,
|
||||
c.mcp_server_ids,
|
||||
c.labels,
|
||||
c.build_id,
|
||||
c.agent_id,
|
||||
c.pin_order,
|
||||
c.last_read_message_id,
|
||||
c.dynamic_tools,
|
||||
c.organization_id,
|
||||
c.plan_mode,
|
||||
c.client_type,
|
||||
c.last_turn_summary,
|
||||
c.snapshot_version,
|
||||
c.history_version,
|
||||
c.queue_version,
|
||||
c.generation_attempt,
|
||||
c.retry_state,
|
||||
c.retry_state_version,
|
||||
c.runner_id,
|
||||
c.requires_action_deadline_at,
|
||||
COALESCE(root.user_acl, c.user_acl) AS user_acl,
|
||||
COALESCE(root.group_acl, c.group_acl) AS group_acl,
|
||||
owner.username AS owner_username,
|
||||
owner.name AS owner_name,
|
||||
c.context_aggregate_hash,
|
||||
c.context_dirty_since,
|
||||
c.context_dirty_resources,
|
||||
c.context_error
|
||||
FROM ((chats c
|
||||
LEFT JOIN chats root ON ((root.id = COALESCE(c.root_chat_id, c.parent_chat_id))))
|
||||
JOIN visible_users owner ON ((owner.id = c.owner_id)));
|
||||
@@ -819,7 +819,6 @@ func (q *sqlQuerier) GetAuthorizedChats(ctx context.Context, arg GetChatsParams,
|
||||
&i.Chat.AgentID,
|
||||
&i.Chat.PinOrder,
|
||||
&i.Chat.LastReadMessageID,
|
||||
&i.Chat.LastInjectedContext,
|
||||
&i.Chat.DynamicTools,
|
||||
&i.Chat.OrganizationID,
|
||||
&i.Chat.PlanMode,
|
||||
@@ -898,7 +897,6 @@ func (q *sqlQuerier) GetAuthorizedChatsByChatFileID(ctx context.Context, fileID
|
||||
&i.AgentID,
|
||||
&i.PinOrder,
|
||||
&i.LastReadMessageID,
|
||||
&i.LastInjectedContext,
|
||||
&i.DynamicTools,
|
||||
&i.OrganizationID,
|
||||
&i.PlanMode,
|
||||
|
||||
Generated
+29
-31
@@ -4787,7 +4787,6 @@ type Chat struct {
|
||||
AgentID uuid.NullUUID `db:"agent_id" json:"agent_id"`
|
||||
PinOrder int32 `db:"pin_order" json:"pin_order"`
|
||||
LastReadMessageID sql.NullInt64 `db:"last_read_message_id" json:"last_read_message_id"`
|
||||
LastInjectedContext pqtype.NullRawMessage `db:"last_injected_context" json:"last_injected_context"`
|
||||
DynamicTools pqtype.NullRawMessage `db:"dynamic_tools" json:"dynamic_tools"`
|
||||
OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"`
|
||||
PlanMode NullChatPlanMode `db:"plan_mode" json:"plan_mode"`
|
||||
@@ -4977,36 +4976,35 @@ type ChatQueuedMessage struct {
|
||||
}
|
||||
|
||||
type ChatTable struct {
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
OwnerID uuid.UUID `db:"owner_id" json:"owner_id"`
|
||||
WorkspaceID uuid.NullUUID `db:"workspace_id" json:"workspace_id"`
|
||||
Title string `db:"title" json:"title"`
|
||||
Status ChatStatus `db:"status" json:"status"`
|
||||
WorkerID uuid.NullUUID `db:"worker_id" json:"worker_id"`
|
||||
StartedAt sql.NullTime `db:"started_at" json:"started_at"`
|
||||
HeartbeatAt sql.NullTime `db:"heartbeat_at" json:"heartbeat_at"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
ParentChatID uuid.NullUUID `db:"parent_chat_id" json:"parent_chat_id"`
|
||||
RootChatID uuid.NullUUID `db:"root_chat_id" json:"root_chat_id"`
|
||||
LastModelConfigID uuid.UUID `db:"last_model_config_id" json:"last_model_config_id"`
|
||||
Archived bool `db:"archived" json:"archived"`
|
||||
LastError pqtype.NullRawMessage `db:"last_error" json:"last_error"`
|
||||
Mode NullChatMode `db:"mode" json:"mode"`
|
||||
MCPServerIDs []uuid.UUID `db:"mcp_server_ids" json:"mcp_server_ids"`
|
||||
Labels StringMap `db:"labels" json:"labels"`
|
||||
BuildID uuid.NullUUID `db:"build_id" json:"build_id"`
|
||||
AgentID uuid.NullUUID `db:"agent_id" json:"agent_id"`
|
||||
PinOrder int32 `db:"pin_order" json:"pin_order"`
|
||||
LastReadMessageID sql.NullInt64 `db:"last_read_message_id" json:"last_read_message_id"`
|
||||
LastInjectedContext pqtype.NullRawMessage `db:"last_injected_context" json:"last_injected_context"`
|
||||
DynamicTools pqtype.NullRawMessage `db:"dynamic_tools" json:"dynamic_tools"`
|
||||
OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"`
|
||||
PlanMode NullChatPlanMode `db:"plan_mode" json:"plan_mode"`
|
||||
ClientType ChatClientType `db:"client_type" json:"client_type"`
|
||||
LastTurnSummary sql.NullString `db:"last_turn_summary" json:"last_turn_summary"`
|
||||
UserACL ChatACL `db:"user_acl" json:"user_acl"`
|
||||
GroupACL ChatACL `db:"group_acl" json:"group_acl"`
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
OwnerID uuid.UUID `db:"owner_id" json:"owner_id"`
|
||||
WorkspaceID uuid.NullUUID `db:"workspace_id" json:"workspace_id"`
|
||||
Title string `db:"title" json:"title"`
|
||||
Status ChatStatus `db:"status" json:"status"`
|
||||
WorkerID uuid.NullUUID `db:"worker_id" json:"worker_id"`
|
||||
StartedAt sql.NullTime `db:"started_at" json:"started_at"`
|
||||
HeartbeatAt sql.NullTime `db:"heartbeat_at" json:"heartbeat_at"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
ParentChatID uuid.NullUUID `db:"parent_chat_id" json:"parent_chat_id"`
|
||||
RootChatID uuid.NullUUID `db:"root_chat_id" json:"root_chat_id"`
|
||||
LastModelConfigID uuid.UUID `db:"last_model_config_id" json:"last_model_config_id"`
|
||||
Archived bool `db:"archived" json:"archived"`
|
||||
LastError pqtype.NullRawMessage `db:"last_error" json:"last_error"`
|
||||
Mode NullChatMode `db:"mode" json:"mode"`
|
||||
MCPServerIDs []uuid.UUID `db:"mcp_server_ids" json:"mcp_server_ids"`
|
||||
Labels StringMap `db:"labels" json:"labels"`
|
||||
BuildID uuid.NullUUID `db:"build_id" json:"build_id"`
|
||||
AgentID uuid.NullUUID `db:"agent_id" json:"agent_id"`
|
||||
PinOrder int32 `db:"pin_order" json:"pin_order"`
|
||||
LastReadMessageID sql.NullInt64 `db:"last_read_message_id" json:"last_read_message_id"`
|
||||
DynamicTools pqtype.NullRawMessage `db:"dynamic_tools" json:"dynamic_tools"`
|
||||
OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"`
|
||||
PlanMode NullChatPlanMode `db:"plan_mode" json:"plan_mode"`
|
||||
ClientType ChatClientType `db:"client_type" json:"client_type"`
|
||||
LastTurnSummary sql.NullString `db:"last_turn_summary" json:"last_turn_summary"`
|
||||
UserACL ChatACL `db:"user_acl" json:"user_acl"`
|
||||
GroupACL ChatACL `db:"group_acl" json:"group_acl"`
|
||||
// Monotonic version for the full chat snapshot. Starts at 1 so stream loops and workers can use 0 to mean they have not loaded the chat yet.
|
||||
SnapshotVersion int64 `db:"snapshot_version" json:"snapshot_version"`
|
||||
// Snapshot version of the latest durable history change. Starts at 0 until chat_messages triggers set it to the current snapshot_version.
|
||||
|
||||
Generated
-5
@@ -1336,11 +1336,6 @@ type sqlcQuerier interface {
|
||||
// caller can detect stolen or completed chats via set-difference.
|
||||
UpdateChatHeartbeats(ctx context.Context, arg UpdateChatHeartbeatsParams) ([]uuid.UUID, error)
|
||||
UpdateChatLabelsByID(ctx context.Context, arg UpdateChatLabelsByIDParams) (Chat, error)
|
||||
// Updates the cached injected context parts (AGENTS.md +
|
||||
// skills) on the chat row. Called only when context changes
|
||||
// (first workspace attach or agent change). updated_at is
|
||||
// intentionally not touched to avoid reordering the chat list.
|
||||
UpdateChatLastInjectedContext(ctx context.Context, arg UpdateChatLastInjectedContextParams) (Chat, error)
|
||||
UpdateChatLastModelConfigByID(ctx context.Context, arg UpdateChatLastModelConfigByIDParams) (Chat, error)
|
||||
// Updates the last read message ID for a chat. This is used to track
|
||||
// which messages the owner has seen, enabling unread indicators.
|
||||
|
||||
Generated
+50
-226
@@ -5482,7 +5482,7 @@ WHERE
|
||||
LIMIT
|
||||
$3::int
|
||||
)
|
||||
RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
|
||||
RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
|
||||
),
|
||||
chats_expanded AS (
|
||||
SELECT
|
||||
@@ -5508,7 +5508,6 @@ chats_expanded AS (
|
||||
acquired_chats.agent_id,
|
||||
acquired_chats.pin_order,
|
||||
acquired_chats.last_read_message_id,
|
||||
acquired_chats.last_injected_context,
|
||||
acquired_chats.dynamic_tools,
|
||||
acquired_chats.organization_id,
|
||||
acquired_chats.plan_mode,
|
||||
@@ -5535,7 +5534,7 @@ chats_expanded AS (
|
||||
LEFT JOIN chats root ON root.id = COALESCE(acquired_chats.root_chat_id, acquired_chats.parent_chat_id)
|
||||
JOIN visible_users owner ON owner.id = acquired_chats.owner_id
|
||||
)
|
||||
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
|
||||
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
|
||||
FROM chats_expanded
|
||||
`
|
||||
|
||||
@@ -5579,7 +5578,6 @@ func (q *sqlQuerier) AcquireChats(ctx context.Context, arg AcquireChatsParams) (
|
||||
&i.AgentID,
|
||||
&i.PinOrder,
|
||||
&i.LastReadMessageID,
|
||||
&i.LastInjectedContext,
|
||||
&i.DynamicTools,
|
||||
&i.OrganizationID,
|
||||
&i.PlanMode,
|
||||
@@ -5739,7 +5737,7 @@ WITH updated_chats AS (
|
||||
UPDATE chats
|
||||
SET archived = true, pin_order = 0, updated_at = NOW()
|
||||
WHERE id = $1::uuid OR root_chat_id = $1::uuid
|
||||
RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
|
||||
RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
|
||||
),
|
||||
chats_expanded AS (
|
||||
SELECT
|
||||
@@ -5765,7 +5763,6 @@ chats_expanded AS (
|
||||
updated_chats.agent_id,
|
||||
updated_chats.pin_order,
|
||||
updated_chats.last_read_message_id,
|
||||
updated_chats.last_injected_context,
|
||||
updated_chats.dynamic_tools,
|
||||
updated_chats.organization_id,
|
||||
updated_chats.plan_mode,
|
||||
@@ -5792,7 +5789,7 @@ chats_expanded AS (
|
||||
LEFT JOIN chats root ON root.id = COALESCE(updated_chats.root_chat_id, updated_chats.parent_chat_id)
|
||||
JOIN visible_users owner ON owner.id = updated_chats.owner_id
|
||||
)
|
||||
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
|
||||
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
|
||||
FROM chats_expanded
|
||||
ORDER BY (chats_expanded.id = $1::uuid) DESC, chats_expanded.created_at ASC, chats_expanded.id ASC
|
||||
`
|
||||
@@ -5829,7 +5826,6 @@ func (q *sqlQuerier) ArchiveChatByID(ctx context.Context, id uuid.UUID) ([]Chat,
|
||||
&i.AgentID,
|
||||
&i.PinOrder,
|
||||
&i.LastReadMessageID,
|
||||
&i.LastInjectedContext,
|
||||
&i.DynamicTools,
|
||||
&i.OrganizationID,
|
||||
&i.PlanMode,
|
||||
@@ -5901,10 +5897,10 @@ archived AS (
|
||||
FROM to_archive t
|
||||
WHERE (c.id = t.id OR c.root_chat_id = t.id) -- cascade to children
|
||||
AND c.archived = false
|
||||
RETURNING c.id, c.owner_id, c.workspace_id, c.title, c.status, c.worker_id, c.started_at, c.heartbeat_at, c.created_at, c.updated_at, c.parent_chat_id, c.root_chat_id, c.last_model_config_id, c.archived, c.last_error, c.mode, c.mcp_server_ids, c.labels, c.build_id, c.agent_id, c.pin_order, c.last_read_message_id, c.last_injected_context, c.dynamic_tools, c.organization_id, c.plan_mode, c.client_type, c.last_turn_summary, c.user_acl, c.group_acl, c.snapshot_version, c.history_version, c.queue_version, c.generation_attempt, c.retry_state, c.retry_state_version, c.runner_id, c.requires_action_deadline_at, c.context_aggregate_hash, c.context_dirty_since, c.context_dirty_resources, c.context_error
|
||||
RETURNING c.id, c.owner_id, c.workspace_id, c.title, c.status, c.worker_id, c.started_at, c.heartbeat_at, c.created_at, c.updated_at, c.parent_chat_id, c.root_chat_id, c.last_model_config_id, c.archived, c.last_error, c.mode, c.mcp_server_ids, c.labels, c.build_id, c.agent_id, c.pin_order, c.last_read_message_id, c.dynamic_tools, c.organization_id, c.plan_mode, c.client_type, c.last_turn_summary, c.user_acl, c.group_acl, c.snapshot_version, c.history_version, c.queue_version, c.generation_attempt, c.retry_state, c.retry_state_version, c.runner_id, c.requires_action_deadline_at, c.context_aggregate_hash, c.context_dirty_since, c.context_dirty_resources, c.context_error
|
||||
)
|
||||
SELECT
|
||||
a.id, a.owner_id, a.workspace_id, a.title, a.status, a.worker_id, a.started_at, a.heartbeat_at, a.created_at, a.updated_at, a.parent_chat_id, a.root_chat_id, a.last_model_config_id, a.archived, a.last_error, a.mode, a.mcp_server_ids, a.labels, a.build_id, a.agent_id, a.pin_order, a.last_read_message_id, a.last_injected_context, a.dynamic_tools, a.organization_id, a.plan_mode, a.client_type, a.last_turn_summary, a.user_acl, a.group_acl, a.snapshot_version, a.history_version, a.queue_version, a.generation_attempt, a.retry_state, a.retry_state_version, a.runner_id, a.requires_action_deadline_at, a.context_aggregate_hash, a.context_dirty_since, a.context_dirty_resources, a.context_error,
|
||||
a.id, a.owner_id, a.workspace_id, a.title, a.status, a.worker_id, a.started_at, a.heartbeat_at, a.created_at, a.updated_at, a.parent_chat_id, a.root_chat_id, a.last_model_config_id, a.archived, a.last_error, a.mode, a.mcp_server_ids, a.labels, a.build_id, a.agent_id, a.pin_order, a.last_read_message_id, a.dynamic_tools, a.organization_id, a.plan_mode, a.client_type, a.last_turn_summary, a.user_acl, a.group_acl, a.snapshot_version, a.history_version, a.queue_version, a.generation_attempt, a.retry_state, a.retry_state_version, a.runner_id, a.requires_action_deadline_at, a.context_aggregate_hash, a.context_dirty_since, a.context_dirty_resources, a.context_error,
|
||||
-- Children inherit their root's activity so last_activity_at is never null.
|
||||
COALESCE(
|
||||
t.last_activity_at,
|
||||
@@ -5944,7 +5940,6 @@ type AutoArchiveInactiveChatsRow struct {
|
||||
AgentID uuid.NullUUID `db:"agent_id" json:"agent_id"`
|
||||
PinOrder int32 `db:"pin_order" json:"pin_order"`
|
||||
LastReadMessageID sql.NullInt64 `db:"last_read_message_id" json:"last_read_message_id"`
|
||||
LastInjectedContext pqtype.NullRawMessage `db:"last_injected_context" json:"last_injected_context"`
|
||||
DynamicTools pqtype.NullRawMessage `db:"dynamic_tools" json:"dynamic_tools"`
|
||||
OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"`
|
||||
PlanMode NullChatPlanMode `db:"plan_mode" json:"plan_mode"`
|
||||
@@ -6006,7 +6001,6 @@ func (q *sqlQuerier) AutoArchiveInactiveChats(ctx context.Context, arg AutoArchi
|
||||
&i.AgentID,
|
||||
&i.PinOrder,
|
||||
&i.LastReadMessageID,
|
||||
&i.LastInjectedContext,
|
||||
&i.DynamicTools,
|
||||
&i.OrganizationID,
|
||||
&i.PlanMode,
|
||||
@@ -6302,7 +6296,7 @@ func (q *sqlQuerier) DeleteStaleChatHeartbeats(ctx context.Context, staleSeconds
|
||||
}
|
||||
|
||||
const getActiveChatsByAgentID = `-- name: GetActiveChatsByAgentID :many
|
||||
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
|
||||
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
|
||||
FROM chats_expanded
|
||||
WHERE agent_id = $1::uuid
|
||||
AND archived = false
|
||||
@@ -6345,7 +6339,6 @@ func (q *sqlQuerier) GetActiveChatsByAgentID(ctx context.Context, agentID uuid.U
|
||||
&i.AgentID,
|
||||
&i.PinOrder,
|
||||
&i.LastReadMessageID,
|
||||
&i.LastInjectedContext,
|
||||
&i.DynamicTools,
|
||||
&i.OrganizationID,
|
||||
&i.PlanMode,
|
||||
@@ -6383,7 +6376,7 @@ func (q *sqlQuerier) GetActiveChatsByAgentID(ctx context.Context, agentID uuid.U
|
||||
|
||||
const getAutoArchiveInactiveChatCandidates = `-- name: GetAutoArchiveInactiveChatCandidates :many
|
||||
SELECT
|
||||
chats_expanded.id, chats_expanded.owner_id, chats_expanded.workspace_id, chats_expanded.title, chats_expanded.status, chats_expanded.worker_id, chats_expanded.started_at, chats_expanded.heartbeat_at, chats_expanded.created_at, chats_expanded.updated_at, chats_expanded.parent_chat_id, chats_expanded.root_chat_id, chats_expanded.last_model_config_id, chats_expanded.archived, chats_expanded.last_error, chats_expanded.mode, chats_expanded.mcp_server_ids, chats_expanded.labels, chats_expanded.build_id, chats_expanded.agent_id, chats_expanded.pin_order, chats_expanded.last_read_message_id, chats_expanded.last_injected_context, chats_expanded.dynamic_tools, chats_expanded.organization_id, chats_expanded.plan_mode, chats_expanded.client_type, chats_expanded.last_turn_summary, chats_expanded.snapshot_version, chats_expanded.history_version, chats_expanded.queue_version, chats_expanded.generation_attempt, chats_expanded.retry_state, chats_expanded.retry_state_version, chats_expanded.runner_id, chats_expanded.requires_action_deadline_at, chats_expanded.user_acl, chats_expanded.group_acl, chats_expanded.owner_username, chats_expanded.owner_name, chats_expanded.context_aggregate_hash, chats_expanded.context_dirty_since, chats_expanded.context_dirty_resources, chats_expanded.context_error,
|
||||
chats_expanded.id, chats_expanded.owner_id, chats_expanded.workspace_id, chats_expanded.title, chats_expanded.status, chats_expanded.worker_id, chats_expanded.started_at, chats_expanded.heartbeat_at, chats_expanded.created_at, chats_expanded.updated_at, chats_expanded.parent_chat_id, chats_expanded.root_chat_id, chats_expanded.last_model_config_id, chats_expanded.archived, chats_expanded.last_error, chats_expanded.mode, chats_expanded.mcp_server_ids, chats_expanded.labels, chats_expanded.build_id, chats_expanded.agent_id, chats_expanded.pin_order, chats_expanded.last_read_message_id, chats_expanded.dynamic_tools, chats_expanded.organization_id, chats_expanded.plan_mode, chats_expanded.client_type, chats_expanded.last_turn_summary, chats_expanded.snapshot_version, chats_expanded.history_version, chats_expanded.queue_version, chats_expanded.generation_attempt, chats_expanded.retry_state, chats_expanded.retry_state_version, chats_expanded.runner_id, chats_expanded.requires_action_deadline_at, chats_expanded.user_acl, chats_expanded.group_acl, chats_expanded.owner_username, chats_expanded.owner_name, chats_expanded.context_aggregate_hash, chats_expanded.context_dirty_since, chats_expanded.context_dirty_resources, chats_expanded.context_error,
|
||||
COALESCE(activity.last_activity_at, chats_expanded.created_at)::timestamptz AS last_activity_at
|
||||
FROM chats_expanded
|
||||
LEFT JOIN LATERAL (
|
||||
@@ -6438,7 +6431,6 @@ type GetAutoArchiveInactiveChatCandidatesRow struct {
|
||||
AgentID uuid.NullUUID `db:"agent_id" json:"agent_id"`
|
||||
PinOrder int32 `db:"pin_order" json:"pin_order"`
|
||||
LastReadMessageID sql.NullInt64 `db:"last_read_message_id" json:"last_read_message_id"`
|
||||
LastInjectedContext pqtype.NullRawMessage `db:"last_injected_context" json:"last_injected_context"`
|
||||
DynamicTools pqtype.NullRawMessage `db:"dynamic_tools" json:"dynamic_tools"`
|
||||
OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"`
|
||||
PlanMode NullChatPlanMode `db:"plan_mode" json:"plan_mode"`
|
||||
@@ -6498,7 +6490,6 @@ func (q *sqlQuerier) GetAutoArchiveInactiveChatCandidates(ctx context.Context, a
|
||||
&i.AgentID,
|
||||
&i.PinOrder,
|
||||
&i.LastReadMessageID,
|
||||
&i.LastInjectedContext,
|
||||
&i.DynamicTools,
|
||||
&i.OrganizationID,
|
||||
&i.PlanMode,
|
||||
@@ -6558,7 +6549,7 @@ func (q *sqlQuerier) GetChatACLByID(ctx context.Context, id uuid.UUID) (GetChatA
|
||||
}
|
||||
|
||||
const getChatByID = `-- name: GetChatByID :one
|
||||
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
|
||||
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
|
||||
FROM chats_expanded
|
||||
WHERE id = $1::uuid
|
||||
`
|
||||
@@ -6589,7 +6580,6 @@ func (q *sqlQuerier) GetChatByID(ctx context.Context, id uuid.UUID) (Chat, error
|
||||
&i.AgentID,
|
||||
&i.PinOrder,
|
||||
&i.LastReadMessageID,
|
||||
&i.LastInjectedContext,
|
||||
&i.DynamicTools,
|
||||
&i.OrganizationID,
|
||||
&i.PlanMode,
|
||||
@@ -6617,7 +6607,7 @@ func (q *sqlQuerier) GetChatByID(ctx context.Context, id uuid.UUID) (Chat, error
|
||||
|
||||
const getChatByIDForShare = `-- name: GetChatByIDForShare :one
|
||||
WITH shared_chat AS (
|
||||
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
|
||||
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
|
||||
FROM chats
|
||||
WHERE id = $1::uuid
|
||||
FOR SHARE
|
||||
@@ -6646,7 +6636,6 @@ chats_expanded AS (
|
||||
shared_chat.agent_id,
|
||||
shared_chat.pin_order,
|
||||
shared_chat.last_read_message_id,
|
||||
shared_chat.last_injected_context,
|
||||
shared_chat.dynamic_tools,
|
||||
shared_chat.organization_id,
|
||||
shared_chat.plan_mode,
|
||||
@@ -6673,7 +6662,7 @@ chats_expanded AS (
|
||||
LEFT JOIN chats root ON root.id = COALESCE(shared_chat.root_chat_id, shared_chat.parent_chat_id)
|
||||
JOIN visible_users owner ON owner.id = shared_chat.owner_id
|
||||
)
|
||||
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
|
||||
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
|
||||
FROM chats_expanded
|
||||
`
|
||||
|
||||
@@ -6703,7 +6692,6 @@ func (q *sqlQuerier) GetChatByIDForShare(ctx context.Context, id uuid.UUID) (Cha
|
||||
&i.AgentID,
|
||||
&i.PinOrder,
|
||||
&i.LastReadMessageID,
|
||||
&i.LastInjectedContext,
|
||||
&i.DynamicTools,
|
||||
&i.OrganizationID,
|
||||
&i.PlanMode,
|
||||
@@ -6731,7 +6719,7 @@ func (q *sqlQuerier) GetChatByIDForShare(ctx context.Context, id uuid.UUID) (Cha
|
||||
|
||||
const getChatByIDForUpdate = `-- name: GetChatByIDForUpdate :one
|
||||
WITH locked_chat AS (
|
||||
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
|
||||
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
|
||||
FROM chats
|
||||
WHERE id = $1::uuid
|
||||
FOR UPDATE
|
||||
@@ -6760,7 +6748,6 @@ chats_expanded AS (
|
||||
locked_chat.agent_id,
|
||||
locked_chat.pin_order,
|
||||
locked_chat.last_read_message_id,
|
||||
locked_chat.last_injected_context,
|
||||
locked_chat.dynamic_tools,
|
||||
locked_chat.organization_id,
|
||||
locked_chat.plan_mode,
|
||||
@@ -6787,7 +6774,7 @@ chats_expanded AS (
|
||||
LEFT JOIN chats root ON root.id = COALESCE(locked_chat.root_chat_id, locked_chat.parent_chat_id)
|
||||
JOIN visible_users owner ON owner.id = locked_chat.owner_id
|
||||
)
|
||||
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
|
||||
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
|
||||
FROM chats_expanded
|
||||
`
|
||||
|
||||
@@ -6817,7 +6804,6 @@ func (q *sqlQuerier) GetChatByIDForUpdate(ctx context.Context, id uuid.UUID) (Ch
|
||||
&i.AgentID,
|
||||
&i.PinOrder,
|
||||
&i.LastReadMessageID,
|
||||
&i.LastInjectedContext,
|
||||
&i.DynamicTools,
|
||||
&i.OrganizationID,
|
||||
&i.PlanMode,
|
||||
@@ -8287,7 +8273,7 @@ func (q *sqlQuerier) GetChatUserPromptsByChatID(ctx context.Context, arg GetChat
|
||||
|
||||
const getChatWorkerAcquisitionCandidates = `-- name: GetChatWorkerAcquisitionCandidates :many
|
||||
SELECT
|
||||
chats_expanded.id, chats_expanded.owner_id, chats_expanded.workspace_id, chats_expanded.title, chats_expanded.status, chats_expanded.worker_id, chats_expanded.started_at, chats_expanded.heartbeat_at, chats_expanded.created_at, chats_expanded.updated_at, chats_expanded.parent_chat_id, chats_expanded.root_chat_id, chats_expanded.last_model_config_id, chats_expanded.archived, chats_expanded.last_error, chats_expanded.mode, chats_expanded.mcp_server_ids, chats_expanded.labels, chats_expanded.build_id, chats_expanded.agent_id, chats_expanded.pin_order, chats_expanded.last_read_message_id, chats_expanded.last_injected_context, chats_expanded.dynamic_tools, chats_expanded.organization_id, chats_expanded.plan_mode, chats_expanded.client_type, chats_expanded.last_turn_summary, chats_expanded.snapshot_version, chats_expanded.history_version, chats_expanded.queue_version, chats_expanded.generation_attempt, chats_expanded.retry_state, chats_expanded.retry_state_version, chats_expanded.runner_id, chats_expanded.requires_action_deadline_at, chats_expanded.user_acl, chats_expanded.group_acl, chats_expanded.owner_username, chats_expanded.owner_name, chats_expanded.context_aggregate_hash, chats_expanded.context_dirty_since, chats_expanded.context_dirty_resources, chats_expanded.context_error,
|
||||
chats_expanded.id, chats_expanded.owner_id, chats_expanded.workspace_id, chats_expanded.title, chats_expanded.status, chats_expanded.worker_id, chats_expanded.started_at, chats_expanded.heartbeat_at, chats_expanded.created_at, chats_expanded.updated_at, chats_expanded.parent_chat_id, chats_expanded.root_chat_id, chats_expanded.last_model_config_id, chats_expanded.archived, chats_expanded.last_error, chats_expanded.mode, chats_expanded.mcp_server_ids, chats_expanded.labels, chats_expanded.build_id, chats_expanded.agent_id, chats_expanded.pin_order, chats_expanded.last_read_message_id, chats_expanded.dynamic_tools, chats_expanded.organization_id, chats_expanded.plan_mode, chats_expanded.client_type, chats_expanded.last_turn_summary, chats_expanded.snapshot_version, chats_expanded.history_version, chats_expanded.queue_version, chats_expanded.generation_attempt, chats_expanded.retry_state, chats_expanded.retry_state_version, chats_expanded.runner_id, chats_expanded.requires_action_deadline_at, chats_expanded.user_acl, chats_expanded.group_acl, chats_expanded.owner_username, chats_expanded.owner_name, chats_expanded.context_aggregate_hash, chats_expanded.context_dirty_since, chats_expanded.context_dirty_resources, chats_expanded.context_error,
|
||||
chat_heartbeats.heartbeat_at AS current_heartbeat_at,
|
||||
NOT EXISTS (
|
||||
SELECT 1
|
||||
@@ -8346,7 +8332,6 @@ type GetChatWorkerAcquisitionCandidatesRow struct {
|
||||
AgentID uuid.NullUUID `db:"agent_id" json:"agent_id"`
|
||||
PinOrder int32 `db:"pin_order" json:"pin_order"`
|
||||
LastReadMessageID sql.NullInt64 `db:"last_read_message_id" json:"last_read_message_id"`
|
||||
LastInjectedContext pqtype.NullRawMessage `db:"last_injected_context" json:"last_injected_context"`
|
||||
DynamicTools pqtype.NullRawMessage `db:"dynamic_tools" json:"dynamic_tools"`
|
||||
OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"`
|
||||
PlanMode NullChatPlanMode `db:"plan_mode" json:"plan_mode"`
|
||||
@@ -8415,7 +8400,6 @@ func (q *sqlQuerier) GetChatWorkerAcquisitionCandidates(ctx context.Context, arg
|
||||
&i.AgentID,
|
||||
&i.PinOrder,
|
||||
&i.LastReadMessageID,
|
||||
&i.LastInjectedContext,
|
||||
&i.DynamicTools,
|
||||
&i.OrganizationID,
|
||||
&i.PlanMode,
|
||||
@@ -8463,7 +8447,7 @@ WITH cursor_chat AS (
|
||||
WHERE id = $7
|
||||
)
|
||||
SELECT
|
||||
chats_expanded.id, chats_expanded.owner_id, chats_expanded.workspace_id, chats_expanded.title, chats_expanded.status, chats_expanded.worker_id, chats_expanded.started_at, chats_expanded.heartbeat_at, chats_expanded.created_at, chats_expanded.updated_at, chats_expanded.parent_chat_id, chats_expanded.root_chat_id, chats_expanded.last_model_config_id, chats_expanded.archived, chats_expanded.last_error, chats_expanded.mode, chats_expanded.mcp_server_ids, chats_expanded.labels, chats_expanded.build_id, chats_expanded.agent_id, chats_expanded.pin_order, chats_expanded.last_read_message_id, chats_expanded.last_injected_context, chats_expanded.dynamic_tools, chats_expanded.organization_id, chats_expanded.plan_mode, chats_expanded.client_type, chats_expanded.last_turn_summary, chats_expanded.snapshot_version, chats_expanded.history_version, chats_expanded.queue_version, chats_expanded.generation_attempt, chats_expanded.retry_state, chats_expanded.retry_state_version, chats_expanded.runner_id, chats_expanded.requires_action_deadline_at, chats_expanded.user_acl, chats_expanded.group_acl, chats_expanded.owner_username, chats_expanded.owner_name, chats_expanded.context_aggregate_hash, chats_expanded.context_dirty_since, chats_expanded.context_dirty_resources, chats_expanded.context_error,
|
||||
chats_expanded.id, chats_expanded.owner_id, chats_expanded.workspace_id, chats_expanded.title, chats_expanded.status, chats_expanded.worker_id, chats_expanded.started_at, chats_expanded.heartbeat_at, chats_expanded.created_at, chats_expanded.updated_at, chats_expanded.parent_chat_id, chats_expanded.root_chat_id, chats_expanded.last_model_config_id, chats_expanded.archived, chats_expanded.last_error, chats_expanded.mode, chats_expanded.mcp_server_ids, chats_expanded.labels, chats_expanded.build_id, chats_expanded.agent_id, chats_expanded.pin_order, chats_expanded.last_read_message_id, chats_expanded.dynamic_tools, chats_expanded.organization_id, chats_expanded.plan_mode, chats_expanded.client_type, chats_expanded.last_turn_summary, chats_expanded.snapshot_version, chats_expanded.history_version, chats_expanded.queue_version, chats_expanded.generation_attempt, chats_expanded.retry_state, chats_expanded.retry_state_version, chats_expanded.runner_id, chats_expanded.requires_action_deadline_at, chats_expanded.user_acl, chats_expanded.group_acl, chats_expanded.owner_username, chats_expanded.owner_name, chats_expanded.context_aggregate_hash, chats_expanded.context_dirty_since, chats_expanded.context_dirty_resources, chats_expanded.context_error,
|
||||
EXISTS (
|
||||
SELECT 1 FROM chat_messages cm
|
||||
WHERE cm.chat_id = chats_expanded.id
|
||||
@@ -8697,7 +8681,6 @@ func (q *sqlQuerier) GetChats(ctx context.Context, arg GetChatsParams) ([]GetCha
|
||||
&i.Chat.AgentID,
|
||||
&i.Chat.PinOrder,
|
||||
&i.Chat.LastReadMessageID,
|
||||
&i.Chat.LastInjectedContext,
|
||||
&i.Chat.DynamicTools,
|
||||
&i.Chat.OrganizationID,
|
||||
&i.Chat.PlanMode,
|
||||
@@ -8736,7 +8719,7 @@ func (q *sqlQuerier) GetChats(ctx context.Context, arg GetChatsParams) ([]GetCha
|
||||
|
||||
const getChatsByChatFileID = `-- name: GetChatsByChatFileID :many
|
||||
SELECT
|
||||
id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
|
||||
id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
|
||||
FROM
|
||||
chats_expanded
|
||||
WHERE
|
||||
@@ -8781,7 +8764,6 @@ func (q *sqlQuerier) GetChatsByChatFileID(ctx context.Context, fileID uuid.UUID)
|
||||
&i.AgentID,
|
||||
&i.PinOrder,
|
||||
&i.LastReadMessageID,
|
||||
&i.LastInjectedContext,
|
||||
&i.DynamicTools,
|
||||
&i.OrganizationID,
|
||||
&i.PlanMode,
|
||||
@@ -8818,7 +8800,7 @@ func (q *sqlQuerier) GetChatsByChatFileID(ctx context.Context, fileID uuid.UUID)
|
||||
}
|
||||
|
||||
const getChatsByIDsForRunnerSync = `-- name: GetChatsByIDsForRunnerSync :many
|
||||
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
|
||||
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
|
||||
FROM chats_expanded
|
||||
WHERE id = ANY($1::uuid[])
|
||||
ORDER BY id ASC
|
||||
@@ -8856,7 +8838,6 @@ func (q *sqlQuerier) GetChatsByIDsForRunnerSync(ctx context.Context, ids []uuid.
|
||||
&i.AgentID,
|
||||
&i.PinOrder,
|
||||
&i.LastReadMessageID,
|
||||
&i.LastInjectedContext,
|
||||
&i.DynamicTools,
|
||||
&i.OrganizationID,
|
||||
&i.PlanMode,
|
||||
@@ -8893,7 +8874,7 @@ func (q *sqlQuerier) GetChatsByIDsForRunnerSync(ctx context.Context, ids []uuid.
|
||||
}
|
||||
|
||||
const getChatsByWorkspaceIDs = `-- name: GetChatsByWorkspaceIDs :many
|
||||
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
|
||||
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
|
||||
FROM chats_expanded
|
||||
WHERE archived = false
|
||||
AND workspace_id = ANY($1::uuid[])
|
||||
@@ -8932,7 +8913,6 @@ func (q *sqlQuerier) GetChatsByWorkspaceIDs(ctx context.Context, ids []uuid.UUID
|
||||
&i.AgentID,
|
||||
&i.PinOrder,
|
||||
&i.LastReadMessageID,
|
||||
&i.LastInjectedContext,
|
||||
&i.DynamicTools,
|
||||
&i.OrganizationID,
|
||||
&i.PlanMode,
|
||||
@@ -9038,7 +9018,7 @@ func (q *sqlQuerier) GetChatsUpdatedAfter(ctx context.Context, updatedAfter time
|
||||
|
||||
const getChildChatsByParentIDs = `-- name: GetChildChatsByParentIDs :many
|
||||
SELECT
|
||||
chats_expanded.id, chats_expanded.owner_id, chats_expanded.workspace_id, chats_expanded.title, chats_expanded.status, chats_expanded.worker_id, chats_expanded.started_at, chats_expanded.heartbeat_at, chats_expanded.created_at, chats_expanded.updated_at, chats_expanded.parent_chat_id, chats_expanded.root_chat_id, chats_expanded.last_model_config_id, chats_expanded.archived, chats_expanded.last_error, chats_expanded.mode, chats_expanded.mcp_server_ids, chats_expanded.labels, chats_expanded.build_id, chats_expanded.agent_id, chats_expanded.pin_order, chats_expanded.last_read_message_id, chats_expanded.last_injected_context, chats_expanded.dynamic_tools, chats_expanded.organization_id, chats_expanded.plan_mode, chats_expanded.client_type, chats_expanded.last_turn_summary, chats_expanded.snapshot_version, chats_expanded.history_version, chats_expanded.queue_version, chats_expanded.generation_attempt, chats_expanded.retry_state, chats_expanded.retry_state_version, chats_expanded.runner_id, chats_expanded.requires_action_deadline_at, chats_expanded.user_acl, chats_expanded.group_acl, chats_expanded.owner_username, chats_expanded.owner_name, chats_expanded.context_aggregate_hash, chats_expanded.context_dirty_since, chats_expanded.context_dirty_resources, chats_expanded.context_error,
|
||||
chats_expanded.id, chats_expanded.owner_id, chats_expanded.workspace_id, chats_expanded.title, chats_expanded.status, chats_expanded.worker_id, chats_expanded.started_at, chats_expanded.heartbeat_at, chats_expanded.created_at, chats_expanded.updated_at, chats_expanded.parent_chat_id, chats_expanded.root_chat_id, chats_expanded.last_model_config_id, chats_expanded.archived, chats_expanded.last_error, chats_expanded.mode, chats_expanded.mcp_server_ids, chats_expanded.labels, chats_expanded.build_id, chats_expanded.agent_id, chats_expanded.pin_order, chats_expanded.last_read_message_id, chats_expanded.dynamic_tools, chats_expanded.organization_id, chats_expanded.plan_mode, chats_expanded.client_type, chats_expanded.last_turn_summary, chats_expanded.snapshot_version, chats_expanded.history_version, chats_expanded.queue_version, chats_expanded.generation_attempt, chats_expanded.retry_state, chats_expanded.retry_state_version, chats_expanded.runner_id, chats_expanded.requires_action_deadline_at, chats_expanded.user_acl, chats_expanded.group_acl, chats_expanded.owner_username, chats_expanded.owner_name, chats_expanded.context_aggregate_hash, chats_expanded.context_dirty_since, chats_expanded.context_dirty_resources, chats_expanded.context_error,
|
||||
EXISTS (
|
||||
SELECT 1 FROM chat_messages cm
|
||||
WHERE cm.chat_id = chats_expanded.id
|
||||
@@ -9105,7 +9085,6 @@ func (q *sqlQuerier) GetChildChatsByParentIDs(ctx context.Context, arg GetChildC
|
||||
&i.Chat.AgentID,
|
||||
&i.Chat.PinOrder,
|
||||
&i.Chat.LastReadMessageID,
|
||||
&i.Chat.LastInjectedContext,
|
||||
&i.Chat.DynamicTools,
|
||||
&i.Chat.OrganizationID,
|
||||
&i.Chat.PlanMode,
|
||||
@@ -9209,7 +9188,7 @@ func (q *sqlQuerier) GetLastChatMessageByRole(ctx context.Context, arg GetLastCh
|
||||
|
||||
const getStaleChats = `-- name: GetStaleChats :many
|
||||
SELECT
|
||||
id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
|
||||
id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
|
||||
FROM
|
||||
chats_expanded
|
||||
WHERE
|
||||
@@ -9264,7 +9243,6 @@ func (q *sqlQuerier) GetStaleChats(ctx context.Context, staleThreshold time.Time
|
||||
&i.AgentID,
|
||||
&i.PinOrder,
|
||||
&i.LastReadMessageID,
|
||||
&i.LastInjectedContext,
|
||||
&i.DynamicTools,
|
||||
&i.OrganizationID,
|
||||
&i.PlanMode,
|
||||
@@ -9491,7 +9469,7 @@ INSERT INTO chats (
|
||||
$15::jsonb,
|
||||
$16::chat_client_type
|
||||
)
|
||||
RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
|
||||
RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
|
||||
),
|
||||
chats_expanded AS (
|
||||
SELECT
|
||||
@@ -9517,7 +9495,6 @@ chats_expanded AS (
|
||||
inserted_chat.agent_id,
|
||||
inserted_chat.pin_order,
|
||||
inserted_chat.last_read_message_id,
|
||||
inserted_chat.last_injected_context,
|
||||
inserted_chat.dynamic_tools,
|
||||
inserted_chat.organization_id,
|
||||
inserted_chat.plan_mode,
|
||||
@@ -9544,7 +9521,7 @@ chats_expanded AS (
|
||||
LEFT JOIN chats root ON root.id = COALESCE(inserted_chat.root_chat_id, inserted_chat.parent_chat_id)
|
||||
JOIN visible_users owner ON owner.id = inserted_chat.owner_id
|
||||
)
|
||||
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
|
||||
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
|
||||
FROM chats_expanded
|
||||
`
|
||||
|
||||
@@ -9610,7 +9587,6 @@ func (q *sqlQuerier) InsertChat(ctx context.Context, arg InsertChatParams) (Chat
|
||||
&i.AgentID,
|
||||
&i.PinOrder,
|
||||
&i.LastReadMessageID,
|
||||
&i.LastInjectedContext,
|
||||
&i.DynamicTools,
|
||||
&i.OrganizationID,
|
||||
&i.PlanMode,
|
||||
@@ -10117,7 +10093,7 @@ WITH bumped_chat AS (
|
||||
WHERE id = $1::uuid
|
||||
FOR UPDATE
|
||||
)
|
||||
RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
|
||||
RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
|
||||
),
|
||||
chats_expanded AS (
|
||||
SELECT
|
||||
@@ -10143,7 +10119,6 @@ chats_expanded AS (
|
||||
bumped_chat.agent_id,
|
||||
bumped_chat.pin_order,
|
||||
bumped_chat.last_read_message_id,
|
||||
bumped_chat.last_injected_context,
|
||||
bumped_chat.dynamic_tools,
|
||||
bumped_chat.organization_id,
|
||||
bumped_chat.plan_mode,
|
||||
@@ -10169,7 +10144,7 @@ chats_expanded AS (
|
||||
LEFT JOIN chats root ON root.id = COALESCE(bumped_chat.root_chat_id, bumped_chat.parent_chat_id)
|
||||
JOIN visible_users owner ON owner.id = bumped_chat.owner_id
|
||||
)
|
||||
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
|
||||
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
|
||||
FROM chats_expanded
|
||||
`
|
||||
|
||||
@@ -10203,7 +10178,6 @@ func (q *sqlQuerier) LockChatAndBumpSnapshotVersion(ctx context.Context, id uuid
|
||||
&i.AgentID,
|
||||
&i.PinOrder,
|
||||
&i.LastReadMessageID,
|
||||
&i.LastInjectedContext,
|
||||
&i.DynamicTools,
|
||||
&i.OrganizationID,
|
||||
&i.PlanMode,
|
||||
@@ -10555,7 +10529,7 @@ WITH updated_chats AS (
|
||||
archived = false,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1::uuid OR root_chat_id = $1::uuid
|
||||
RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
|
||||
RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
|
||||
),
|
||||
chats_expanded AS (
|
||||
SELECT
|
||||
@@ -10581,7 +10555,6 @@ chats_expanded AS (
|
||||
updated_chats.agent_id,
|
||||
updated_chats.pin_order,
|
||||
updated_chats.last_read_message_id,
|
||||
updated_chats.last_injected_context,
|
||||
updated_chats.dynamic_tools,
|
||||
updated_chats.organization_id,
|
||||
updated_chats.plan_mode,
|
||||
@@ -10608,7 +10581,7 @@ chats_expanded AS (
|
||||
LEFT JOIN chats root ON root.id = COALESCE(updated_chats.root_chat_id, updated_chats.parent_chat_id)
|
||||
JOIN visible_users owner ON owner.id = updated_chats.owner_id
|
||||
)
|
||||
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
|
||||
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
|
||||
FROM chats_expanded
|
||||
ORDER BY (chats_expanded.id = $1::uuid) DESC, chats_expanded.created_at ASC, chats_expanded.id ASC
|
||||
`
|
||||
@@ -10649,7 +10622,6 @@ func (q *sqlQuerier) UnarchiveChatByID(ctx context.Context, id uuid.UUID) ([]Cha
|
||||
&i.AgentID,
|
||||
&i.PinOrder,
|
||||
&i.LastReadMessageID,
|
||||
&i.LastInjectedContext,
|
||||
&i.DynamicTools,
|
||||
&i.OrganizationID,
|
||||
&i.PlanMode,
|
||||
@@ -10773,7 +10745,7 @@ UPDATE chats SET
|
||||
updated_at = NOW()
|
||||
WHERE
|
||||
id = $3::uuid
|
||||
RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
|
||||
RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
|
||||
),
|
||||
chats_expanded AS (
|
||||
SELECT
|
||||
@@ -10799,7 +10771,6 @@ chats_expanded AS (
|
||||
updated_chat.agent_id,
|
||||
updated_chat.pin_order,
|
||||
updated_chat.last_read_message_id,
|
||||
updated_chat.last_injected_context,
|
||||
updated_chat.dynamic_tools,
|
||||
updated_chat.organization_id,
|
||||
updated_chat.plan_mode,
|
||||
@@ -10826,7 +10797,7 @@ chats_expanded AS (
|
||||
LEFT JOIN chats root ON root.id = COALESCE(updated_chat.root_chat_id, updated_chat.parent_chat_id)
|
||||
JOIN visible_users owner ON owner.id = updated_chat.owner_id
|
||||
)
|
||||
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
|
||||
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
|
||||
FROM chats_expanded
|
||||
`
|
||||
|
||||
@@ -10862,7 +10833,6 @@ func (q *sqlQuerier) UpdateChatBuildAgentBinding(ctx context.Context, arg Update
|
||||
&i.AgentID,
|
||||
&i.PinOrder,
|
||||
&i.LastReadMessageID,
|
||||
&i.LastInjectedContext,
|
||||
&i.DynamicTools,
|
||||
&i.OrganizationID,
|
||||
&i.PlanMode,
|
||||
@@ -10897,7 +10867,7 @@ SET
|
||||
updated_at = NOW()
|
||||
WHERE
|
||||
id = $2::uuid
|
||||
RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
|
||||
RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
|
||||
),
|
||||
chats_expanded AS (
|
||||
SELECT
|
||||
@@ -10923,7 +10893,6 @@ chats_expanded AS (
|
||||
updated_chat.agent_id,
|
||||
updated_chat.pin_order,
|
||||
updated_chat.last_read_message_id,
|
||||
updated_chat.last_injected_context,
|
||||
updated_chat.dynamic_tools,
|
||||
updated_chat.organization_id,
|
||||
updated_chat.plan_mode,
|
||||
@@ -10950,7 +10919,7 @@ chats_expanded AS (
|
||||
LEFT JOIN chats root ON root.id = COALESCE(updated_chat.root_chat_id, updated_chat.parent_chat_id)
|
||||
JOIN visible_users owner ON owner.id = updated_chat.owner_id
|
||||
)
|
||||
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
|
||||
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
|
||||
FROM chats_expanded
|
||||
`
|
||||
|
||||
@@ -10985,7 +10954,6 @@ func (q *sqlQuerier) UpdateChatByID(ctx context.Context, arg UpdateChatByIDParam
|
||||
&i.AgentID,
|
||||
&i.PinOrder,
|
||||
&i.LastReadMessageID,
|
||||
&i.LastInjectedContext,
|
||||
&i.DynamicTools,
|
||||
&i.OrganizationID,
|
||||
&i.PlanMode,
|
||||
@@ -11024,7 +10992,7 @@ WITH updated_chat AS (
|
||||
pin_order = CASE WHEN $2::boolean THEN 0 ELSE pin_order END,
|
||||
updated_at = NOW()
|
||||
WHERE id = $7::uuid
|
||||
RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
|
||||
RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
|
||||
),
|
||||
chats_expanded AS (
|
||||
SELECT
|
||||
@@ -11050,7 +11018,6 @@ chats_expanded AS (
|
||||
updated_chat.agent_id,
|
||||
updated_chat.pin_order,
|
||||
updated_chat.last_read_message_id,
|
||||
updated_chat.last_injected_context,
|
||||
updated_chat.dynamic_tools,
|
||||
updated_chat.organization_id,
|
||||
updated_chat.plan_mode,
|
||||
@@ -11076,7 +11043,7 @@ chats_expanded AS (
|
||||
LEFT JOIN chats root ON root.id = COALESCE(updated_chat.root_chat_id, updated_chat.parent_chat_id)
|
||||
JOIN visible_users owner ON owner.id = updated_chat.owner_id
|
||||
)
|
||||
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
|
||||
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
|
||||
FROM chats_expanded
|
||||
`
|
||||
|
||||
@@ -11128,7 +11095,6 @@ func (q *sqlQuerier) UpdateChatExecutionState(ctx context.Context, arg UpdateCha
|
||||
&i.AgentID,
|
||||
&i.PinOrder,
|
||||
&i.LastReadMessageID,
|
||||
&i.LastInjectedContext,
|
||||
&i.DynamicTools,
|
||||
&i.OrganizationID,
|
||||
&i.PlanMode,
|
||||
@@ -11208,7 +11174,7 @@ SET
|
||||
updated_at = NOW()
|
||||
WHERE
|
||||
id = $2::uuid
|
||||
RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
|
||||
RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
|
||||
),
|
||||
chats_expanded AS (
|
||||
SELECT
|
||||
@@ -11234,7 +11200,6 @@ chats_expanded AS (
|
||||
updated_chat.agent_id,
|
||||
updated_chat.pin_order,
|
||||
updated_chat.last_read_message_id,
|
||||
updated_chat.last_injected_context,
|
||||
updated_chat.dynamic_tools,
|
||||
updated_chat.organization_id,
|
||||
updated_chat.plan_mode,
|
||||
@@ -11261,7 +11226,7 @@ chats_expanded AS (
|
||||
LEFT JOIN chats root ON root.id = COALESCE(updated_chat.root_chat_id, updated_chat.parent_chat_id)
|
||||
JOIN visible_users owner ON owner.id = updated_chat.owner_id
|
||||
)
|
||||
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
|
||||
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
|
||||
FROM chats_expanded
|
||||
`
|
||||
|
||||
@@ -11296,131 +11261,6 @@ func (q *sqlQuerier) UpdateChatLabelsByID(ctx context.Context, arg UpdateChatLab
|
||||
&i.AgentID,
|
||||
&i.PinOrder,
|
||||
&i.LastReadMessageID,
|
||||
&i.LastInjectedContext,
|
||||
&i.DynamicTools,
|
||||
&i.OrganizationID,
|
||||
&i.PlanMode,
|
||||
&i.ClientType,
|
||||
&i.LastTurnSummary,
|
||||
&i.SnapshotVersion,
|
||||
&i.HistoryVersion,
|
||||
&i.QueueVersion,
|
||||
&i.GenerationAttempt,
|
||||
&i.RetryState,
|
||||
&i.RetryStateVersion,
|
||||
&i.RunnerID,
|
||||
&i.RequiresActionDeadlineAt,
|
||||
&i.UserACL,
|
||||
&i.GroupACL,
|
||||
&i.OwnerUsername,
|
||||
&i.OwnerName,
|
||||
&i.ContextAggregateHash,
|
||||
&i.ContextDirtySince,
|
||||
&i.ContextDirtyResources,
|
||||
&i.ContextError,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const updateChatLastInjectedContext = `-- name: UpdateChatLastInjectedContext :one
|
||||
WITH updated_chat AS (
|
||||
UPDATE chats SET
|
||||
last_injected_context = $1::jsonb
|
||||
WHERE
|
||||
id = $2::uuid
|
||||
RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
|
||||
),
|
||||
chats_expanded AS (
|
||||
SELECT
|
||||
updated_chat.id,
|
||||
updated_chat.owner_id,
|
||||
updated_chat.workspace_id,
|
||||
updated_chat.title,
|
||||
updated_chat.status,
|
||||
updated_chat.worker_id,
|
||||
updated_chat.started_at,
|
||||
updated_chat.heartbeat_at,
|
||||
updated_chat.created_at,
|
||||
updated_chat.updated_at,
|
||||
updated_chat.parent_chat_id,
|
||||
updated_chat.root_chat_id,
|
||||
updated_chat.last_model_config_id,
|
||||
updated_chat.archived,
|
||||
updated_chat.last_error,
|
||||
updated_chat.mode,
|
||||
updated_chat.mcp_server_ids,
|
||||
updated_chat.labels,
|
||||
updated_chat.build_id,
|
||||
updated_chat.agent_id,
|
||||
updated_chat.pin_order,
|
||||
updated_chat.last_read_message_id,
|
||||
updated_chat.last_injected_context,
|
||||
updated_chat.dynamic_tools,
|
||||
updated_chat.organization_id,
|
||||
updated_chat.plan_mode,
|
||||
updated_chat.client_type,
|
||||
updated_chat.last_turn_summary,
|
||||
updated_chat.snapshot_version,
|
||||
updated_chat.history_version,
|
||||
updated_chat.queue_version,
|
||||
updated_chat.generation_attempt,
|
||||
updated_chat.retry_state,
|
||||
updated_chat.retry_state_version,
|
||||
updated_chat.runner_id,
|
||||
updated_chat.requires_action_deadline_at,
|
||||
COALESCE(root.user_acl, updated_chat.user_acl) AS user_acl,
|
||||
COALESCE(root.group_acl, updated_chat.group_acl) AS group_acl,
|
||||
owner.username AS owner_username,
|
||||
owner.name AS owner_name,
|
||||
updated_chat.context_aggregate_hash,
|
||||
updated_chat.context_dirty_since,
|
||||
updated_chat.context_dirty_resources,
|
||||
updated_chat.context_error
|
||||
FROM
|
||||
updated_chat
|
||||
LEFT JOIN chats root ON root.id = COALESCE(updated_chat.root_chat_id, updated_chat.parent_chat_id)
|
||||
JOIN visible_users owner ON owner.id = updated_chat.owner_id
|
||||
)
|
||||
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
|
||||
FROM chats_expanded
|
||||
`
|
||||
|
||||
type UpdateChatLastInjectedContextParams struct {
|
||||
LastInjectedContext pqtype.NullRawMessage `db:"last_injected_context" json:"last_injected_context"`
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
}
|
||||
|
||||
// Updates the cached injected context parts (AGENTS.md +
|
||||
// skills) on the chat row. Called only when context changes
|
||||
// (first workspace attach or agent change). updated_at is
|
||||
// intentionally not touched to avoid reordering the chat list.
|
||||
func (q *sqlQuerier) UpdateChatLastInjectedContext(ctx context.Context, arg UpdateChatLastInjectedContextParams) (Chat, error) {
|
||||
row := q.db.QueryRowContext(ctx, updateChatLastInjectedContext, arg.LastInjectedContext, arg.ID)
|
||||
var i Chat
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.OwnerID,
|
||||
&i.WorkspaceID,
|
||||
&i.Title,
|
||||
&i.Status,
|
||||
&i.WorkerID,
|
||||
&i.StartedAt,
|
||||
&i.HeartbeatAt,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.ParentChatID,
|
||||
&i.RootChatID,
|
||||
&i.LastModelConfigID,
|
||||
&i.Archived,
|
||||
&i.LastError,
|
||||
&i.Mode,
|
||||
pq.Array(&i.MCPServerIDs),
|
||||
&i.Labels,
|
||||
&i.BuildID,
|
||||
&i.AgentID,
|
||||
&i.PinOrder,
|
||||
&i.LastReadMessageID,
|
||||
&i.LastInjectedContext,
|
||||
&i.DynamicTools,
|
||||
&i.OrganizationID,
|
||||
&i.PlanMode,
|
||||
@@ -11455,7 +11295,7 @@ SET
|
||||
last_model_config_id = $1::uuid
|
||||
WHERE
|
||||
id = $2::uuid
|
||||
RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
|
||||
RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
|
||||
),
|
||||
chats_expanded AS (
|
||||
SELECT
|
||||
@@ -11481,7 +11321,6 @@ chats_expanded AS (
|
||||
updated_chat.agent_id,
|
||||
updated_chat.pin_order,
|
||||
updated_chat.last_read_message_id,
|
||||
updated_chat.last_injected_context,
|
||||
updated_chat.dynamic_tools,
|
||||
updated_chat.organization_id,
|
||||
updated_chat.plan_mode,
|
||||
@@ -11508,7 +11347,7 @@ chats_expanded AS (
|
||||
LEFT JOIN chats root ON root.id = COALESCE(updated_chat.root_chat_id, updated_chat.parent_chat_id)
|
||||
JOIN visible_users owner ON owner.id = updated_chat.owner_id
|
||||
)
|
||||
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
|
||||
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
|
||||
FROM chats_expanded
|
||||
`
|
||||
|
||||
@@ -11543,7 +11382,6 @@ func (q *sqlQuerier) UpdateChatLastModelConfigByID(ctx context.Context, arg Upda
|
||||
&i.AgentID,
|
||||
&i.PinOrder,
|
||||
&i.LastReadMessageID,
|
||||
&i.LastInjectedContext,
|
||||
&i.DynamicTools,
|
||||
&i.OrganizationID,
|
||||
&i.PlanMode,
|
||||
@@ -11628,7 +11466,7 @@ SET
|
||||
updated_at = NOW()
|
||||
WHERE
|
||||
id = $2::uuid
|
||||
RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
|
||||
RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
|
||||
),
|
||||
chats_expanded AS (
|
||||
SELECT
|
||||
@@ -11654,7 +11492,6 @@ chats_expanded AS (
|
||||
updated_chat.agent_id,
|
||||
updated_chat.pin_order,
|
||||
updated_chat.last_read_message_id,
|
||||
updated_chat.last_injected_context,
|
||||
updated_chat.dynamic_tools,
|
||||
updated_chat.organization_id,
|
||||
updated_chat.plan_mode,
|
||||
@@ -11681,7 +11518,7 @@ chats_expanded AS (
|
||||
LEFT JOIN chats root ON root.id = COALESCE(updated_chat.root_chat_id, updated_chat.parent_chat_id)
|
||||
JOIN visible_users owner ON owner.id = updated_chat.owner_id
|
||||
)
|
||||
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
|
||||
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
|
||||
FROM chats_expanded
|
||||
`
|
||||
|
||||
@@ -11716,7 +11553,6 @@ func (q *sqlQuerier) UpdateChatMCPServerIDs(ctx context.Context, arg UpdateChatM
|
||||
&i.AgentID,
|
||||
&i.PinOrder,
|
||||
&i.LastReadMessageID,
|
||||
&i.LastInjectedContext,
|
||||
&i.DynamicTools,
|
||||
&i.OrganizationID,
|
||||
&i.PlanMode,
|
||||
@@ -11871,7 +11707,7 @@ SET
|
||||
plan_mode = $1::chat_plan_mode
|
||||
WHERE
|
||||
id = $2::uuid
|
||||
RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
|
||||
RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
|
||||
),
|
||||
chats_expanded AS (
|
||||
SELECT
|
||||
@@ -11897,7 +11733,6 @@ chats_expanded AS (
|
||||
updated_chat.agent_id,
|
||||
updated_chat.pin_order,
|
||||
updated_chat.last_read_message_id,
|
||||
updated_chat.last_injected_context,
|
||||
updated_chat.dynamic_tools,
|
||||
updated_chat.organization_id,
|
||||
updated_chat.plan_mode,
|
||||
@@ -11924,7 +11759,7 @@ chats_expanded AS (
|
||||
LEFT JOIN chats root ON root.id = COALESCE(updated_chat.root_chat_id, updated_chat.parent_chat_id)
|
||||
JOIN visible_users owner ON owner.id = updated_chat.owner_id
|
||||
)
|
||||
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
|
||||
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
|
||||
FROM chats_expanded
|
||||
`
|
||||
|
||||
@@ -11959,7 +11794,6 @@ func (q *sqlQuerier) UpdateChatPlanModeByID(ctx context.Context, arg UpdateChatP
|
||||
&i.AgentID,
|
||||
&i.PinOrder,
|
||||
&i.LastReadMessageID,
|
||||
&i.LastInjectedContext,
|
||||
&i.DynamicTools,
|
||||
&i.OrganizationID,
|
||||
&i.PlanMode,
|
||||
@@ -11992,7 +11826,7 @@ WITH updated_chat AS (
|
||||
retry_state = $1::jsonb,
|
||||
updated_at = NOW()
|
||||
WHERE id = $2::uuid
|
||||
RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
|
||||
RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
|
||||
),
|
||||
chats_expanded AS (
|
||||
SELECT
|
||||
@@ -12018,7 +11852,6 @@ chats_expanded AS (
|
||||
updated_chat.agent_id,
|
||||
updated_chat.pin_order,
|
||||
updated_chat.last_read_message_id,
|
||||
updated_chat.last_injected_context,
|
||||
updated_chat.dynamic_tools,
|
||||
updated_chat.organization_id,
|
||||
updated_chat.plan_mode,
|
||||
@@ -12044,7 +11877,7 @@ chats_expanded AS (
|
||||
LEFT JOIN chats root ON root.id = COALESCE(updated_chat.root_chat_id, updated_chat.parent_chat_id)
|
||||
JOIN visible_users owner ON owner.id = updated_chat.owner_id
|
||||
)
|
||||
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
|
||||
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
|
||||
FROM chats_expanded
|
||||
`
|
||||
|
||||
@@ -12081,7 +11914,6 @@ func (q *sqlQuerier) UpdateChatRetryState(ctx context.Context, arg UpdateChatRet
|
||||
&i.AgentID,
|
||||
&i.PinOrder,
|
||||
&i.LastReadMessageID,
|
||||
&i.LastInjectedContext,
|
||||
&i.DynamicTools,
|
||||
&i.OrganizationID,
|
||||
&i.PlanMode,
|
||||
@@ -12120,7 +11952,7 @@ SET
|
||||
updated_at = NOW()
|
||||
WHERE
|
||||
id = $6::uuid
|
||||
RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
|
||||
RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
|
||||
),
|
||||
chats_expanded AS (
|
||||
SELECT
|
||||
@@ -12146,7 +11978,6 @@ chats_expanded AS (
|
||||
updated_chat.agent_id,
|
||||
updated_chat.pin_order,
|
||||
updated_chat.last_read_message_id,
|
||||
updated_chat.last_injected_context,
|
||||
updated_chat.dynamic_tools,
|
||||
updated_chat.organization_id,
|
||||
updated_chat.plan_mode,
|
||||
@@ -12173,7 +12004,7 @@ chats_expanded AS (
|
||||
LEFT JOIN chats root ON root.id = COALESCE(updated_chat.root_chat_id, updated_chat.parent_chat_id)
|
||||
JOIN visible_users owner ON owner.id = updated_chat.owner_id
|
||||
)
|
||||
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
|
||||
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
|
||||
FROM chats_expanded
|
||||
`
|
||||
|
||||
@@ -12219,7 +12050,6 @@ func (q *sqlQuerier) UpdateChatStatus(ctx context.Context, arg UpdateChatStatusP
|
||||
&i.AgentID,
|
||||
&i.PinOrder,
|
||||
&i.LastReadMessageID,
|
||||
&i.LastInjectedContext,
|
||||
&i.DynamicTools,
|
||||
&i.OrganizationID,
|
||||
&i.PlanMode,
|
||||
@@ -12258,7 +12088,7 @@ SET
|
||||
updated_at = $6::timestamptz
|
||||
WHERE
|
||||
id = $7::uuid
|
||||
RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
|
||||
RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
|
||||
),
|
||||
chats_expanded AS (
|
||||
SELECT
|
||||
@@ -12284,7 +12114,6 @@ chats_expanded AS (
|
||||
updated_chat.agent_id,
|
||||
updated_chat.pin_order,
|
||||
updated_chat.last_read_message_id,
|
||||
updated_chat.last_injected_context,
|
||||
updated_chat.dynamic_tools,
|
||||
updated_chat.organization_id,
|
||||
updated_chat.plan_mode,
|
||||
@@ -12311,7 +12140,7 @@ chats_expanded AS (
|
||||
LEFT JOIN chats root ON root.id = COALESCE(updated_chat.root_chat_id, updated_chat.parent_chat_id)
|
||||
JOIN visible_users owner ON owner.id = updated_chat.owner_id
|
||||
)
|
||||
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
|
||||
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
|
||||
FROM chats_expanded
|
||||
`
|
||||
|
||||
@@ -12359,7 +12188,6 @@ func (q *sqlQuerier) UpdateChatStatusPreserveUpdatedAt(ctx context.Context, arg
|
||||
&i.AgentID,
|
||||
&i.PinOrder,
|
||||
&i.LastReadMessageID,
|
||||
&i.LastInjectedContext,
|
||||
&i.DynamicTools,
|
||||
&i.OrganizationID,
|
||||
&i.PlanMode,
|
||||
@@ -12396,7 +12224,7 @@ SET
|
||||
title = $1::text
|
||||
WHERE
|
||||
id = $2::uuid
|
||||
RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
|
||||
RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
|
||||
),
|
||||
chats_expanded AS (
|
||||
SELECT
|
||||
@@ -12422,7 +12250,6 @@ chats_expanded AS (
|
||||
updated_chat.agent_id,
|
||||
updated_chat.pin_order,
|
||||
updated_chat.last_read_message_id,
|
||||
updated_chat.last_injected_context,
|
||||
updated_chat.dynamic_tools,
|
||||
updated_chat.organization_id,
|
||||
updated_chat.plan_mode,
|
||||
@@ -12449,7 +12276,7 @@ chats_expanded AS (
|
||||
LEFT JOIN chats root ON root.id = COALESCE(updated_chat.root_chat_id, updated_chat.parent_chat_id)
|
||||
JOIN visible_users owner ON owner.id = updated_chat.owner_id
|
||||
)
|
||||
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
|
||||
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
|
||||
FROM chats_expanded
|
||||
`
|
||||
|
||||
@@ -12484,7 +12311,6 @@ func (q *sqlQuerier) UpdateChatTitleByID(ctx context.Context, arg UpdateChatTitl
|
||||
&i.AgentID,
|
||||
&i.PinOrder,
|
||||
&i.LastReadMessageID,
|
||||
&i.LastInjectedContext,
|
||||
&i.DynamicTools,
|
||||
&i.OrganizationID,
|
||||
&i.PlanMode,
|
||||
@@ -12518,7 +12344,7 @@ UPDATE chats SET
|
||||
agent_id = $3::uuid,
|
||||
updated_at = NOW()
|
||||
WHERE id = $4::uuid
|
||||
RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
|
||||
RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
|
||||
),
|
||||
chats_expanded AS (
|
||||
SELECT
|
||||
@@ -12544,7 +12370,6 @@ chats_expanded AS (
|
||||
updated_chat.agent_id,
|
||||
updated_chat.pin_order,
|
||||
updated_chat.last_read_message_id,
|
||||
updated_chat.last_injected_context,
|
||||
updated_chat.dynamic_tools,
|
||||
updated_chat.organization_id,
|
||||
updated_chat.plan_mode,
|
||||
@@ -12571,7 +12396,7 @@ chats_expanded AS (
|
||||
LEFT JOIN chats root ON root.id = COALESCE(updated_chat.root_chat_id, updated_chat.parent_chat_id)
|
||||
JOIN visible_users owner ON owner.id = updated_chat.owner_id
|
||||
)
|
||||
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
|
||||
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
|
||||
FROM chats_expanded
|
||||
`
|
||||
|
||||
@@ -12613,7 +12438,6 @@ func (q *sqlQuerier) UpdateChatWorkspaceBinding(ctx context.Context, arg UpdateC
|
||||
&i.AgentID,
|
||||
&i.PinOrder,
|
||||
&i.LastReadMessageID,
|
||||
&i.LastInjectedContext,
|
||||
&i.DynamicTools,
|
||||
&i.OrganizationID,
|
||||
&i.PlanMode,
|
||||
|
||||
@@ -29,7 +29,6 @@ chats_expanded AS (
|
||||
updated_chats.agent_id,
|
||||
updated_chats.pin_order,
|
||||
updated_chats.last_read_message_id,
|
||||
updated_chats.last_injected_context,
|
||||
updated_chats.dynamic_tools,
|
||||
updated_chats.organization_id,
|
||||
updated_chats.plan_mode,
|
||||
@@ -96,7 +95,6 @@ chats_expanded AS (
|
||||
updated_chats.agent_id,
|
||||
updated_chats.pin_order,
|
||||
updated_chats.last_read_message_id,
|
||||
updated_chats.last_injected_context,
|
||||
updated_chats.dynamic_tools,
|
||||
updated_chats.organization_id,
|
||||
updated_chats.plan_mode,
|
||||
@@ -762,7 +760,6 @@ chats_expanded AS (
|
||||
inserted_chat.agent_id,
|
||||
inserted_chat.pin_order,
|
||||
inserted_chat.last_read_message_id,
|
||||
inserted_chat.last_injected_context,
|
||||
inserted_chat.dynamic_tools,
|
||||
inserted_chat.organization_id,
|
||||
inserted_chat.plan_mode,
|
||||
@@ -911,7 +908,6 @@ chats_expanded AS (
|
||||
updated_chat.agent_id,
|
||||
updated_chat.pin_order,
|
||||
updated_chat.last_read_message_id,
|
||||
updated_chat.last_injected_context,
|
||||
updated_chat.dynamic_tools,
|
||||
updated_chat.organization_id,
|
||||
updated_chat.plan_mode,
|
||||
@@ -978,7 +974,6 @@ chats_expanded AS (
|
||||
updated_chat.agent_id,
|
||||
updated_chat.pin_order,
|
||||
updated_chat.last_read_message_id,
|
||||
updated_chat.last_injected_context,
|
||||
updated_chat.dynamic_tools,
|
||||
updated_chat.organization_id,
|
||||
updated_chat.plan_mode,
|
||||
@@ -1043,7 +1038,6 @@ chats_expanded AS (
|
||||
updated_chat.agent_id,
|
||||
updated_chat.pin_order,
|
||||
updated_chat.last_read_message_id,
|
||||
updated_chat.last_injected_context,
|
||||
updated_chat.dynamic_tools,
|
||||
updated_chat.organization_id,
|
||||
updated_chat.plan_mode,
|
||||
@@ -1108,7 +1102,6 @@ chats_expanded AS (
|
||||
updated_chat.agent_id,
|
||||
updated_chat.pin_order,
|
||||
updated_chat.last_read_message_id,
|
||||
updated_chat.last_injected_context,
|
||||
updated_chat.dynamic_tools,
|
||||
updated_chat.organization_id,
|
||||
updated_chat.plan_mode,
|
||||
@@ -1173,7 +1166,6 @@ chats_expanded AS (
|
||||
updated_chat.agent_id,
|
||||
updated_chat.pin_order,
|
||||
updated_chat.last_read_message_id,
|
||||
updated_chat.last_injected_context,
|
||||
updated_chat.dynamic_tools,
|
||||
updated_chat.organization_id,
|
||||
updated_chat.plan_mode,
|
||||
@@ -1237,7 +1229,6 @@ chats_expanded AS (
|
||||
updated_chat.agent_id,
|
||||
updated_chat.pin_order,
|
||||
updated_chat.last_read_message_id,
|
||||
updated_chat.last_injected_context,
|
||||
updated_chat.dynamic_tools,
|
||||
updated_chat.organization_id,
|
||||
updated_chat.plan_mode,
|
||||
@@ -1301,73 +1292,6 @@ chats_expanded AS (
|
||||
updated_chat.agent_id,
|
||||
updated_chat.pin_order,
|
||||
updated_chat.last_read_message_id,
|
||||
updated_chat.last_injected_context,
|
||||
updated_chat.dynamic_tools,
|
||||
updated_chat.organization_id,
|
||||
updated_chat.plan_mode,
|
||||
updated_chat.client_type,
|
||||
updated_chat.last_turn_summary,
|
||||
updated_chat.snapshot_version,
|
||||
updated_chat.history_version,
|
||||
updated_chat.queue_version,
|
||||
updated_chat.generation_attempt,
|
||||
updated_chat.retry_state,
|
||||
updated_chat.retry_state_version,
|
||||
updated_chat.runner_id,
|
||||
updated_chat.requires_action_deadline_at,
|
||||
COALESCE(root.user_acl, updated_chat.user_acl) AS user_acl,
|
||||
COALESCE(root.group_acl, updated_chat.group_acl) AS group_acl,
|
||||
owner.username AS owner_username,
|
||||
owner.name AS owner_name,
|
||||
updated_chat.context_aggregate_hash,
|
||||
updated_chat.context_dirty_since,
|
||||
updated_chat.context_dirty_resources,
|
||||
updated_chat.context_error
|
||||
FROM
|
||||
updated_chat
|
||||
LEFT JOIN chats root ON root.id = COALESCE(updated_chat.root_chat_id, updated_chat.parent_chat_id)
|
||||
JOIN visible_users owner ON owner.id = updated_chat.owner_id
|
||||
)
|
||||
SELECT *
|
||||
FROM chats_expanded;
|
||||
|
||||
-- name: UpdateChatLastInjectedContext :one
|
||||
WITH updated_chat AS (
|
||||
-- Updates the cached injected context parts (AGENTS.md +
|
||||
-- skills) on the chat row. Called only when context changes
|
||||
-- (first workspace attach or agent change). updated_at is
|
||||
-- intentionally not touched to avoid reordering the chat list.
|
||||
UPDATE chats SET
|
||||
last_injected_context = sqlc.narg('last_injected_context')::jsonb
|
||||
WHERE
|
||||
id = @id::uuid
|
||||
RETURNING *
|
||||
),
|
||||
chats_expanded AS (
|
||||
SELECT
|
||||
updated_chat.id,
|
||||
updated_chat.owner_id,
|
||||
updated_chat.workspace_id,
|
||||
updated_chat.title,
|
||||
updated_chat.status,
|
||||
updated_chat.worker_id,
|
||||
updated_chat.started_at,
|
||||
updated_chat.heartbeat_at,
|
||||
updated_chat.created_at,
|
||||
updated_chat.updated_at,
|
||||
updated_chat.parent_chat_id,
|
||||
updated_chat.root_chat_id,
|
||||
updated_chat.last_model_config_id,
|
||||
updated_chat.archived,
|
||||
updated_chat.last_error,
|
||||
updated_chat.mode,
|
||||
updated_chat.mcp_server_ids,
|
||||
updated_chat.labels,
|
||||
updated_chat.build_id,
|
||||
updated_chat.agent_id,
|
||||
updated_chat.pin_order,
|
||||
updated_chat.last_read_message_id,
|
||||
updated_chat.last_injected_context,
|
||||
updated_chat.dynamic_tools,
|
||||
updated_chat.organization_id,
|
||||
updated_chat.plan_mode,
|
||||
@@ -1449,7 +1373,6 @@ chats_expanded AS (
|
||||
updated_chat.agent_id,
|
||||
updated_chat.pin_order,
|
||||
updated_chat.last_read_message_id,
|
||||
updated_chat.last_injected_context,
|
||||
updated_chat.dynamic_tools,
|
||||
updated_chat.organization_id,
|
||||
updated_chat.plan_mode,
|
||||
@@ -1666,7 +1589,6 @@ chats_expanded AS (
|
||||
acquired_chats.agent_id,
|
||||
acquired_chats.pin_order,
|
||||
acquired_chats.last_read_message_id,
|
||||
acquired_chats.last_injected_context,
|
||||
acquired_chats.dynamic_tools,
|
||||
acquired_chats.organization_id,
|
||||
acquired_chats.plan_mode,
|
||||
@@ -1735,7 +1657,6 @@ chats_expanded AS (
|
||||
updated_chat.agent_id,
|
||||
updated_chat.pin_order,
|
||||
updated_chat.last_read_message_id,
|
||||
updated_chat.last_injected_context,
|
||||
updated_chat.dynamic_tools,
|
||||
updated_chat.organization_id,
|
||||
updated_chat.plan_mode,
|
||||
@@ -1804,7 +1725,6 @@ chats_expanded AS (
|
||||
updated_chat.agent_id,
|
||||
updated_chat.pin_order,
|
||||
updated_chat.last_read_message_id,
|
||||
updated_chat.last_injected_context,
|
||||
updated_chat.dynamic_tools,
|
||||
updated_chat.organization_id,
|
||||
updated_chat.plan_mode,
|
||||
@@ -2080,7 +2000,6 @@ chats_expanded AS (
|
||||
locked_chat.agent_id,
|
||||
locked_chat.pin_order,
|
||||
locked_chat.last_read_message_id,
|
||||
locked_chat.last_injected_context,
|
||||
locked_chat.dynamic_tools,
|
||||
locked_chat.organization_id,
|
||||
locked_chat.plan_mode,
|
||||
@@ -2141,7 +2060,6 @@ chats_expanded AS (
|
||||
shared_chat.agent_id,
|
||||
shared_chat.pin_order,
|
||||
shared_chat.last_read_message_id,
|
||||
shared_chat.last_injected_context,
|
||||
shared_chat.dynamic_tools,
|
||||
shared_chat.organization_id,
|
||||
shared_chat.plan_mode,
|
||||
@@ -2821,7 +2739,6 @@ chats_expanded AS (
|
||||
bumped_chat.agent_id,
|
||||
bumped_chat.pin_order,
|
||||
bumped_chat.last_read_message_id,
|
||||
bumped_chat.last_injected_context,
|
||||
bumped_chat.dynamic_tools,
|
||||
bumped_chat.organization_id,
|
||||
bumped_chat.plan_mode,
|
||||
@@ -2893,7 +2810,6 @@ chats_expanded AS (
|
||||
updated_chat.agent_id,
|
||||
updated_chat.pin_order,
|
||||
updated_chat.last_read_message_id,
|
||||
updated_chat.last_injected_context,
|
||||
updated_chat.dynamic_tools,
|
||||
updated_chat.organization_id,
|
||||
updated_chat.plan_mode,
|
||||
@@ -2957,7 +2873,6 @@ chats_expanded AS (
|
||||
updated_chat.agent_id,
|
||||
updated_chat.pin_order,
|
||||
updated_chat.last_read_message_id,
|
||||
updated_chat.last_injected_context,
|
||||
updated_chat.dynamic_tools,
|
||||
updated_chat.organization_id,
|
||||
updated_chat.plan_mode,
|
||||
|
||||
@@ -1982,13 +1982,6 @@ func TestWatchChats(t *testing.T) {
|
||||
user := coderdtest.CreateFirstUser(t, client.Client)
|
||||
modelConfig := createChatModelConfig(t, client)
|
||||
|
||||
lastInjectedContext, err := json.Marshal([]codersdk.ChatMessagePart{{
|
||||
Type: codersdk.ChatMessagePartTypeSkill,
|
||||
SkillName: "large-skill",
|
||||
SkillDescription: strings.Repeat("x", 9000),
|
||||
}})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Insert a chat and a diff status row.
|
||||
chat := dbgen.Chat(t, db, database.Chat{
|
||||
OrganizationID: user.OrganizationID,
|
||||
@@ -1996,20 +1989,9 @@ func TestWatchChats(t *testing.T) {
|
||||
LastModelConfigID: modelConfig.ID,
|
||||
Title: "diff status watch test",
|
||||
})
|
||||
chat, err = db.UpdateChatLastInjectedContext(
|
||||
dbauthz.AsChatd(ctx),
|
||||
database.UpdateChatLastInjectedContextParams{
|
||||
ID: chat.ID,
|
||||
LastInjectedContext: pqtype.NullRawMessage{
|
||||
RawMessage: lastInjectedContext,
|
||||
Valid: true,
|
||||
},
|
||||
},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
refreshedAt := time.Now().UTC().Truncate(time.Second)
|
||||
staleAt := refreshedAt.Add(time.Hour)
|
||||
_, err = db.UpsertChatDiffStatusReference(
|
||||
_, err := db.UpsertChatDiffStatusReference(
|
||||
dbauthz.AsSystemRestricted(ctx),
|
||||
database.UpsertChatDiffStatusReferenceParams{
|
||||
ChatID: chat.ID,
|
||||
@@ -2035,10 +2017,6 @@ func TestWatchChats(t *testing.T) {
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
storedChat, err := client.GetChat(ctx, chat.ID)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, storedChat.LastInjectedContext)
|
||||
|
||||
// Open the watch WebSocket.
|
||||
conn, err := client.Dial(ctx, "/api/experimental/chats/watch", nil)
|
||||
require.NoError(t, err)
|
||||
@@ -2069,7 +2047,6 @@ func TestWatchChats(t *testing.T) {
|
||||
require.EqualValues(t, 42, ds.Additions)
|
||||
require.EqualValues(t, 7, ds.Deletions)
|
||||
require.EqualValues(t, 5, ds.ChangedFiles)
|
||||
require.Empty(t, received.Chat.LastInjectedContext)
|
||||
})
|
||||
t.Run("ArchiveAndUnarchiveEmitEventsForDescendants", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
package coderd
|
||||
|
||||
// InsertAgentChatTestModelConfig exposes insertAgentChatTestModelConfig for external tests.
|
||||
var InsertAgentChatTestModelConfig = insertAgentChatTestModelConfig
|
||||
|
||||
// ChatStartWorkspace exposes chatStartWorkspace for external tests.
|
||||
//
|
||||
// chatStartWorkspace is intentionally unexported to keep symmetry with
|
||||
|
||||
+1
-688
@@ -42,9 +42,6 @@ import (
|
||||
"github.com/coder/coder/v2/coderd/telemetry"
|
||||
maputil "github.com/coder/coder/v2/coderd/util/maps"
|
||||
"github.com/coder/coder/v2/coderd/wspubsub"
|
||||
"github.com/coder/coder/v2/coderd/x/chatd"
|
||||
"github.com/coder/coder/v2/coderd/x/chatd/chatprompt"
|
||||
"github.com/coder/coder/v2/coderd/x/chatd/chatstate"
|
||||
"github.com/coder/coder/v2/coderd/x/gitsync"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/codersdk/agentsdk"
|
||||
@@ -2387,315 +2384,11 @@ func convertWorkspaceAgentLogs(logs []database.WorkspaceAgentLog) []codersdk.Wor
|
||||
return sdk
|
||||
}
|
||||
|
||||
// maxChatContextParts caps the number of parts per request to
|
||||
// prevent unbounded message payloads.
|
||||
const maxChatContextParts = 100
|
||||
|
||||
// maxChatContextFileBytes caps each context-file part to the same
|
||||
// 64KiB budget used when the agent reads instruction files from disk.
|
||||
const maxChatContextFileBytes = 64 * 1024
|
||||
|
||||
// maxChatContextRequestBodyBytes caps the JSON request body size for
|
||||
// agent-added context to roughly the same per-part budget used when
|
||||
// reading instruction files from disk.
|
||||
const maxChatContextRequestBodyBytes int64 = maxChatContextParts * maxChatContextFileBytes
|
||||
|
||||
// sanitizeWorkspaceAgentContextFileContent applies prompt
|
||||
// sanitization, then enforces the 64KiB per-file budget. The
|
||||
// truncated flag is preserved when the caller already capped the
|
||||
// file before sending it.
|
||||
func sanitizeWorkspaceAgentContextFileContent(
|
||||
content string,
|
||||
truncated bool,
|
||||
) (string, bool) {
|
||||
content = chatd.SanitizePromptText(content)
|
||||
if len(content) > maxChatContextFileBytes {
|
||||
content = content[:maxChatContextFileBytes]
|
||||
truncated = true
|
||||
}
|
||||
return content, truncated
|
||||
}
|
||||
|
||||
// readChatContextBody reads and validates the request body for chat
|
||||
// context endpoints. It handles MaxBytesReader wrapping, error
|
||||
// responses, and body rewind. If the body is empty or whitespace-only
|
||||
// and allowEmpty is true, it returns false without writing an error.
|
||||
//
|
||||
//nolint:revive // Add and clear endpoints only differ by empty-body handling.
|
||||
func readChatContextBody(ctx context.Context, rw http.ResponseWriter, r *http.Request, dst any, allowEmpty bool) bool {
|
||||
r.Body = http.MaxBytesReader(rw, r.Body, maxChatContextRequestBodyBytes)
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
var maxBytesErr *http.MaxBytesError
|
||||
if errors.As(err, &maxBytesErr) {
|
||||
httpapi.Write(ctx, rw, http.StatusRequestEntityTooLarge, codersdk.Response{
|
||||
Message: "Request body too large.",
|
||||
Detail: fmt.Sprintf("Maximum request body size is %d bytes.", maxChatContextRequestBodyBytes),
|
||||
})
|
||||
return false
|
||||
}
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "Failed to read request body.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return false
|
||||
}
|
||||
if allowEmpty && len(bytes.TrimSpace(body)) == 0 {
|
||||
r.Body = http.NoBody
|
||||
return false
|
||||
}
|
||||
|
||||
r.Body = io.NopCloser(bytes.NewReader(body))
|
||||
return httpapi.Read(ctx, rw, r, dst)
|
||||
}
|
||||
|
||||
// @x-apidocgen {"skip": true}
|
||||
func (api *API) workspaceAgentAddChatContext(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
workspaceAgent := httpmw.WorkspaceAgent(r)
|
||||
|
||||
var req agentsdk.AddChatContextRequest
|
||||
if !readChatContextBody(ctx, rw, r, &req, false) {
|
||||
return
|
||||
}
|
||||
|
||||
if len(req.Parts) == 0 {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "No context parts provided.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if len(req.Parts) > maxChatContextParts {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: fmt.Sprintf("Too many context parts (%d). Maximum is %d.", len(req.Parts), maxChatContextParts),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Filter to only non-empty context-file and skill parts.
|
||||
filtered := chatd.FilterContextParts(req.Parts, false)
|
||||
if len(filtered) == 0 {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "No context-file or skill parts provided.",
|
||||
})
|
||||
return
|
||||
}
|
||||
req.Parts = filtered
|
||||
responsePartCount := 0
|
||||
|
||||
// Use system context for chat operations since the
|
||||
// workspace agent scope does not include chat resources.
|
||||
// We verify agent-to-chat ownership explicitly below.
|
||||
//nolint:gocritic // Agent needs system access to read/write chat resources.
|
||||
sysCtx := dbauthz.AsSystemRestricted(ctx)
|
||||
workspace, err := api.Database.GetWorkspaceByAgentID(sysCtx, workspaceAgent.ID)
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Failed to determine workspace from agent token.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
chat, err := resolveAgentChat(sysCtx, api.Database, workspaceAgent.ID, workspace.OwnerID, req.ChatID)
|
||||
if err != nil {
|
||||
writeAgentChatError(ctx, rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Stamp each persisted part with the agent identity. Context-file
|
||||
// parts also get server-authoritative workspace metadata.
|
||||
directory := workspaceAgent.ExpandedDirectory
|
||||
if directory == "" {
|
||||
directory = workspaceAgent.Directory
|
||||
}
|
||||
for i := range req.Parts {
|
||||
req.Parts[i].ContextFileAgentID = uuid.NullUUID{
|
||||
UUID: workspaceAgent.ID,
|
||||
Valid: true,
|
||||
}
|
||||
if req.Parts[i].Type != codersdk.ChatMessagePartTypeContextFile {
|
||||
continue
|
||||
}
|
||||
req.Parts[i].ContextFileContent, req.Parts[i].ContextFileTruncated = sanitizeWorkspaceAgentContextFileContent(
|
||||
req.Parts[i].ContextFileContent,
|
||||
req.Parts[i].ContextFileTruncated,
|
||||
)
|
||||
req.Parts[i].ContextFileOS = workspaceAgent.OperatingSystem
|
||||
req.Parts[i].ContextFileDirectory = directory
|
||||
}
|
||||
req.Parts = chatd.FilterContextParts(req.Parts, false)
|
||||
if len(req.Parts) == 0 {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "No context-file or skill parts provided.",
|
||||
})
|
||||
return
|
||||
}
|
||||
responsePartCount = len(req.Parts)
|
||||
|
||||
// Skill-only messages need a sentinel context-file part so the turn
|
||||
// pipeline trusts the associated skill metadata.
|
||||
req.Parts = prependAgentChatContextSentinelIfNeeded(
|
||||
req.Parts,
|
||||
workspaceAgent.ID,
|
||||
workspaceAgent.OperatingSystem,
|
||||
directory,
|
||||
)
|
||||
|
||||
content, err := chatprompt.MarshalParts(req.Parts)
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Failed to marshal context parts.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
machine := chatstate.NewChatMachine(api.Database, api.Pubsub, chat.ID)
|
||||
err = machine.Update(sysCtx, func(tx *chatstate.Tx, store database.Store) error {
|
||||
locked, err := store.GetChatByID(sysCtx, chat.ID)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("load chat: %w", err)
|
||||
}
|
||||
if !isActiveAgentChat(locked) {
|
||||
return errChatNotActive
|
||||
}
|
||||
if !locked.AgentID.Valid || locked.AgentID.UUID != workspaceAgent.ID {
|
||||
return errChatDoesNotBelongToAgent
|
||||
}
|
||||
if locked.OwnerID != workspace.OwnerID {
|
||||
return errChatDoesNotBelongToWorkspaceOwner
|
||||
}
|
||||
apiKeyID, err := resolveAgentChatContextAPIKeyID(sysCtx, store, locked)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sendResult, err := tx.SendMessage(chatstate.SendMessageInput{
|
||||
Message: chatstate.Message{
|
||||
Role: database.ChatMessageRoleUser,
|
||||
Content: content,
|
||||
Visibility: database.ChatMessageVisibilityBoth,
|
||||
ModelConfigID: uuid.NullUUID{UUID: locked.LastModelConfigID, Valid: locked.LastModelConfigID != uuid.Nil},
|
||||
CreatedBy: uuid.NullUUID{UUID: locked.OwnerID, Valid: locked.OwnerID != uuid.Nil},
|
||||
ContentVersion: chatprompt.CurrentContentVersion,
|
||||
APIKeyID: sql.NullString{String: apiKeyID, Valid: apiKeyID != ""},
|
||||
},
|
||||
BusyBehavior: chatstate.BusyBehaviorInterrupt,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(sendResult.InsertedMessages) == 0 {
|
||||
return nil
|
||||
}
|
||||
if err := updateAgentChatLastInjectedContextFromMessages(sysCtx, api.Logger, store, chat.ID); err != nil {
|
||||
return xerrors.Errorf("rebuild injected context cache: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, errChatNotActive), errors.Is(err, errChatDoesNotBelongToAgent), errors.Is(err, errChatDoesNotBelongToWorkspaceOwner):
|
||||
writeAgentChatError(ctx, rw, err)
|
||||
case errors.Is(err, errChatAPIKeyAttributionUnavailable):
|
||||
httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{
|
||||
Message: "Cannot modify context: chat has no API key attribution.",
|
||||
})
|
||||
case errors.Is(err, chatstate.ErrMessageQueueFull):
|
||||
var queueFull *chatstate.MessageQueueFullError
|
||||
detail := ""
|
||||
if errors.As(err, &queueFull) {
|
||||
detail = fmt.Sprintf("Maximum %d messages can be queued.", queueFull.Max)
|
||||
}
|
||||
httpapi.Write(ctx, rw, http.StatusTooManyRequests, codersdk.Response{
|
||||
Message: "Message queue is full.",
|
||||
Detail: detail,
|
||||
})
|
||||
case errors.Is(err, chatstate.ErrInvalidState):
|
||||
httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{
|
||||
Message: "Chat is in an invalid state.",
|
||||
})
|
||||
case errors.Is(err, chatstate.ErrTransitionNotAllowed):
|
||||
httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{
|
||||
Message: "Chat is not in a state that accepts new context.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
case errors.Is(err, chatstate.ErrChatNotFound):
|
||||
writeAgentChatError(ctx, rw, errChatNotFound)
|
||||
default:
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Failed to persist context message.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
httpapi.Write(ctx, rw, http.StatusOK, agentsdk.AddChatContextResponse{
|
||||
ChatID: chat.ID,
|
||||
Count: responsePartCount,
|
||||
})
|
||||
}
|
||||
|
||||
// @x-apidocgen {"skip": true}
|
||||
func (api *API) workspaceAgentClearChatContext(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
workspaceAgent := httpmw.WorkspaceAgent(r)
|
||||
|
||||
var req agentsdk.ClearChatContextRequest
|
||||
populated := readChatContextBody(ctx, rw, r, &req, true)
|
||||
if !populated && r.Body != http.NoBody {
|
||||
return
|
||||
}
|
||||
|
||||
// Use system context for chat operations since the
|
||||
// workspace agent scope does not include chat resources.
|
||||
//nolint:gocritic // Agent needs system access to read/write chat resources.
|
||||
sysCtx := dbauthz.AsSystemRestricted(ctx)
|
||||
workspace, err := api.Database.GetWorkspaceByAgentID(sysCtx, workspaceAgent.ID)
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Failed to determine workspace from agent token.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
chat, err := resolveAgentChat(sysCtx, api.Database, workspaceAgent.ID, workspace.OwnerID, req.ChatID)
|
||||
if err != nil {
|
||||
// Zero active chats is not an error for clear.
|
||||
if errors.Is(err, errNoActiveChats) {
|
||||
httpapi.Write(ctx, rw, http.StatusOK, agentsdk.ClearChatContextResponse{})
|
||||
return
|
||||
}
|
||||
writeAgentChatError(ctx, rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
err = clearAgentChatContext(sysCtx, api.Database, chat.ID, workspaceAgent.ID, workspace.OwnerID)
|
||||
if err != nil {
|
||||
if errors.Is(err, errChatNotActive) || errors.Is(err, errChatDoesNotBelongToAgent) || errors.Is(err, errChatDoesNotBelongToWorkspaceOwner) {
|
||||
writeAgentChatError(ctx, rw, err)
|
||||
return
|
||||
}
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Failed to clear context from chat.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
httpapi.Write(ctx, rw, http.StatusOK, agentsdk.ClearChatContextResponse{
|
||||
ChatID: chat.ID,
|
||||
})
|
||||
}
|
||||
|
||||
// workspaceAgentRefreshChatContext re-pins every drifted chat bound to the
|
||||
// calling agent to the agent's latest context snapshot, clearing their
|
||||
// drift markers. It backs the in-workspace `coder exp chat context refresh`
|
||||
// (no chat argument), which uses the agent token rather than a user
|
||||
// session, mirroring workspaceAgentClearChatContext's auth model.
|
||||
// session.
|
||||
//
|
||||
// @x-apidocgen {"skip": true}
|
||||
func (api *API) workspaceAgentRefreshChatContext(rw http.ResponseWriter, r *http.Request) {
|
||||
@@ -2753,383 +2446,3 @@ func (api *API) workspaceAgentRefreshChatContext(rw http.ResponseWriter, r *http
|
||||
Refreshed: refreshed,
|
||||
})
|
||||
}
|
||||
|
||||
var (
|
||||
errNoActiveChats = xerrors.New("no active chats found")
|
||||
errChatNotFound = xerrors.New("chat not found")
|
||||
errChatNotActive = xerrors.New("chat is not active")
|
||||
errChatDoesNotBelongToAgent = xerrors.New("chat does not belong to this agent")
|
||||
errChatDoesNotBelongToWorkspaceOwner = xerrors.New("chat does not belong to this workspace owner")
|
||||
errChatAPIKeyAttributionUnavailable = xerrors.New("chat has no API key attribution")
|
||||
)
|
||||
|
||||
type multipleActiveChatsError struct {
|
||||
count int
|
||||
}
|
||||
|
||||
func (e *multipleActiveChatsError) Error() string {
|
||||
return fmt.Sprintf(
|
||||
"multiple active chats (%d) found for this agent, specify a chat ID",
|
||||
e.count,
|
||||
)
|
||||
}
|
||||
|
||||
func resolveDefaultAgentChat(chats []database.Chat) (database.Chat, error) {
|
||||
switch len(chats) {
|
||||
case 0:
|
||||
return database.Chat{}, errNoActiveChats
|
||||
case 1:
|
||||
return chats[0], nil
|
||||
}
|
||||
|
||||
var rootChat *database.Chat
|
||||
for i := range chats {
|
||||
chat := &chats[i]
|
||||
if chat.ParentChatID.Valid {
|
||||
continue
|
||||
}
|
||||
if rootChat != nil {
|
||||
return database.Chat{}, &multipleActiveChatsError{count: len(chats)}
|
||||
}
|
||||
rootChat = chat
|
||||
}
|
||||
if rootChat != nil {
|
||||
return *rootChat, nil
|
||||
}
|
||||
return database.Chat{}, &multipleActiveChatsError{count: len(chats)}
|
||||
}
|
||||
|
||||
// resolveAgentChat finds the target chat from either an explicit ID
|
||||
// or auto-detection via the agent's active chats.
|
||||
func resolveAgentChat(
|
||||
ctx context.Context,
|
||||
db database.Store,
|
||||
agentID uuid.UUID,
|
||||
workspaceOwnerID uuid.UUID,
|
||||
explicitChatID uuid.UUID,
|
||||
) (database.Chat, error) {
|
||||
if explicitChatID == uuid.Nil {
|
||||
chats, err := db.GetActiveChatsByAgentID(ctx, agentID)
|
||||
if err != nil {
|
||||
return database.Chat{}, xerrors.Errorf("list active chats: %w", err)
|
||||
}
|
||||
ownerChats := make([]database.Chat, 0, len(chats))
|
||||
for _, chat := range chats {
|
||||
if chat.OwnerID != workspaceOwnerID {
|
||||
continue
|
||||
}
|
||||
ownerChats = append(ownerChats, chat)
|
||||
}
|
||||
return resolveDefaultAgentChat(ownerChats)
|
||||
}
|
||||
|
||||
chat, err := db.GetChatByID(ctx, explicitChatID)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return database.Chat{}, errChatNotFound
|
||||
}
|
||||
return database.Chat{}, xerrors.Errorf("get chat by id: %w", err)
|
||||
}
|
||||
if !chat.AgentID.Valid || chat.AgentID.UUID != agentID {
|
||||
return database.Chat{}, errChatDoesNotBelongToAgent
|
||||
}
|
||||
if chat.OwnerID != workspaceOwnerID {
|
||||
return database.Chat{}, errChatDoesNotBelongToWorkspaceOwner
|
||||
}
|
||||
if !isActiveAgentChat(chat) {
|
||||
return database.Chat{}, errChatNotActive
|
||||
}
|
||||
return chat, nil
|
||||
}
|
||||
|
||||
func isActiveAgentChat(chat database.Chat) bool {
|
||||
if chat.Archived {
|
||||
return false
|
||||
}
|
||||
|
||||
switch chat.Status {
|
||||
case database.ChatStatusWaiting,
|
||||
database.ChatStatusPending,
|
||||
database.ChatStatusRunning,
|
||||
database.ChatStatusPaused,
|
||||
database.ChatStatusRequiresAction:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func resolveAgentChatContextAPIKeyID(ctx context.Context, db database.Store, chat database.Chat) (string, error) {
|
||||
messages, err := db.GetChatMessagesByChatID(ctx, database.GetChatMessagesByChatIDParams{
|
||||
ChatID: chat.ID,
|
||||
AfterID: 0,
|
||||
})
|
||||
if err != nil {
|
||||
return "", xerrors.Errorf("load chat messages for API key attribution: %w", err)
|
||||
}
|
||||
for i := len(messages) - 1; i >= 0; i-- {
|
||||
message := messages[i]
|
||||
if message.Role != database.ChatMessageRoleUser {
|
||||
continue
|
||||
}
|
||||
if !message.APIKeyID.Valid || message.APIKeyID.String == "" {
|
||||
continue
|
||||
}
|
||||
return message.APIKeyID.String, nil
|
||||
}
|
||||
|
||||
loginTypes := []database.LoginType{
|
||||
database.LoginTypePassword,
|
||||
database.LoginTypeOIDC,
|
||||
database.LoginTypeGithub,
|
||||
database.LoginTypeToken,
|
||||
database.LoginTypeNone,
|
||||
}
|
||||
var newest database.APIKey
|
||||
hasNewest := false
|
||||
for _, loginType := range loginTypes {
|
||||
keys, err := db.GetAPIKeysByUserID(ctx, database.GetAPIKeysByUserIDParams{
|
||||
LoginType: loginType,
|
||||
UserID: chat.OwnerID,
|
||||
IncludeExpired: false,
|
||||
})
|
||||
if err != nil {
|
||||
return "", xerrors.Errorf("load owner API keys for attribution: %w", err)
|
||||
}
|
||||
for _, key := range keys {
|
||||
if !hasNewest || key.CreatedAt.After(newest.CreatedAt) {
|
||||
newest = key
|
||||
hasNewest = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if !hasNewest {
|
||||
return "", errChatAPIKeyAttributionUnavailable
|
||||
}
|
||||
return newest.ID, nil
|
||||
}
|
||||
|
||||
func clearAgentChatContext(
|
||||
ctx context.Context,
|
||||
db database.Store,
|
||||
chatID uuid.UUID,
|
||||
agentID uuid.UUID,
|
||||
workspaceOwnerID uuid.UUID,
|
||||
) error {
|
||||
return db.InTx(func(tx database.Store) error {
|
||||
locked, err := tx.GetChatByIDForUpdate(ctx, chatID)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("lock chat: %w", err)
|
||||
}
|
||||
if !isActiveAgentChat(locked) {
|
||||
return errChatNotActive
|
||||
}
|
||||
if !locked.AgentID.Valid || locked.AgentID.UUID != agentID {
|
||||
return errChatDoesNotBelongToAgent
|
||||
}
|
||||
if locked.OwnerID != workspaceOwnerID {
|
||||
return errChatDoesNotBelongToWorkspaceOwner
|
||||
}
|
||||
messages, err := tx.GetChatMessagesByChatID(ctx, database.GetChatMessagesByChatIDParams{
|
||||
ChatID: chatID,
|
||||
AfterID: 0,
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("get chat messages: %w", err)
|
||||
}
|
||||
hadInjectedContext := locked.LastInjectedContext.Valid
|
||||
var skillOnlyMessageIDs []int64
|
||||
for _, msg := range messages {
|
||||
if !msg.Content.Valid {
|
||||
continue
|
||||
}
|
||||
hasContextFile := messageHasPartTypes(msg.Content.RawMessage, codersdk.ChatMessagePartTypeContextFile)
|
||||
hasSkill := messageHasPartTypes(msg.Content.RawMessage, codersdk.ChatMessagePartTypeSkill)
|
||||
if hasContextFile || hasSkill {
|
||||
hadInjectedContext = true
|
||||
}
|
||||
if hasSkill && !hasContextFile {
|
||||
skillOnlyMessageIDs = append(skillOnlyMessageIDs, msg.ID)
|
||||
}
|
||||
}
|
||||
if !hadInjectedContext {
|
||||
return nil
|
||||
}
|
||||
if err := tx.SoftDeleteContextFileMessages(ctx, chatID); err != nil {
|
||||
return xerrors.Errorf("soft delete context-file messages: %w", err)
|
||||
}
|
||||
for _, messageID := range skillOnlyMessageIDs {
|
||||
if err := tx.SoftDeleteChatMessageByID(ctx, messageID); err != nil {
|
||||
return xerrors.Errorf("soft delete context message %d: %w", messageID, err)
|
||||
}
|
||||
}
|
||||
// Reset provider-side Responses chaining so the next turn replays
|
||||
// the post-clear history instead of inheriting cleared context.
|
||||
if err := tx.ClearChatMessageProviderResponseIDsByChatID(ctx, chatID); err != nil {
|
||||
return xerrors.Errorf("clear provider response chain: %w", err)
|
||||
}
|
||||
// Clear the injected-context cache inside the transaction so it is
|
||||
// atomic with the soft-deletes.
|
||||
param, err := chatd.BuildLastInjectedContext(nil)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("clear injected context cache: %w", err)
|
||||
}
|
||||
if _, err := tx.UpdateChatLastInjectedContext(ctx, database.UpdateChatLastInjectedContextParams{
|
||||
ID: chatID,
|
||||
LastInjectedContext: param,
|
||||
}); err != nil {
|
||||
return xerrors.Errorf("clear injected context cache: %w", err)
|
||||
}
|
||||
return nil
|
||||
}, nil)
|
||||
}
|
||||
|
||||
// prependAgentChatContextSentinelIfNeeded adds an empty context-file
|
||||
// part when the request only carries skills. The turn pipeline uses
|
||||
// the sentinel's agent metadata to trust the skill parts.
|
||||
func prependAgentChatContextSentinelIfNeeded(
|
||||
parts []codersdk.ChatMessagePart,
|
||||
agentID uuid.UUID,
|
||||
operatingSystem string,
|
||||
directory string,
|
||||
) []codersdk.ChatMessagePart {
|
||||
hasContextFile := false
|
||||
hasSkill := false
|
||||
for _, part := range parts {
|
||||
switch part.Type {
|
||||
case codersdk.ChatMessagePartTypeContextFile:
|
||||
hasContextFile = true
|
||||
case codersdk.ChatMessagePartTypeSkill:
|
||||
hasSkill = true
|
||||
}
|
||||
if hasContextFile && hasSkill {
|
||||
return parts
|
||||
}
|
||||
}
|
||||
if !hasSkill || hasContextFile {
|
||||
return parts
|
||||
}
|
||||
return append([]codersdk.ChatMessagePart{{
|
||||
Type: codersdk.ChatMessagePartTypeContextFile,
|
||||
ContextFilePath: chatd.AgentChatContextSentinelPath,
|
||||
ContextFileAgentID: uuid.NullUUID{
|
||||
UUID: agentID,
|
||||
Valid: true,
|
||||
},
|
||||
ContextFileOS: operatingSystem,
|
||||
ContextFileDirectory: directory,
|
||||
}}, parts...)
|
||||
}
|
||||
|
||||
func sortChatMessagesByCreatedAtAndID(messages []database.ChatMessage) {
|
||||
sort.SliceStable(messages, func(i, j int) bool {
|
||||
if messages[i].CreatedAt.Equal(messages[j].CreatedAt) {
|
||||
return messages[i].ID < messages[j].ID
|
||||
}
|
||||
return messages[i].CreatedAt.Before(messages[j].CreatedAt)
|
||||
})
|
||||
}
|
||||
|
||||
// updateAgentChatLastInjectedContextFromMessages rebuilds the
|
||||
// injected-context cache from all persisted context-file and skill parts.
|
||||
func updateAgentChatLastInjectedContextFromMessages(
|
||||
ctx context.Context,
|
||||
logger slog.Logger,
|
||||
db database.Store,
|
||||
chatID uuid.UUID,
|
||||
) error {
|
||||
messages, err := db.GetChatMessagesByChatID(ctx, database.GetChatMessagesByChatIDParams{
|
||||
ChatID: chatID,
|
||||
AfterID: 0,
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("load context messages for injected context: %w", err)
|
||||
}
|
||||
|
||||
sortChatMessagesByCreatedAtAndID(messages)
|
||||
|
||||
parts, err := chatd.CollectContextPartsFromMessages(ctx, logger, messages, true)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("collect injected context parts: %w", err)
|
||||
}
|
||||
parts = chatd.FilterContextPartsToLatestAgent(parts)
|
||||
|
||||
param, err := chatd.BuildLastInjectedContext(parts)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("update injected context: %w", err)
|
||||
}
|
||||
if _, err := db.UpdateChatLastInjectedContext(ctx, database.UpdateChatLastInjectedContextParams{
|
||||
ID: chatID,
|
||||
LastInjectedContext: param,
|
||||
}); err != nil {
|
||||
return xerrors.Errorf("update injected context: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func messageHasPartTypes(raw []byte, types ...codersdk.ChatMessagePartType) bool {
|
||||
var parts []codersdk.ChatMessagePart
|
||||
if err := json.Unmarshal(raw, &parts); err != nil {
|
||||
return false
|
||||
}
|
||||
for _, part := range parts {
|
||||
for _, typ := range types {
|
||||
if part.Type == typ {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// writeAgentChatError translates resolveAgentChat errors to HTTP
|
||||
// responses.
|
||||
func writeAgentChatError(
|
||||
ctx context.Context,
|
||||
rw http.ResponseWriter,
|
||||
err error,
|
||||
) {
|
||||
if errors.Is(err, errNoActiveChats) {
|
||||
httpapi.Write(ctx, rw, http.StatusNotFound, codersdk.Response{
|
||||
Message: "No active chats found for this agent.",
|
||||
})
|
||||
return
|
||||
}
|
||||
if errors.Is(err, errChatNotFound) {
|
||||
httpapi.Write(ctx, rw, http.StatusNotFound, codersdk.Response{
|
||||
Message: "Chat not found.",
|
||||
})
|
||||
return
|
||||
}
|
||||
if errors.Is(err, errChatDoesNotBelongToAgent) {
|
||||
httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{
|
||||
Message: "Chat does not belong to this agent.",
|
||||
})
|
||||
return
|
||||
}
|
||||
if errors.Is(err, errChatDoesNotBelongToWorkspaceOwner) {
|
||||
httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{
|
||||
Message: "Chat does not belong to this workspace owner.",
|
||||
})
|
||||
return
|
||||
}
|
||||
if errors.Is(err, errChatNotActive) {
|
||||
httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{
|
||||
Message: "Cannot modify context: this chat is no longer active.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var multipleErr *multipleActiveChatsError
|
||||
if errors.As(err, &multipleErr) {
|
||||
httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{
|
||||
Message: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Failed to resolve chat.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package coderd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"database/sql"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -16,66 +16,6 @@ import (
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
)
|
||||
|
||||
func TestActiveAgentChatDefinitionsAgree(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := dbauthz.AsSystemRestricted(testutil.Context(t, testutil.WaitMedium))
|
||||
db, _ := dbtestutil.NewDB(t)
|
||||
|
||||
org, err := db.GetDefaultOrganization(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
owner := dbgen.User(t, db, database.User{})
|
||||
workspace := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
|
||||
OrganizationID: org.ID,
|
||||
OwnerID: owner.ID,
|
||||
}).WithAgent().Do()
|
||||
modelConfig := insertAgentChatTestModelConfig(t, db, owner.ID)
|
||||
|
||||
insertedChats := make([]database.Chat, 0, len(database.AllChatStatusValues())*2)
|
||||
for _, archived := range []bool{false, true} {
|
||||
for _, status := range database.AllChatStatusValues() {
|
||||
chat := dbgen.Chat(t, db, database.Chat{
|
||||
OrganizationID: org.ID,
|
||||
Status: status,
|
||||
OwnerID: owner.ID,
|
||||
LastModelConfigID: modelConfig.ID,
|
||||
Title: fmt.Sprintf("%s-archived-%t", status, archived),
|
||||
AgentID: uuid.NullUUID{UUID: workspace.Agents[0].ID, Valid: true},
|
||||
})
|
||||
|
||||
if archived {
|
||||
_, err = db.ArchiveChatByID(ctx, chat.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
chat, err = db.GetChatByID(ctx, chat.ID)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
insertedChats = append(insertedChats, chat)
|
||||
}
|
||||
}
|
||||
|
||||
activeChats, err := db.GetActiveChatsByAgentID(ctx, workspace.Agents[0].ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
activeByID := make(map[uuid.UUID]bool, len(activeChats))
|
||||
for _, chat := range activeChats {
|
||||
activeByID[chat.ID] = true
|
||||
}
|
||||
|
||||
for _, chat := range insertedChats {
|
||||
require.Equalf(
|
||||
t,
|
||||
isActiveAgentChat(chat),
|
||||
activeByID[chat.ID],
|
||||
"status=%s archived=%t",
|
||||
chat.Status,
|
||||
chat.Archived,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActiveAgentChatsIncludeInheritedACLs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -157,3 +97,31 @@ func TestActiveAgentChatsIncludeInheritedACLs(t *testing.T) {
|
||||
require.Equal(t, rootUserACL, fetchedChild.UserACL)
|
||||
require.Equal(t, rootGroupACL, fetchedChild.GroupACL)
|
||||
}
|
||||
|
||||
func insertAgentChatTestModelConfig(
|
||||
t testing.TB,
|
||||
db database.Store,
|
||||
userID uuid.UUID,
|
||||
) database.ChatModelConfig {
|
||||
t.Helper()
|
||||
|
||||
createdBy := uuid.NullUUID{UUID: userID, Valid: true}
|
||||
|
||||
provider := dbgen.AIProvider(t, db, database.AIProvider{
|
||||
Type: database.AIProviderTypeOpenai,
|
||||
Name: "test-openai",
|
||||
DisplayName: sql.NullString{String: "OpenAI", Valid: true},
|
||||
})
|
||||
dbgen.AIProviderKey(t, db, database.AIProviderKey{
|
||||
ProviderID: provider.ID,
|
||||
APIKey: "test-api-key",
|
||||
})
|
||||
|
||||
return dbgen.ChatModelConfig(t, db, database.ChatModelConfig{
|
||||
Provider: "openai",
|
||||
AIProviderID: uuid.NullUUID{UUID: provider.ID, Valid: true},
|
||||
CreatedBy: createdBy,
|
||||
UpdatedBy: createdBy,
|
||||
IsDefault: true,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
package coderd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/sqlc-dev/pqtype"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/mock/gomock"
|
||||
|
||||
"cdr.dev/slog/v3/sloggers/slogtest"
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/dbgen"
|
||||
"github.com/coder/coder/v2/coderd/database/dbmock"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
)
|
||||
|
||||
func TestUpdateAgentChatLastInjectedContextFromMessagesUsesMessageIDTieBreaker(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctrl := gomock.NewController(t)
|
||||
db := dbmock.NewMockStore(ctrl)
|
||||
chatID := uuid.New()
|
||||
createdAt := time.Date(2026, time.April, 9, 13, 0, 0, 0, time.UTC)
|
||||
oldAgentID := uuid.New()
|
||||
newAgentID := uuid.New()
|
||||
|
||||
oldContent, err := json.Marshal([]codersdk.ChatMessagePart{{
|
||||
Type: codersdk.ChatMessagePartTypeContextFile,
|
||||
ContextFilePath: "/old/AGENTS.md",
|
||||
ContextFileContent: "old instructions",
|
||||
ContextFileAgentID: uuid.NullUUID{UUID: oldAgentID, Valid: true},
|
||||
}})
|
||||
require.NoError(t, err)
|
||||
newContent, err := json.Marshal([]codersdk.ChatMessagePart{{
|
||||
Type: codersdk.ChatMessagePartTypeContextFile,
|
||||
ContextFilePath: "/new/AGENTS.md",
|
||||
ContextFileContent: "new instructions",
|
||||
ContextFileAgentID: uuid.NullUUID{UUID: newAgentID, Valid: true},
|
||||
}})
|
||||
require.NoError(t, err)
|
||||
|
||||
db.EXPECT().GetChatMessagesByChatID(gomock.Any(), database.GetChatMessagesByChatIDParams{
|
||||
ChatID: chatID,
|
||||
AfterID: 0,
|
||||
}).Return([]database.ChatMessage{
|
||||
{
|
||||
ID: 2,
|
||||
CreatedAt: createdAt,
|
||||
Content: pqtype.NullRawMessage{
|
||||
RawMessage: newContent,
|
||||
Valid: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: 1,
|
||||
CreatedAt: createdAt,
|
||||
Content: pqtype.NullRawMessage{
|
||||
RawMessage: oldContent,
|
||||
Valid: true,
|
||||
},
|
||||
},
|
||||
}, nil)
|
||||
|
||||
db.EXPECT().UpdateChatLastInjectedContext(gomock.Any(), gomock.Any()).DoAndReturn(
|
||||
func(_ context.Context, arg database.UpdateChatLastInjectedContextParams) (database.Chat, error) {
|
||||
require.Equal(t, chatID, arg.ID)
|
||||
require.True(t, arg.LastInjectedContext.Valid)
|
||||
var cached []codersdk.ChatMessagePart
|
||||
require.NoError(t, json.Unmarshal(arg.LastInjectedContext.RawMessage, &cached))
|
||||
require.Len(t, cached, 1)
|
||||
require.Equal(t, "/new/AGENTS.md", cached[0].ContextFilePath)
|
||||
require.Equal(t, uuid.NullUUID{UUID: newAgentID, Valid: true}, cached[0].ContextFileAgentID)
|
||||
return database.Chat{}, nil
|
||||
},
|
||||
)
|
||||
|
||||
err = updateAgentChatLastInjectedContextFromMessages(
|
||||
context.Background(),
|
||||
slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}),
|
||||
db,
|
||||
chatID,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func insertAgentChatTestModelConfig(
|
||||
t testing.TB,
|
||||
db database.Store,
|
||||
userID uuid.UUID,
|
||||
) database.ChatModelConfig {
|
||||
t.Helper()
|
||||
|
||||
createdBy := uuid.NullUUID{UUID: userID, Valid: true}
|
||||
|
||||
provider := dbgen.AIProvider(t, db, database.AIProvider{
|
||||
Type: database.AIProviderTypeOpenai,
|
||||
Name: "test-openai",
|
||||
DisplayName: sql.NullString{String: "OpenAI", Valid: true},
|
||||
})
|
||||
dbgen.AIProviderKey(t, db, database.AIProviderKey{
|
||||
ProviderID: provider.ID,
|
||||
APIKey: "test-api-key",
|
||||
})
|
||||
|
||||
return dbgen.ChatModelConfig(t, db, database.ChatModelConfig{
|
||||
Provider: "openai",
|
||||
AIProviderID: uuid.NullUUID{UUID: provider.ID, Valid: true},
|
||||
CreatedBy: createdBy,
|
||||
UpdatedBy: createdBy,
|
||||
IsDefault: true,
|
||||
})
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+28
-525
@@ -1,7 +1,6 @@
|
||||
package chatd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"cmp"
|
||||
"context"
|
||||
"database/sql"
|
||||
@@ -68,31 +67,17 @@ const (
|
||||
DefaultInFlightChatStaleAfter = 5 * time.Minute
|
||||
|
||||
homeInstructionLookupTimeout = 5 * time.Second
|
||||
planPathLookupTimeout = 5 * time.Second
|
||||
workspaceDialValidationDelay = 5 * time.Second
|
||||
// Must exceed agent/x/agentmcp.connectTimeout (30s) so a
|
||||
// cold-start agent's first MCP reload can settle before
|
||||
// chatd gives up.
|
||||
workspaceMCPDiscoveryTimeout = 35 * time.Second
|
||||
// workspaceMCPPrimeMaxWait bounds the deadline used by the
|
||||
// create_workspace / start_workspace post-ready cache primer
|
||||
// loop. The primer checks the deadline only after each
|
||||
// discoverWorkspaceMCPTools call returns, so total wall-clock
|
||||
// time can exceed this by one such call (dialTimeout +
|
||||
// workspaceMCPDiscoveryTimeout in the worst case). The constant
|
||||
// caps when new retries can start, not when an in-flight call
|
||||
// must finish. Empty results usually mean the agent's MCP
|
||||
// Connect is still racing with agent startup. The agent-side
|
||||
// budget is agent/x/agentmcp.connectTimeout (30s).
|
||||
workspaceMCPPrimeMaxWait = 30 * time.Second
|
||||
// workspaceMCPPrimeRetryInterval is the short backoff between
|
||||
// re-attempts inside the primer when ListMCPTools returns an
|
||||
// empty list without error.
|
||||
workspaceMCPPrimeRetryInterval = 2 * time.Second
|
||||
turnStatusLabelWriteTimeout = 5 * time.Second
|
||||
turnStatusLabelWriteTimeout = 5 * time.Second
|
||||
// defaultDialTimeout matches the timeout used by ~8 other
|
||||
// server-side AgentConn callers.
|
||||
defaultDialTimeout = 30 * time.Second
|
||||
// planPathLookupTimeout bounds resolving the per-chat plan path, which
|
||||
// dials the workspace agent to read its home directory. It must exceed
|
||||
// defaultDialTimeout so a cold dial, bounded internally by that timeout,
|
||||
// can finish before this outer budget fires, with a small margin for the
|
||||
// follow-up LS call.
|
||||
planPathLookupTimeout = defaultDialTimeout + 5*time.Second
|
||||
// DefaultChatHeartbeatInterval is the default time between chat
|
||||
// heartbeat updates while a chat is being processed.
|
||||
DefaultChatHeartbeatInterval = 30 * time.Second
|
||||
@@ -195,11 +180,6 @@ type Server struct {
|
||||
configCache *chatConfigCache
|
||||
configCacheUnsubscribe func()
|
||||
|
||||
// workspaceMCPToolsCache caches workspace MCP tool definitions
|
||||
// per chat to avoid re-fetching on every turn. The cache is
|
||||
// keyed by chat ID and invalidated when the agent changes.
|
||||
workspaceMCPToolsCache sync.Map // uuid.UUID -> *cachedWorkspaceMCPTools
|
||||
|
||||
usageTracker *workspacestats.UsageTracker
|
||||
clock quartz.Clock
|
||||
metrics *chatloop.Metrics
|
||||
@@ -444,226 +424,50 @@ func (p *Server) newAdvisorRuntime(
|
||||
return rt, nil
|
||||
}
|
||||
|
||||
// cachedWorkspaceMCPTools stores workspace MCP tools discovered
|
||||
// from a workspace agent, keyed by the agent ID that provided them.
|
||||
type cachedWorkspaceMCPTools struct {
|
||||
agentID uuid.UUID
|
||||
tools []workspacesdk.MCPToolInfo
|
||||
}
|
||||
|
||||
// loadCachedWorkspaceContext checks the MCP tools cache for the
|
||||
// given chat and agent. Returns non-nil tools when the cache hits,
|
||||
// which signals the caller to skip the slow MCP discovery path.
|
||||
func (p *Server) loadCachedWorkspaceContext(
|
||||
chatID uuid.UUID,
|
||||
agent database.WorkspaceAgent,
|
||||
getConn func(context.Context) (workspacesdk.AgentConn, error),
|
||||
) []fantasy.AgentTool {
|
||||
cached, ok := p.workspaceMCPToolsCache.Load(chatID)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
entry, ok := cached.(*cachedWorkspaceMCPTools)
|
||||
if !ok || entry.agentID != agent.ID {
|
||||
return nil
|
||||
}
|
||||
|
||||
var tools []fantasy.AgentTool
|
||||
invalidate := func() { p.workspaceMCPToolsCache.Delete(chatID) }
|
||||
for _, t := range entry.tools {
|
||||
tools = append(tools, chattool.NewWorkspaceMCPTool(t, getConn, invalidate))
|
||||
}
|
||||
|
||||
return tools
|
||||
}
|
||||
|
||||
// discoverWorkspaceMCPTools resolves the chat's workspace agent and
|
||||
// lists the workspace MCP tools advertised by that agent. Results are
|
||||
// cached per chat keyed on the agent ID so subsequent calls hit the
|
||||
// cache. Returns nil (and never an error) on every failure mode so the
|
||||
// caller can continue without MCP tools.
|
||||
//
|
||||
// This helper is shared between the initial discovery path and the
|
||||
// mid-turn workspace binding path triggered after create_workspace or
|
||||
// start_workspace bind a workspace to a chat that started without one.
|
||||
func (p *Server) discoverWorkspaceMCPTools(
|
||||
ctx context.Context,
|
||||
logger slog.Logger,
|
||||
chatID uuid.UUID,
|
||||
workspaceCtx *turnWorkspaceContext,
|
||||
) []fantasy.AgentTool {
|
||||
// Fast path: check cache using the in-memory cached agent
|
||||
// (ensureWorkspaceAgent is free when already loaded). This
|
||||
// avoids a per-turn latest-build DB query on the common
|
||||
// subsequent-turn path.
|
||||
if agent, agentErr := workspaceCtx.getWorkspaceAgent(ctx); agentErr == nil {
|
||||
if tools := p.loadCachedWorkspaceContext(
|
||||
chatID, agent, workspaceCtx.getWorkspaceConn,
|
||||
); tools != nil {
|
||||
return tools
|
||||
}
|
||||
} // Cache miss, agent changed, or no cache: validate
|
||||
// that the workspace still has a live agent before
|
||||
// attempting a dial.
|
||||
_, _, agentErr := workspaceCtx.workspaceAgentIDForConn(ctx)
|
||||
if agentErr != nil {
|
||||
if xerrors.Is(agentErr, errChatHasNoWorkspaceAgent) {
|
||||
p.workspaceMCPToolsCache.Delete(chatID)
|
||||
return nil
|
||||
}
|
||||
logger.Warn(ctx, "failed to resolve workspace agent for MCP tools",
|
||||
slog.Error(agentErr))
|
||||
return nil
|
||||
}
|
||||
|
||||
// List workspace MCP tools via the agent conn.
|
||||
conn, connErr := workspaceCtx.getWorkspaceConn(ctx)
|
||||
if connErr != nil {
|
||||
logger.Warn(ctx, "failed to get workspace conn for MCP tools",
|
||||
slog.Error(connErr))
|
||||
return nil
|
||||
}
|
||||
listCtx, cancel := context.WithTimeout(ctx, workspaceMCPDiscoveryTimeout)
|
||||
defer cancel()
|
||||
toolsResp, listErr := conn.ListMCPTools(listCtx)
|
||||
if listErr != nil {
|
||||
logger.Warn(ctx, "failed to list workspace MCP tools",
|
||||
slog.Error(listErr))
|
||||
return nil
|
||||
}
|
||||
// Cache the result for subsequent turns. Skip caching when
|
||||
// the list is empty because the agent's MCP Connect may not
|
||||
// have finished yet; caching an empty list would hide tools
|
||||
// permanently.
|
||||
if len(toolsResp.Tools) > 0 {
|
||||
if agent, agentErr := workspaceCtx.getWorkspaceAgent(ctx); agentErr == nil {
|
||||
p.workspaceMCPToolsCache.Store(chatID, &cachedWorkspaceMCPTools{
|
||||
agentID: agent.ID,
|
||||
tools: toolsResp.Tools,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
invalidate := func() { p.workspaceMCPToolsCache.Delete(chatID) }
|
||||
tools := make([]fantasy.AgentTool, 0, len(toolsResp.Tools))
|
||||
for _, t := range toolsResp.Tools {
|
||||
tools = append(tools, chattool.NewWorkspaceMCPTool(t, workspaceCtx.getWorkspaceConn, invalidate))
|
||||
}
|
||||
return tools
|
||||
}
|
||||
|
||||
// resolveWorkspaceMCPTools selects the workspace MCP tool set for a turn. It
|
||||
// prefers the chat's pinned context snapshot and falls back to the per-turn
|
||||
// live discovery path for chats whose agent has not reported context yet. The
|
||||
// two paths are mutually exclusive, mirroring resolveTurnWorkspaceContext for
|
||||
// instructions and skills.
|
||||
// resolveWorkspaceMCPTools builds the workspace MCP tool set for a turn from
|
||||
// the chat's pinned context snapshot (chat_context_resources). The agent
|
||||
// reports its MCP servers in the snapshot it pushes, so a chat with no pinned
|
||||
// rows, or one whose workspace advertises no MCP servers, contributes no
|
||||
// workspace MCP tools. A read failure is logged and yields no tools rather
|
||||
// than aborting the turn.
|
||||
func (p *Server) resolveWorkspaceMCPTools(
|
||||
ctx context.Context,
|
||||
logger slog.Logger,
|
||||
chat database.Chat,
|
||||
workspaceCtx *turnWorkspaceContext,
|
||||
) []fantasy.AgentTool {
|
||||
pinned, ok, err := p.pinnedWorkspaceMCPTools(ctx, chat, workspaceCtx.getWorkspaceConn)
|
||||
tools, err := p.pinnedWorkspaceMCPTools(ctx, chat, workspaceCtx.getWorkspaceConn)
|
||||
if err != nil {
|
||||
// A pinned-read failure should not be more fatal than a live
|
||||
// discovery failure (which returns nil tools), so log and fall back
|
||||
// rather than aborting the turn.
|
||||
logger.Warn(ctx, "failed to read pinned workspace MCP tools; falling back to live discovery",
|
||||
logger.Warn(ctx, "failed to read pinned workspace MCP tools",
|
||||
slog.F("chat_id", chat.ID), slog.Error(err))
|
||||
} else if ok {
|
||||
return pinned
|
||||
return nil
|
||||
}
|
||||
return p.discoverWorkspaceMCPTools(ctx, logger, chat.ID, workspaceCtx)
|
||||
return tools
|
||||
}
|
||||
|
||||
// pinnedWorkspaceMCPTools builds workspace MCP tools from the chat's pinned
|
||||
// context snapshot (chat_context_resources) instead of dialing the agent for
|
||||
// a live tool list. ok reports whether the caller should use these tools
|
||||
// instead of the live discovery path. It is false when the chat has no pinned
|
||||
// rows (an older agent that never reported context, or a chat not yet
|
||||
// hydrated), so the caller falls back. When rows exist ok is true even if none
|
||||
// are MCP servers, because the pin is then authoritative: a workspace with no
|
||||
// MCP servers contributes no tools.
|
||||
//
|
||||
// Each tool still proxies its calls back through the workspace agent
|
||||
// connection; the snapshot carries tool definitions, not a way to execute
|
||||
// them, so execution requires a reachable agent. There is no per-chat cache to
|
||||
// invalidate on the pinned path: a server removed or renamed in the workspace
|
||||
// surfaces as a dirty chat on the agent's next push, and the user refreshes to
|
||||
// re-pin, so a nil invalidate callback (a 404 no-op) is correct here.
|
||||
// context snapshot (chat_context_resources). Each tool still proxies its calls
|
||||
// back through the workspace agent connection; the snapshot carries tool
|
||||
// definitions, not a way to execute them, so execution requires a reachable
|
||||
// agent. There is no per-chat cache to invalidate: a server removed or renamed
|
||||
// in the workspace surfaces as a dirty chat on the agent's next push, and the
|
||||
// user refreshes to re-pin, so a nil invalidate callback (a 404 no-op) is
|
||||
// correct here.
|
||||
func (p *Server) pinnedWorkspaceMCPTools(
|
||||
ctx context.Context,
|
||||
chat database.Chat,
|
||||
getConn func(context.Context) (workspacesdk.AgentConn, error),
|
||||
) (tools []fantasy.AgentTool, ok bool, err error) {
|
||||
) ([]fantasy.AgentTool, error) {
|
||||
resources, err := p.db.ListChatContextResourcesByChatID(ctx, chat.ID)
|
||||
if err != nil {
|
||||
return nil, false, xerrors.Errorf("list chat context resources: %w", err)
|
||||
}
|
||||
if len(resources) == 0 {
|
||||
return nil, false, nil
|
||||
return nil, xerrors.Errorf("list chat context resources: %w", err)
|
||||
}
|
||||
infos := workspaceMCPToolInfosFromResources(resources)
|
||||
tools = make([]fantasy.AgentTool, 0, len(infos))
|
||||
tools := make([]fantasy.AgentTool, 0, len(infos))
|
||||
for _, info := range infos {
|
||||
tools = append(tools, chattool.NewWorkspaceMCPTool(info, getConn, nil))
|
||||
}
|
||||
return tools, true, nil
|
||||
}
|
||||
|
||||
// primeWorkspaceMCPCache populates workspaceMCPToolsCache after the
|
||||
// create_workspace or start_workspace tool finishes waiting for the
|
||||
// workspace agent to become reachable. By the time it runs the agent
|
||||
// is already Ready, so a single ListMCPTools call usually succeeds.
|
||||
// When the agent's MCP server is still racing with agent startup,
|
||||
// ListMCPTools may return an empty list (no error) on the first call;
|
||||
// the primer retries with a short backoff up to
|
||||
// workspaceMCPPrimeMaxWait so the generation action that follows the
|
||||
// tool call sees the workspace MCP tools in the cache and does not need
|
||||
// to dial again.
|
||||
//
|
||||
// Returns silently on every failure mode. The chat continues without
|
||||
// workspace MCP tools when the agent does not advertise any within
|
||||
// the budget. The next user turn re-runs top-of-turn discovery from
|
||||
// scratch.
|
||||
func (p *Server) primeWorkspaceMCPCache(
|
||||
ctx context.Context,
|
||||
logger slog.Logger,
|
||||
chatID uuid.UUID,
|
||||
workspaceCtx *turnWorkspaceContext,
|
||||
) {
|
||||
deadline := p.clock.Now().Add(workspaceMCPPrimeMaxWait)
|
||||
attempt := 0
|
||||
for {
|
||||
attempt++
|
||||
tools := p.discoverWorkspaceMCPTools(ctx, logger, chatID, workspaceCtx)
|
||||
if len(tools) > 0 {
|
||||
logger.Debug(ctx, "primed workspace MCP cache",
|
||||
slog.F("chat_id", chatID),
|
||||
slog.F("tool_count", len(tools)),
|
||||
slog.F("attempts", attempt),
|
||||
)
|
||||
return
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
if !p.clock.Now().Before(deadline) {
|
||||
logger.Debug(ctx,
|
||||
"workspace MCP cache primer gave up waiting for tools",
|
||||
slog.F("chat_id", chatID),
|
||||
slog.F("attempts", attempt),
|
||||
)
|
||||
return
|
||||
}
|
||||
timer := p.clock.NewTimer(workspaceMCPPrimeRetryInterval, "chatd", "workspace-mcp-prime")
|
||||
select {
|
||||
case <-timer.C:
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
return
|
||||
}
|
||||
}
|
||||
return tools, nil
|
||||
}
|
||||
|
||||
type turnWorkspaceContext struct {
|
||||
@@ -3585,7 +3389,6 @@ func (p *Server) publishChatPubsubEvents(chats []database.Chat, kind codersdk.Ch
|
||||
func chatWatchEventSDKChat(chat database.Chat, diffStatus *codersdk.ChatDiffStatus) codersdk.Chat {
|
||||
sdkChat := db2sdk.Chat(chat, nil, nil)
|
||||
sdkChat.Files = nil
|
||||
sdkChat.LastInjectedContext = nil
|
||||
if diffStatus != nil {
|
||||
sdkChat.DiffStatus = diffStatus
|
||||
}
|
||||
@@ -4079,11 +3882,6 @@ type rootChatToolsOptions struct {
|
||||
resolvePlanPath func(context.Context) (string, string, error)
|
||||
storeFile chattool.StoreFileFunc
|
||||
isPlanModeTurn bool
|
||||
// primerCtx scopes the workspace MCP cache primer goroutines
|
||||
// that onChatUpdated launches. runChat cancels it before
|
||||
// workspaceCtx.close() so an in-flight primer cannot dial a
|
||||
// fresh conn after the cached one was released.
|
||||
primerCtx context.Context
|
||||
}
|
||||
|
||||
func (p *Server) loadPlanModeInstructions(
|
||||
@@ -4203,56 +4001,6 @@ func (p *Server) appendRootChatTools(
|
||||
// Notify the frontend immediately so it can start streaming
|
||||
// build logs before the tool completes.
|
||||
p.publishChatPubsubEvent(updatedChat, codersdk.ChatWatchEventKindStatusChange, nil)
|
||||
|
||||
// Note: we intentionally do not insert AGENTS.md / workspace
|
||||
// context here. Local tool callbacks must not mutate chat
|
||||
// history while a local-tool generation task is in flight,
|
||||
// because that advances history_version before the tool
|
||||
// result is committed and exits the local-tool commit as
|
||||
// stale. Workspace context is persisted by the
|
||||
// persist_workspace_context generation action in a later
|
||||
// pass.
|
||||
|
||||
// Prime the workspace MCP tools cache while the create_workspace
|
||||
// or start_workspace tool is still running. The AgentID guard
|
||||
// below restricts the primer to the post-ready callback, when
|
||||
// the agent is reachable. ListMCPTools may still return an
|
||||
// empty list on the first try when the agent's MCP Connect is
|
||||
// racing with agent startup; primeWorkspaceMCPCache retries
|
||||
// with a short backoff up to workspaceMCPPrimeMaxWait. Priming
|
||||
// here lets the next assistant-generation action hit the cache
|
||||
// instead of dialing again on a separate timeout budget.
|
||||
//
|
||||
// Run asynchronously: the tool itself must not block on the
|
||||
// primer because the agent may not advertise any MCP tools at
|
||||
// all (e.g. minimal templates), in which case the primer waits
|
||||
// the full budget before giving up. The next assistant-generation
|
||||
// action covers the cache miss path; the primer is purely an
|
||||
// optimization that warms the cache while the LLM is thinking.
|
||||
// inflight tracking ensures server shutdown still waits for any
|
||||
// in-progress primer.
|
||||
//
|
||||
// Guard on both WorkspaceID and AgentID being valid:
|
||||
// create_workspace and start_workspace each fire onChatUpdated
|
||||
// twice for a new build (binding before waitForAgentReady;
|
||||
// post-ready after it), and stop_workspace fires it with a nil
|
||||
// agent. Only the post-ready callback has a live AgentID, so
|
||||
// the pre-build and stop-side firings would otherwise spawn a
|
||||
// primer goroutine that dials a missing or dying agent and
|
||||
// burns the full budget for nothing.
|
||||
snapshot := opts.workspaceCtx.currentChatSnapshot()
|
||||
if snapshot.WorkspaceID.Valid && snapshot.AgentID.Valid {
|
||||
if err := p.goInflight(func() {
|
||||
p.primeWorkspaceMCPCache(opts.primerCtx, p.logger, snapshot.ID, opts.workspaceCtx)
|
||||
}); err != nil {
|
||||
p.logger.Error(context.WithoutCancel(ctx), "failed to schedule workspace MCP cache primer",
|
||||
slog.F("chat_id", snapshot.ID),
|
||||
slog.F("workspace_id", snapshot.WorkspaceID.UUID),
|
||||
slog.F("agent_id", snapshot.AgentID.UUID),
|
||||
slog.Error(err),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tools = append(tools,
|
||||
@@ -4726,251 +4474,6 @@ func refreshChatWorkspaceSnapshot(
|
||||
return refreshedChat, nil
|
||||
}
|
||||
|
||||
// contextFileAgentID extracts the workspace agent ID from the most
|
||||
// recent persisted instruction-file parts. The skill-only sentinel is
|
||||
// ignored because it does not represent persisted instruction content.
|
||||
// Returns uuid.Nil, false if no instruction-file parts exist.
|
||||
func contextFileAgentID(messages []database.ChatMessage) (uuid.UUID, bool) {
|
||||
var lastID uuid.UUID
|
||||
found := false
|
||||
for _, msg := range messages {
|
||||
if !msg.Content.Valid || !bytes.Contains(msg.Content.RawMessage, []byte(`"context-file"`)) {
|
||||
continue
|
||||
}
|
||||
var parts []codersdk.ChatMessagePart
|
||||
if err := json.Unmarshal(msg.Content.RawMessage, &parts); err != nil {
|
||||
continue
|
||||
}
|
||||
for _, p := range parts {
|
||||
if p.Type != codersdk.ChatMessagePartTypeContextFile ||
|
||||
!p.ContextFileAgentID.Valid ||
|
||||
p.ContextFilePath == AgentChatContextSentinelPath {
|
||||
continue
|
||||
}
|
||||
lastID = p.ContextFileAgentID.UUID
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
return lastID, found
|
||||
}
|
||||
|
||||
// fetchWorkspaceContext retrieves fresh instruction files and
|
||||
// skills from the workspace agent without persisting. It handles
|
||||
// agent connection, context configuration fetching, content
|
||||
// sanitization, and metadata stamping. Returns the workspace
|
||||
// agent, the stamped parts, discovered skills, and whether the
|
||||
// workspace connection succeeded. A nil agent means the chat has
|
||||
// no valid workspace or the agent lookup failed;
|
||||
// workspaceConnOK is false in that case.
|
||||
func (p *Server) fetchWorkspaceContext(
|
||||
ctx context.Context,
|
||||
chat database.Chat,
|
||||
getWorkspaceAgent func(context.Context) (database.WorkspaceAgent, error),
|
||||
getWorkspaceConn func(context.Context) (workspacesdk.AgentConn, error),
|
||||
) (agent *database.WorkspaceAgent, agentParts []codersdk.ChatMessagePart, discoveredSkills []chattool.SkillMeta, workspaceConnOK bool) {
|
||||
if !chat.WorkspaceID.Valid || getWorkspaceAgent == nil {
|
||||
return nil, nil, nil, false
|
||||
}
|
||||
|
||||
loadedAgent, agentErr := getWorkspaceAgent(ctx)
|
||||
if agentErr != nil {
|
||||
return nil, nil, nil, false
|
||||
}
|
||||
|
||||
directory := loadedAgent.ExpandedDirectory
|
||||
if directory == "" {
|
||||
directory = loadedAgent.Directory
|
||||
}
|
||||
|
||||
// Fetch context configuration from the agent. Parts
|
||||
// arrive pre-populated with context-file and skill entries
|
||||
// so we don't need additional round-trips.
|
||||
if getWorkspaceConn != nil {
|
||||
instructionCtx, cancel := context.WithTimeout(ctx, p.instructionLookupTimeout)
|
||||
defer cancel()
|
||||
|
||||
conn, connErr := getWorkspaceConn(instructionCtx)
|
||||
if connErr != nil {
|
||||
p.logger.Debug(ctx, "failed to resolve workspace connection for instruction files",
|
||||
slog.F("chat_id", chat.ID),
|
||||
slog.Error(connErr),
|
||||
)
|
||||
} else {
|
||||
workspaceConnOK = true
|
||||
|
||||
agentCfg, cfgErr := conn.ContextConfig(instructionCtx)
|
||||
if cfgErr != nil {
|
||||
p.logger.Debug(ctx, "failed to fetch context config from agent",
|
||||
slog.F("chat_id", chat.ID), slog.Error(cfgErr))
|
||||
// Treat a transient ContextConfig failure the
|
||||
// same as a failed connection so no sentinel is
|
||||
// persisted. The next turn will retry.
|
||||
workspaceConnOK = false
|
||||
} else {
|
||||
agentParts = agentCfg.Parts
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Stamp server-side fields and sanitize content. The
|
||||
// agent cannot know its own UUID, OS metadata, or
|
||||
// directory, those are added here at the trust boundary.
|
||||
agentID := uuid.NullUUID{UUID: loadedAgent.ID, Valid: true}
|
||||
|
||||
for i := range agentParts {
|
||||
agentParts[i].ContextFileAgentID = agentID
|
||||
switch agentParts[i].Type {
|
||||
case codersdk.ChatMessagePartTypeContextFile:
|
||||
agentParts[i].ContextFileContent = SanitizePromptText(agentParts[i].ContextFileContent)
|
||||
agentParts[i].ContextFileOS = loadedAgent.OperatingSystem
|
||||
agentParts[i].ContextFileDirectory = directory
|
||||
case codersdk.ChatMessagePartTypeSkill:
|
||||
discoveredSkills = append(discoveredSkills, chattool.SkillMeta{
|
||||
Name: agentParts[i].SkillName,
|
||||
Description: agentParts[i].SkillDescription,
|
||||
Dir: agentParts[i].SkillDir,
|
||||
MetaFile: agentParts[i].ContextFileSkillMetaFile,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return &loadedAgent, agentParts, discoveredSkills, workspaceConnOK
|
||||
}
|
||||
|
||||
func filterSkillParts(parts []codersdk.ChatMessagePart) []codersdk.ChatMessagePart {
|
||||
var filtered []codersdk.ChatMessagePart
|
||||
for _, part := range parts {
|
||||
if part.Type == codersdk.ChatMessagePartTypeSkill {
|
||||
filtered = append(filtered, part)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
// persistInstructionFiles fetches AGENTS.md instruction files and
|
||||
// skills from the workspace agent, persisting both as message
|
||||
// parts. This is called once when a workspace is first attached
|
||||
// to a chat (or when the agent changes). Returns the formatted
|
||||
// instruction string and skill index for injection into the
|
||||
// current turn's prompt.
|
||||
func (p *Server) persistInstructionFiles(
|
||||
ctx context.Context,
|
||||
chat database.Chat,
|
||||
modelConfigID uuid.UUID,
|
||||
getWorkspaceAgent func(context.Context) (database.WorkspaceAgent, error),
|
||||
getWorkspaceConn func(context.Context) (workspacesdk.AgentConn, error),
|
||||
) (instruction string, skills []chattool.SkillMeta, err error) {
|
||||
agent, agentParts, discoveredSkills, workspaceConnOK := p.fetchWorkspaceContext(
|
||||
ctx, chat, getWorkspaceAgent, getWorkspaceConn,
|
||||
)
|
||||
if agent == nil {
|
||||
return "", nil, nil
|
||||
}
|
||||
|
||||
agentID := uuid.NullUUID{UUID: agent.ID, Valid: true}
|
||||
hasContent := false
|
||||
hasContextFilePart := false
|
||||
for _, part := range agentParts {
|
||||
if part.Type == codersdk.ChatMessagePartTypeContextFile {
|
||||
hasContextFilePart = true
|
||||
if part.ContextFileContent != "" {
|
||||
hasContent = true
|
||||
}
|
||||
}
|
||||
}
|
||||
directory := agent.ExpandedDirectory
|
||||
if directory == "" {
|
||||
directory = agent.Directory
|
||||
}
|
||||
|
||||
contextAPIKeyID, _ := aibridge.DelegatedAPIKeyIDFromContext(ctx)
|
||||
if !hasContent {
|
||||
if !workspaceConnOK {
|
||||
return "", nil, nil
|
||||
}
|
||||
if !hasContextFilePart {
|
||||
agentParts = append([]codersdk.ChatMessagePart{{
|
||||
Type: codersdk.ChatMessagePartTypeContextFile,
|
||||
ContextFileAgentID: agentID,
|
||||
}}, agentParts...)
|
||||
}
|
||||
content, err := chatprompt.MarshalParts(agentParts)
|
||||
if err != nil {
|
||||
return "", nil, nil
|
||||
}
|
||||
msgParams := database.InsertChatMessagesParams{ //nolint:exhaustruct // Fields populated by appendUserChatMessage.
|
||||
ChatID: chat.ID,
|
||||
}
|
||||
appendUserChatMessage(&msgParams, newUserChatMessage(
|
||||
contextAPIKeyID,
|
||||
content,
|
||||
database.ChatMessageVisibilityBoth,
|
||||
modelConfigID,
|
||||
chatprompt.CurrentContentVersion,
|
||||
))
|
||||
_, _ = p.db.InsertChatMessages(ctx, msgParams)
|
||||
skillParts := filterSkillParts(agentParts)
|
||||
p.updateLastInjectedContext(ctx, chat.ID, skillParts)
|
||||
return "", discoveredSkills, nil
|
||||
}
|
||||
content, err := chatprompt.MarshalParts(agentParts)
|
||||
if err != nil {
|
||||
return "", nil, xerrors.Errorf("marshal context-file parts: %w", err)
|
||||
}
|
||||
|
||||
msgParams := database.InsertChatMessagesParams{ //nolint:exhaustruct // Fields populated by appendUserChatMessage.
|
||||
ChatID: chat.ID,
|
||||
}
|
||||
appendUserChatMessage(&msgParams, newUserChatMessage(
|
||||
contextAPIKeyID,
|
||||
content,
|
||||
database.ChatMessageVisibilityBoth,
|
||||
modelConfigID,
|
||||
chatprompt.CurrentContentVersion,
|
||||
))
|
||||
if _, err := p.db.InsertChatMessages(ctx, msgParams); err != nil {
|
||||
return "", nil, xerrors.Errorf("persist instruction files: %w", err)
|
||||
}
|
||||
stripped := make([]codersdk.ChatMessagePart, len(agentParts))
|
||||
copy(stripped, agentParts)
|
||||
for i := range stripped {
|
||||
stripped[i].StripInternal()
|
||||
}
|
||||
p.updateLastInjectedContext(ctx, chat.ID, stripped)
|
||||
|
||||
return formatSystemInstructions(agent.OperatingSystem, directory, agentParts), discoveredSkills, nil
|
||||
}
|
||||
|
||||
// updateLastInjectedContext persists the injected context
|
||||
// parts (AGENTS.md files and skills) on the chat row so they
|
||||
// are directly queryable without scanning messages. This is
|
||||
// best-effort, a failure here is logged but does not block
|
||||
// the turn.
|
||||
func (p *Server) updateLastInjectedContext(ctx context.Context, chatID uuid.UUID, parts []codersdk.ChatMessagePart) {
|
||||
param := pqtype.NullRawMessage{Valid: false}
|
||||
if parts != nil {
|
||||
raw, err := json.Marshal(parts)
|
||||
if err != nil {
|
||||
p.logger.Warn(ctx, "failed to marshal injected context",
|
||||
slog.F("chat_id", chatID),
|
||||
slog.Error(err),
|
||||
)
|
||||
return
|
||||
}
|
||||
param = pqtype.NullRawMessage{RawMessage: raw, Valid: true}
|
||||
}
|
||||
if _, err := p.db.UpdateChatLastInjectedContext(ctx, database.UpdateChatLastInjectedContextParams{
|
||||
ID: chatID,
|
||||
LastInjectedContext: param,
|
||||
}); err != nil {
|
||||
p.logger.Warn(ctx, "failed to update injected context",
|
||||
slog.F("chat_id", chatID),
|
||||
slog.Error(err),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// resolveUserCompactionThreshold looks up the user's per-model
|
||||
// compaction threshold override. Returns the override value and
|
||||
// true if one exists and is valid, or 0 and false otherwise.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+60
-1084
File diff suppressed because it is too large
Load Diff
@@ -19,23 +19,22 @@ import (
|
||||
|
||||
// CreateChatInput configures [CreateChat].
|
||||
type CreateChatInput struct {
|
||||
OrganizationID uuid.UUID
|
||||
OwnerID uuid.UUID
|
||||
WorkspaceID uuid.NullUUID
|
||||
BuildID uuid.NullUUID
|
||||
AgentID uuid.NullUUID
|
||||
ParentChatID uuid.NullUUID
|
||||
RootChatID uuid.NullUUID
|
||||
LastModelConfigID uuid.UUID
|
||||
Title string
|
||||
Mode database.NullChatMode
|
||||
PlanMode database.NullChatPlanMode
|
||||
MCPServerIDs []uuid.UUID
|
||||
Labels pqtype.NullRawMessage
|
||||
DynamicTools pqtype.NullRawMessage
|
||||
ClientType database.ChatClientType
|
||||
InitialMessages []Message
|
||||
LastInjectedContext pqtype.NullRawMessage
|
||||
OrganizationID uuid.UUID
|
||||
OwnerID uuid.UUID
|
||||
WorkspaceID uuid.NullUUID
|
||||
BuildID uuid.NullUUID
|
||||
AgentID uuid.NullUUID
|
||||
ParentChatID uuid.NullUUID
|
||||
RootChatID uuid.NullUUID
|
||||
LastModelConfigID uuid.UUID
|
||||
Title string
|
||||
Mode database.NullChatMode
|
||||
PlanMode database.NullChatPlanMode
|
||||
MCPServerIDs []uuid.UUID
|
||||
Labels pqtype.NullRawMessage
|
||||
DynamicTools pqtype.NullRawMessage
|
||||
ClientType database.ChatClientType
|
||||
InitialMessages []Message
|
||||
}
|
||||
|
||||
// CreateChatResult is the value returned by [CreateChat]. It carries
|
||||
@@ -106,14 +105,6 @@ func CreateChat(
|
||||
if err != nil {
|
||||
return xerrors.Errorf("insert initial messages: %w", err)
|
||||
}
|
||||
if input.LastInjectedContext.Valid {
|
||||
if _, err := store.UpdateChatLastInjectedContext(ctx, database.UpdateChatLastInjectedContextParams{
|
||||
ID: chat.ID,
|
||||
LastInjectedContext: input.LastInjectedContext,
|
||||
}); err != nil {
|
||||
return xerrors.Errorf("set last injected context: %w", err)
|
||||
}
|
||||
}
|
||||
refreshed, err := store.GetChatByID(ctx, chat.ID)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("reload chat after initial messages: %w", err)
|
||||
|
||||
@@ -135,57 +135,6 @@ func renderSkillIndex(entries []skillIndexEntry, opts skillIndexFormatOptions) s
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// LoadSkillBody reads the full skill meta file for a discovered
|
||||
// skill and lists the supporting files in its directory.
|
||||
func LoadSkillBody(
|
||||
ctx context.Context,
|
||||
conn workspacesdk.AgentConn,
|
||||
skill SkillMeta,
|
||||
metaFile string,
|
||||
) (SkillContent, error) {
|
||||
metaPath := path.Join(skill.Dir, metaFile)
|
||||
|
||||
reader, _, err := conn.ReadFile(
|
||||
ctx, metaPath, 0, maxSkillMetaBytes+1,
|
||||
)
|
||||
if err != nil {
|
||||
return SkillContent{}, xerrors.Errorf(
|
||||
"read skill body: %w", err,
|
||||
)
|
||||
}
|
||||
raw, err := io.ReadAll(io.LimitReader(reader, maxSkillMetaBytes+1))
|
||||
reader.Close()
|
||||
if err != nil {
|
||||
return SkillContent{}, xerrors.Errorf(
|
||||
"read skill body bytes: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
if int64(len(raw)) > maxSkillMetaBytes {
|
||||
raw = raw[:maxSkillMetaBytes]
|
||||
}
|
||||
|
||||
_, _, body, err := workspacesdk.ParseSkillFrontmatter(string(raw))
|
||||
if err != nil {
|
||||
return SkillContent{}, xerrors.Errorf(
|
||||
"parse skill frontmatter: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
// List supporting files so the model knows what it can
|
||||
// request via read_skill_file.
|
||||
files, err := listSkillFiles(ctx, conn, skill.Dir, metaFile)
|
||||
if err != nil {
|
||||
return SkillContent{}, err
|
||||
}
|
||||
|
||||
return SkillContent{
|
||||
SkillMeta: skill,
|
||||
Body: body,
|
||||
Files: files,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// listSkillFiles lists the supporting files in a skill directory,
|
||||
// excluding the skill meta file itself. Directory entries are
|
||||
// suffixed with "/" so the model can tell them apart from files.
|
||||
@@ -524,30 +473,11 @@ func readWorkspaceSkillBody(
|
||||
return SkillContent{}, skillNotFoundResponse(requestedName), true
|
||||
}
|
||||
|
||||
// Pinned path: the SKILL.md body travels in the workspace context
|
||||
// snapshot, so it is served without dialing the workspace.
|
||||
if len(skill.Meta) > 0 {
|
||||
content, err := loadPinnedWorkspaceSkillContent(ctx, options, skill)
|
||||
if err != nil {
|
||||
return SkillContent{}, fantasy.NewTextErrorResponse(err.Error()), true
|
||||
}
|
||||
return content, fantasy.ToolResponse{}, false
|
||||
}
|
||||
|
||||
// Legacy path: read the SKILL.md body live over the workspace
|
||||
// connection for agents that have not pushed context.
|
||||
if options.GetWorkspaceConn == nil {
|
||||
return SkillContent{}, fantasy.NewTextErrorResponse(
|
||||
"workspace connection resolver is not configured",
|
||||
), true
|
||||
}
|
||||
|
||||
conn, err := options.GetWorkspaceConn(ctx)
|
||||
if err != nil {
|
||||
return SkillContent{}, fantasy.NewTextErrorResponse(err.Error()), true
|
||||
}
|
||||
|
||||
content, err := LoadSkillBody(ctx, conn, skill, cmp.Or(skill.MetaFile, DefaultSkillMetaFile))
|
||||
// The SKILL.md body travels in the workspace context snapshot, so it
|
||||
// is served from the pin without dialing the workspace. The supporting
|
||||
// file list is still a best-effort live lookup; see
|
||||
// loadPinnedWorkspaceSkillContent.
|
||||
content, err := loadPinnedWorkspaceSkillContent(ctx, options, skill)
|
||||
if err != nil {
|
||||
return SkillContent{}, fantasy.NewTextErrorResponse(err.Error()), true
|
||||
}
|
||||
|
||||
@@ -123,51 +123,6 @@ func TestFormatResolvedSkillIndex(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestLoadSkillBody(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("ReturnsBodyAndFiles", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctrl := gomock.NewController(t)
|
||||
conn := agentconnmock.NewMockAgentConn(ctrl)
|
||||
|
||||
skill := chattool.SkillMeta{
|
||||
Name: "my-skill",
|
||||
Description: "desc",
|
||||
Dir: "/work/.agents/skills/my-skill",
|
||||
}
|
||||
|
||||
// Read the full SKILL.md.
|
||||
conn.EXPECT().ReadFile(
|
||||
gomock.Any(),
|
||||
"/work/.agents/skills/my-skill/SKILL.md",
|
||||
int64(0),
|
||||
int64(64*1024+1),
|
||||
).Return(
|
||||
io.NopCloser(strings.NewReader(validSkillMD("my-skill", "desc"))),
|
||||
"text/markdown",
|
||||
nil,
|
||||
)
|
||||
|
||||
// List supporting files.
|
||||
conn.EXPECT().LS(gomock.Any(), "", gomock.Any()).Return(
|
||||
workspacesdk.LSResponse{
|
||||
Contents: []workspacesdk.LSFile{
|
||||
{Name: "SKILL.md"},
|
||||
{Name: "helper.md"},
|
||||
{Name: "roles", IsDir: true},
|
||||
},
|
||||
}, nil,
|
||||
)
|
||||
|
||||
content, err := chattool.LoadSkillBody(context.Background(), conn, skill, "SKILL.md")
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, content.Body, "Do the thing.")
|
||||
assert.Equal(t, []string{"helper.md", "roles/"}, content.Files)
|
||||
})
|
||||
}
|
||||
|
||||
func TestLoadSkillFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -309,50 +264,6 @@ func TestLoadSkillFile(t *testing.T) {
|
||||
func TestReadSkillTool(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("ValidSkill", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctrl := gomock.NewController(t)
|
||||
conn := agentconnmock.NewMockAgentConn(ctrl)
|
||||
|
||||
skills := []chattool.SkillMeta{{
|
||||
Name: "my-skill",
|
||||
Description: "test",
|
||||
Dir: "/work/.agents/skills/my-skill",
|
||||
}}
|
||||
|
||||
conn.EXPECT().ReadFile(
|
||||
gomock.Any(), gomock.Any(), int64(0), gomock.Any(),
|
||||
).Return(
|
||||
io.NopCloser(strings.NewReader(validSkillMD("my-skill", "test"))),
|
||||
"text/markdown",
|
||||
nil,
|
||||
)
|
||||
conn.EXPECT().LS(gomock.Any(), "", gomock.Any()).Return(
|
||||
workspacesdk.LSResponse{
|
||||
Contents: []workspacesdk.LSFile{
|
||||
{Name: "SKILL.md"},
|
||||
},
|
||||
}, nil,
|
||||
)
|
||||
|
||||
tool := chattool.ReadSkill(chattool.ReadSkillOptions{
|
||||
GetWorkspaceConn: func(context.Context) (workspacesdk.AgentConn, error) {
|
||||
return conn, nil
|
||||
},
|
||||
GetSkills: func() []chattool.SkillMeta { return skills },
|
||||
})
|
||||
|
||||
resp, err := tool.Run(context.Background(), fantasy.ToolCall{
|
||||
ID: "call-1",
|
||||
Name: "read_skill",
|
||||
Input: `{"name":"my-skill"}`,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.False(t, resp.IsError)
|
||||
assert.Contains(t, resp.Content, "Do the thing.")
|
||||
})
|
||||
|
||||
t.Run("PinnedBodyFromMeta", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -514,15 +425,9 @@ func TestReadSkillTool(t *testing.T) {
|
||||
Name: "my-skill",
|
||||
Description: "test",
|
||||
Dir: "/work/.agents/skills/my-skill",
|
||||
Meta: []byte(validSkillMD("my-skill", "test")),
|
||||
}}
|
||||
|
||||
conn.EXPECT().ReadFile(
|
||||
gomock.Any(), gomock.Any(), int64(0), gomock.Any(),
|
||||
).Return(
|
||||
io.NopCloser(strings.NewReader(validSkillMD("my-skill", "test"))),
|
||||
"text/markdown",
|
||||
nil,
|
||||
)
|
||||
conn.EXPECT().LS(gomock.Any(), "", gomock.Any()).Return(
|
||||
workspacesdk.LSResponse{}, nil,
|
||||
)
|
||||
@@ -566,15 +471,9 @@ func TestReadSkillTool(t *testing.T) {
|
||||
Name: "deploy",
|
||||
Description: "workspace deploy",
|
||||
Dir: "/work/.agents/skills/deploy",
|
||||
Meta: []byte(validSkillMD("deploy", "workspace deploy")),
|
||||
}}
|
||||
|
||||
conn.EXPECT().ReadFile(
|
||||
gomock.Any(), gomock.Any(), int64(0), gomock.Any(),
|
||||
).Return(
|
||||
io.NopCloser(strings.NewReader(validSkillMD("deploy", "workspace deploy"))),
|
||||
"text/markdown",
|
||||
nil,
|
||||
)
|
||||
conn.EXPECT().LS(gomock.Any(), "", gomock.Any()).Return(
|
||||
workspacesdk.LSResponse{}, nil,
|
||||
)
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
package chatd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
)
|
||||
|
||||
// agentChatContextSentinelPath marks the synthetic empty context-file
|
||||
// part used to record an attempted workspace-context fetch when no
|
||||
// AGENTS.md content is available. It mirrors the constant of the same
|
||||
// value in the chatd package so the worker can recognize sentinel
|
||||
// parts without importing chatd (which would be a cycle).
|
||||
const agentChatContextSentinelPath = ".coder/agent-chat-context-sentinel"
|
||||
|
||||
// contextFileAgentIDFromMessages returns the most recent workspace
|
||||
// agent ID stamped on a persisted context-file part, ignoring the
|
||||
// skill-only sentinel. Returns uuid.Nil, false when no stamped
|
||||
// non-sentinel context-file parts exist.
|
||||
//
|
||||
// This mirrors chatd.contextFileAgentID. It is duplicated here as a
|
||||
// small pure helper so chatworker can decide whether workspace
|
||||
// context is current without importing chatd.
|
||||
func contextFileAgentIDFromMessages(messages []database.ChatMessage) (uuid.UUID, bool) {
|
||||
var lastID uuid.UUID
|
||||
found := false
|
||||
for _, msg := range messages {
|
||||
if !msg.Content.Valid || !bytes.Contains(msg.Content.RawMessage, []byte(`"context-file"`)) {
|
||||
continue
|
||||
}
|
||||
var parts []codersdk.ChatMessagePart
|
||||
if err := json.Unmarshal(msg.Content.RawMessage, &parts); err != nil {
|
||||
continue
|
||||
}
|
||||
for _, p := range parts {
|
||||
if p.Type != codersdk.ChatMessagePartTypeContextFile ||
|
||||
!p.ContextFileAgentID.Valid ||
|
||||
p.ContextFilePath == agentChatContextSentinelPath {
|
||||
continue
|
||||
}
|
||||
lastID = p.ContextFileAgentID.UUID
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
return lastID, found
|
||||
}
|
||||
|
||||
// hasPersistedContextFileForAgent reports whether messages include
|
||||
// any persisted context-file marker for the given agent, including
|
||||
// the skill-only sentinel. This is true once the
|
||||
// persist_workspace_context action has committed at least one
|
||||
// context-file row for the agent (with or without content), so a
|
||||
// subsequent decision pass will not loop on the same agent.
|
||||
func hasPersistedContextFileForAgent(messages []database.ChatMessage, agentID uuid.UUID) bool {
|
||||
if agentID == uuid.Nil {
|
||||
return false
|
||||
}
|
||||
for _, msg := range messages {
|
||||
if !msg.Content.Valid || !bytes.Contains(msg.Content.RawMessage, []byte(`"context-file"`)) {
|
||||
continue
|
||||
}
|
||||
var parts []codersdk.ChatMessagePart
|
||||
if err := json.Unmarshal(msg.Content.RawMessage, &parts); err != nil {
|
||||
continue
|
||||
}
|
||||
for _, p := range parts {
|
||||
if p.Type != codersdk.ChatMessagePartTypeContextFile ||
|
||||
!p.ContextFileAgentID.Valid {
|
||||
continue
|
||||
}
|
||||
if p.ContextFileAgentID.UUID == agentID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -85,6 +85,33 @@ func (p *Server) HydrateAndMarkChatsDirty(ctx context.Context, tx database.Store
|
||||
}, 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
|
||||
@@ -98,32 +125,37 @@ func (p *Server) hydrateChatContextOnCreate(ctx context.Context, chat database.C
|
||||
}
|
||||
//nolint:gocritic // Chatd stamps chats it does not own as the daemon subject.
|
||||
ctx = dbauthz.AsChatd(ctx)
|
||||
|
||||
// Read the snapshot hash and copy the agent's resources in one
|
||||
// repeatable-read transaction so a concurrent push cannot commit between
|
||||
// the two and leave the chat stamped with one snapshot's hash but another
|
||||
// snapshot's resources. The NULL-hash guard inside the statement still
|
||||
// keeps a concurrent push that already hydrated the chat from being
|
||||
// clobbered.
|
||||
if err := database.ReadModifyUpdate(p.db, func(tx database.Store) error {
|
||||
aggregateHash, snapshotError, ok, err := latestAgentSnapshot(ctx, tx, chat.AgentID.UUID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return tx.HydrateAgentChatsContext(ctx, database.HydrateAgentChatsContextParams{
|
||||
AgentID: chat.AgentID.UUID,
|
||||
AggregateHash: aggregateHash,
|
||||
ContextError: snapshotError,
|
||||
})
|
||||
}); err != nil {
|
||||
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
|
||||
|
||||
@@ -83,3 +83,71 @@ func TestHydrateChatContextOnCreate(t *testing.T) {
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// TestEnsureChatContextPinnedOnFirstTurn covers the lazy-bind pinning path. An
|
||||
// API-created chat carries no agent at create, binds its agent on the first
|
||||
// turn, and must pin the agent's already-pushed snapshot then. This is the
|
||||
// mechanism that lets a workspace created mid-turn have its context pinned on
|
||||
// the next turn: the agent pushes its snapshot before the chat is bound to it,
|
||||
// so HydrateAgentChatsContext on that push cannot reach the chat, and the
|
||||
// rebind-only binding does not pin a first-time agent.
|
||||
func TestEnsureChatContextPinnedOnFirstTurn(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("PinsWhenUnpinnedAndSnapshotExists", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
ctrl := gomock.NewController(t)
|
||||
db := dbmock.NewMockStore(ctrl)
|
||||
server := &Server{db: db, logger: slogtest.Make(t, nil)}
|
||||
|
||||
agentID := uuid.New()
|
||||
chat := database.Chat{ID: uuid.New(), AgentID: uuid.NullUUID{UUID: agentID, Valid: true}}
|
||||
snapshot := database.WorkspaceAgentContextSnapshot{
|
||||
WorkspaceAgentID: agentID,
|
||||
AggregateHash: []byte{0x0a, 0x0b},
|
||||
}
|
||||
|
||||
db.EXPECT().InTx(gomock.Any(), gomock.Any()).DoAndReturn(
|
||||
func(f func(database.Store) error, _ *database.TxOptions) error { return f(db) })
|
||||
db.EXPECT().GetLatestWorkspaceAgentContextSnapshot(gomock.Any(), agentID).
|
||||
Return(snapshot, nil)
|
||||
// The guarded agent-scoped stamp, not an unconditional SetChatContextSnapshot,
|
||||
// so a concurrent push that already hydrated the chat wins.
|
||||
db.EXPECT().HydrateAgentChatsContext(gomock.Any(), database.HydrateAgentChatsContextParams{
|
||||
AgentID: agentID,
|
||||
AggregateHash: snapshot.AggregateHash,
|
||||
ContextError: snapshot.SnapshotError,
|
||||
}).Return(nil)
|
||||
|
||||
server.ensureChatContextPinnedOnFirstTurn(ctx, chat)
|
||||
})
|
||||
|
||||
t.Run("SkipsWhenAlreadyPinned", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
ctrl := gomock.NewController(t)
|
||||
// A non-NULL pinned hash means the chat is already pinned (or dirty
|
||||
// awaiting refresh); the hook must touch the database zero times so it
|
||||
// never clobbers existing bodies or a dirty chat's stale hash.
|
||||
db := dbmock.NewMockStore(ctrl)
|
||||
server := &Server{db: db, logger: slogtest.Make(t, nil)}
|
||||
|
||||
server.ensureChatContextPinnedOnFirstTurn(ctx, database.Chat{
|
||||
ID: uuid.New(),
|
||||
AgentID: uuid.NullUUID{UUID: uuid.New(), Valid: true},
|
||||
ContextAggregateHash: []byte{0x01},
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("SkipsWhenAgentless", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
ctrl := gomock.NewController(t)
|
||||
// No agent bound yet: the hook must touch the database zero times.
|
||||
db := dbmock.NewMockStore(ctrl)
|
||||
server := &Server{db: db, logger: slogtest.Make(t, nil)}
|
||||
|
||||
server.ensureChatContextPinnedOnFirstTurn(ctx, database.Chat{ID: uuid.New()})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
package chatd_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/protobuf/encoding/protojson"
|
||||
"google.golang.org/protobuf/types/known/structpb"
|
||||
|
||||
agentproto "github.com/coder/coder/v2/agent/proto"
|
||||
"github.com/coder/coder/v2/coderd/coderdtest"
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/dbauthz"
|
||||
"github.com/coder/coder/v2/coderd/database/dbgen"
|
||||
"github.com/coder/coder/v2/coderd/database/dbtime"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/codersdk/agentsdk"
|
||||
"github.com/coder/coder/v2/provisioner/echo"
|
||||
@@ -396,3 +400,107 @@ func TestChatContextRefreshFromAgentToken(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 0, refresh.Refreshed, "nothing left to refresh")
|
||||
}
|
||||
|
||||
// agentMCPToolContext specifies an mcp_server tool to seed into an agent's
|
||||
// pushed context snapshot.
|
||||
type agentMCPToolContext struct {
|
||||
AgentID uuid.UUID
|
||||
ServerName string
|
||||
ToolName string
|
||||
ToolDescription string
|
||||
}
|
||||
|
||||
// seedAgentMCPToolContext upserts an mcp_server context snapshot and resource
|
||||
// for the agent, mirroring what PushContextState writes, so a chat bound to the
|
||||
// agent hydrates a pinned, execution-ready MCP tool. The model-facing tool name
|
||||
// is "<ServerName>__<ToolName>". It seeds the raw store directly so unit tests
|
||||
// can exercise pinned MCP execution without a live agent connection.
|
||||
func seedAgentMCPToolContext(
|
||||
ctx context.Context,
|
||||
t *testing.T,
|
||||
db database.Store,
|
||||
tool agentMCPToolContext,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
schema, err := structpb.NewStruct(map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"input": map[string]any{"type": "string"},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
body, err := protojson.Marshal(&agentproto.MCPServerBody{
|
||||
ServerName: tool.ServerName,
|
||||
Tools: []*agentproto.MCPTool{{
|
||||
Name: tool.ToolName,
|
||||
Description: tool.ToolDescription,
|
||||
InputSchema: schema,
|
||||
}},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
now := dbtime.Now()
|
||||
hash := []byte(tool.ServerName + ":" + tool.ToolName)
|
||||
_, err = db.UpsertWorkspaceAgentContextSnapshot(ctx, database.UpsertWorkspaceAgentContextSnapshotParams{
|
||||
WorkspaceAgentID: tool.AgentID,
|
||||
Version: 1,
|
||||
AggregateHash: hash,
|
||||
ReceivedAt: now,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = db.UpsertWorkspaceAgentContextResource(ctx, database.UpsertWorkspaceAgentContextResourceParams{
|
||||
WorkspaceAgentID: tool.AgentID,
|
||||
Source: tool.ServerName,
|
||||
BodyKind: database.WorkspaceAgentContextBodyKindMcpServer,
|
||||
Body: body,
|
||||
ContentHash: hash,
|
||||
SizeBytes: int64(len(body)),
|
||||
Status: database.WorkspaceAgentContextResourceStatusOk,
|
||||
Now: now,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// seedAgentInstructionContext upserts an instruction_file context snapshot and
|
||||
// resource for the agent, mirroring what PushContextState writes, so a chat
|
||||
// bound to the agent hydrates a pinned instruction block. It seeds the raw
|
||||
// store directly so unit tests can exercise pinned workspace context without a
|
||||
// live agent connection.
|
||||
func seedAgentInstructionContext(
|
||||
ctx context.Context,
|
||||
t *testing.T,
|
||||
db database.Store,
|
||||
agentID uuid.UUID,
|
||||
source string,
|
||||
content string,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
body, err := protojson.Marshal(&agentproto.InstructionFileBody{Content: []byte(content)})
|
||||
require.NoError(t, err)
|
||||
|
||||
now := dbtime.Now()
|
||||
hash := []byte("instruction:" + source)
|
||||
_, err = db.UpsertWorkspaceAgentContextSnapshot(ctx, database.UpsertWorkspaceAgentContextSnapshotParams{
|
||||
WorkspaceAgentID: agentID,
|
||||
Version: 1,
|
||||
AggregateHash: hash,
|
||||
ReceivedAt: now,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = db.UpsertWorkspaceAgentContextResource(ctx, database.UpsertWorkspaceAgentContextResourceParams{
|
||||
WorkspaceAgentID: agentID,
|
||||
Source: source,
|
||||
BodyKind: database.WorkspaceAgentContextBodyKindInstructionFile,
|
||||
Body: body,
|
||||
ContentHash: hash,
|
||||
SizeBytes: int64(len(body)),
|
||||
Status: database.WorkspaceAgentContextResourceStatusOk,
|
||||
Now: now,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
@@ -17,6 +17,13 @@ import (
|
||||
"github.com/coder/coder/v2/codersdk/workspacesdk"
|
||||
)
|
||||
|
||||
// AgentChatContextSentinelPath is the canonical path of the synthetic empty
|
||||
// context-file part that legacy chats used to mark skill-only workspace-agent
|
||||
// context. New turns no longer emit it; it is retained as the canonical value
|
||||
// so historical-message handling and the chatopenai chain-mode tests stay in
|
||||
// sync.
|
||||
const AgentChatContextSentinelPath = ".coder/agent-chat-context-sentinel"
|
||||
|
||||
// contextBodyUnmarshalOptions reads the protojson resource bodies written by
|
||||
// the agent context push (coderd/agentapi/context.go). DiscardUnknown keeps
|
||||
// the reader forward compatible as new body fields are added to the proto.
|
||||
@@ -171,30 +178,24 @@ func decodeSkillIdentity(body json.RawMessage) (name, description string, decode
|
||||
|
||||
// pinnedWorkspaceContext builds the system-prompt instruction block and
|
||||
// workspace skills from the chat's pinned context resources
|
||||
// (chat_context_resources), populated at hydrate and refresh time.
|
||||
//
|
||||
// ok reports whether the caller should use these values instead of the
|
||||
// per-turn, history-derived path. It is false when the chat has no pinned
|
||||
// rows (an older agent that never reported context, or a chat not yet
|
||||
// hydrated), so the caller falls back to the legacy path. When rows exist ok
|
||||
// is true even if they all filter to empty content, because the pin is then
|
||||
// the source of truth. A read error is returned rather than swallowed,
|
||||
// matching the other prompt-input reads in prepareGeneration.
|
||||
// (chat_context_resources), populated at hydrate and refresh time. A chat
|
||||
// with no pinned rows yields no context. A read error is returned rather than
|
||||
// swallowed, matching the other prompt-input reads in prepareGeneration.
|
||||
//
|
||||
// agent only decorates the instruction header with its OS and directory; an
|
||||
// unresolved (zero-value) agent does not force a fallback, so the pin keeps
|
||||
// unresolved (zero-value) agent does not blank the context, so the pin keeps
|
||||
// working when the workspace is unreachable.
|
||||
func (server *Server) pinnedWorkspaceContext(
|
||||
ctx context.Context,
|
||||
chat database.Chat,
|
||||
agent database.WorkspaceAgent,
|
||||
) (instruction string, skills []chattool.SkillMeta, ok bool, err error) {
|
||||
) (instruction string, skills []chattool.SkillMeta, err error) {
|
||||
resources, err := server.db.ListChatContextResourcesByChatID(ctx, chat.ID)
|
||||
if err != nil {
|
||||
return "", nil, false, xerrors.Errorf("list chat context resources: %w", err)
|
||||
return "", nil, xerrors.Errorf("list chat context resources: %w", err)
|
||||
}
|
||||
if len(resources) == 0 {
|
||||
return "", nil, false, nil
|
||||
return "", nil, nil
|
||||
}
|
||||
|
||||
directory := agent.ExpandedDirectory
|
||||
@@ -218,42 +219,23 @@ func (server *Server) pinnedWorkspaceContext(
|
||||
slog.F("skill_count", len(skills)),
|
||||
slog.F("has_instruction", instruction != ""),
|
||||
)
|
||||
return instruction, skills, true, nil
|
||||
return instruction, skills, nil
|
||||
}
|
||||
|
||||
// resolveTurnWorkspaceContext selects the instruction block and workspace
|
||||
// skills for a turn. It prefers the chat's pinned context copy when the
|
||||
// workspace agent has reported context, and falls back to the per-turn,
|
||||
// history-derived context-file and skill parts for older agents that have
|
||||
// not. The two paths are mutually exclusive. agent is the chat's resolved
|
||||
// workspace agent, used only to decorate the pinned instruction header. A
|
||||
// non-workspace chat yields no context.
|
||||
// skills for a turn from the chat's pinned context snapshot
|
||||
// (chat_context_resources). agent is the chat's resolved workspace agent,
|
||||
// used only to decorate the pinned instruction header. A non-workspace chat
|
||||
// yields no context.
|
||||
func (server *Server) resolveTurnWorkspaceContext(
|
||||
ctx context.Context,
|
||||
chat database.Chat,
|
||||
agent database.WorkspaceAgent,
|
||||
promptRows []database.ChatMessage,
|
||||
) (instruction string, skills []chattool.SkillMeta, err error) {
|
||||
if !chat.WorkspaceID.Valid {
|
||||
return "", nil, nil
|
||||
}
|
||||
|
||||
pinnedInstruction, pinnedSkills, ok, err := server.pinnedWorkspaceContext(ctx, chat, agent)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
if ok {
|
||||
return pinnedInstruction, pinnedSkills, nil
|
||||
}
|
||||
|
||||
// History fallback: re-derive the instruction and skills from the
|
||||
// context-file and skill parts the per-turn pull persisted. Skills are
|
||||
// included only when context files are present; the pinned path resolves
|
||||
// them independently.
|
||||
if _, found := contextFileAgentID(promptRows); found {
|
||||
return instructionFromContextFiles(promptRows), skillsFromParts(promptRows), nil
|
||||
}
|
||||
return "", nil, nil
|
||||
return server.pinnedWorkspaceContext(ctx, chat, agent)
|
||||
}
|
||||
|
||||
// contextResourcesToPrompt converts a chat's pinned context resources into
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/sqlc-dev/pqtype"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/mock/gomock"
|
||||
"golang.org/x/xerrors"
|
||||
@@ -270,12 +269,11 @@ func TestPinnedWorkspaceContext(t *testing.T) {
|
||||
Return(nil, xerrors.New("boom"))
|
||||
server := newPinServer(t, db)
|
||||
|
||||
_, _, ok, err := server.pinnedWorkspaceContext(context.Background(), database.Chat{ID: chatID}, database.WorkspaceAgent{})
|
||||
_, _, err := server.pinnedWorkspaceContext(context.Background(), database.Chat{ID: chatID}, database.WorkspaceAgent{})
|
||||
require.Error(t, err)
|
||||
require.False(t, ok)
|
||||
})
|
||||
|
||||
t.Run("NoRowsFallsBack", func(t *testing.T) {
|
||||
t.Run("NoRowsYieldsNothing", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctrl := gomock.NewController(t)
|
||||
@@ -285,9 +283,8 @@ func TestPinnedWorkspaceContext(t *testing.T) {
|
||||
Return([]database.ChatContextResource{}, nil)
|
||||
server := newPinServer(t, db)
|
||||
|
||||
instruction, skills, ok, err := server.pinnedWorkspaceContext(context.Background(), database.Chat{ID: chatID}, database.WorkspaceAgent{})
|
||||
instruction, skills, err := server.pinnedWorkspaceContext(context.Background(), database.Chat{ID: chatID}, database.WorkspaceAgent{})
|
||||
require.NoError(t, err)
|
||||
require.False(t, ok)
|
||||
require.Empty(t, instruction)
|
||||
require.Empty(t, skills)
|
||||
})
|
||||
@@ -306,9 +303,8 @@ func TestPinnedWorkspaceContext(t *testing.T) {
|
||||
server := newPinServer(t, db)
|
||||
|
||||
agent := database.WorkspaceAgent{OperatingSystem: "linux", ExpandedDirectory: "/home/coder"}
|
||||
instruction, skills, ok, err := server.pinnedWorkspaceContext(context.Background(), database.Chat{ID: chatID}, agent)
|
||||
instruction, skills, err := server.pinnedWorkspaceContext(context.Background(), database.Chat{ID: chatID}, agent)
|
||||
require.NoError(t, err)
|
||||
require.True(t, ok)
|
||||
require.Contains(t, instruction, "Operating System: linux")
|
||||
require.Contains(t, instruction, "Source: /home/coder/AGENTS.md")
|
||||
require.Contains(t, instruction, "be helpful")
|
||||
@@ -330,9 +326,8 @@ func TestPinnedWorkspaceContext(t *testing.T) {
|
||||
|
||||
// Zero-value agent: the pin still resolves, just without the
|
||||
// OS/directory header.
|
||||
instruction, _, ok, err := server.pinnedWorkspaceContext(context.Background(), database.Chat{ID: chatID}, database.WorkspaceAgent{})
|
||||
instruction, _, err := server.pinnedWorkspaceContext(context.Background(), database.Chat{ID: chatID}, database.WorkspaceAgent{})
|
||||
require.NoError(t, err)
|
||||
require.True(t, ok)
|
||||
require.Contains(t, instruction, "Source: /home/coder/AGENTS.md")
|
||||
require.NotContains(t, instruction, "Operating System:")
|
||||
})
|
||||
@@ -415,9 +410,8 @@ func TestPinnedWorkspaceContextFromHydratedPin(t *testing.T) {
|
||||
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug)
|
||||
server := &Server{db: db, logger: logger}
|
||||
|
||||
instruction, skills, ok, err := server.pinnedWorkspaceContext(ctx, chat, agent)
|
||||
instruction, skills, err := server.pinnedWorkspaceContext(ctx, chat, agent)
|
||||
require.NoError(t, err)
|
||||
require.True(t, ok)
|
||||
require.Contains(t, instruction, "Operating System: linux")
|
||||
require.Contains(t, instruction, "Working Directory: /home/coder/ws")
|
||||
require.Contains(t, instruction, "Source: /home/coder/ws/AGENTS.md")
|
||||
@@ -428,8 +422,7 @@ func TestPinnedWorkspaceContextFromHydratedPin(t *testing.T) {
|
||||
require.Equal(t, "/home/coder/ws/.coder/skills/deploy", skills[0].Dir)
|
||||
|
||||
// A chat created after hydration keeps a NULL pinned hash and no pinned
|
||||
// rows, so the pin resolves to ok=false and the caller falls back to the
|
||||
// per-turn history path.
|
||||
// rows, so the pin yields no instruction or skills.
|
||||
unpinnedChat := dbgen.Chat(t, db, database.Chat{
|
||||
OwnerID: user.ID,
|
||||
OrganizationID: org.ID,
|
||||
@@ -438,37 +431,15 @@ func TestPinnedWorkspaceContextFromHydratedPin(t *testing.T) {
|
||||
AgentID: uuid.NullUUID{UUID: agent.ID, Valid: true},
|
||||
Status: database.ChatStatusWaiting,
|
||||
})
|
||||
_, _, ok, err = server.pinnedWorkspaceContext(ctx, unpinnedChat, agent)
|
||||
emptyInstruction, emptySkills, err := server.pinnedWorkspaceContext(ctx, unpinnedChat, agent)
|
||||
require.NoError(t, err)
|
||||
require.False(t, ok)
|
||||
}
|
||||
|
||||
func historyContextMessage(t *testing.T, agentID uuid.UUID) database.ChatMessage {
|
||||
t.Helper()
|
||||
parts := []codersdk.ChatMessagePart{
|
||||
{
|
||||
Type: codersdk.ChatMessagePartTypeContextFile,
|
||||
ContextFileAgentID: uuid.NullUUID{UUID: agentID, Valid: true},
|
||||
ContextFilePath: "/home/coder/AGENTS.md",
|
||||
ContextFileContent: "history content",
|
||||
ContextFileOS: "linux",
|
||||
ContextFileDirectory: "/home/coder",
|
||||
},
|
||||
{
|
||||
Type: codersdk.ChatMessagePartTypeSkill,
|
||||
ContextFileAgentID: uuid.NullUUID{UUID: agentID, Valid: true},
|
||||
SkillName: "history-skill",
|
||||
SkillDescription: "from history",
|
||||
},
|
||||
}
|
||||
raw, err := json.Marshal(parts)
|
||||
require.NoError(t, err)
|
||||
return database.ChatMessage{Content: pqtype.NullRawMessage{RawMessage: raw, Valid: true}}
|
||||
require.Empty(t, emptyInstruction)
|
||||
require.Empty(t, emptySkills)
|
||||
}
|
||||
|
||||
// TestResolveTurnWorkspaceContext covers the dispatch that prepareGeneration
|
||||
// wires up: the pinned copy when the chat has pinned rows, otherwise the
|
||||
// per-turn history-derived parts, and nothing for a non-workspace chat.
|
||||
// wires up: the pinned copy when the chat has pinned rows, and nothing for a
|
||||
// non-workspace chat or a chat without pinned rows.
|
||||
func TestResolveTurnWorkspaceContext(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -483,7 +454,7 @@ func TestResolveTurnWorkspaceContext(t *testing.T) {
|
||||
db := dbmock.NewMockStore(ctrl)
|
||||
server := newPinServer(t, db)
|
||||
|
||||
instruction, skills, err := server.resolveTurnWorkspaceContext(context.Background(), database.Chat{ID: uuid.New()}, database.WorkspaceAgent{}, nil)
|
||||
instruction, skills, err := server.resolveTurnWorkspaceContext(context.Background(), database.Chat{ID: uuid.New()}, database.WorkspaceAgent{})
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, instruction)
|
||||
require.Empty(t, skills)
|
||||
@@ -495,7 +466,6 @@ func TestResolveTurnWorkspaceContext(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
db := dbmock.NewMockStore(ctrl)
|
||||
chat := workspaceChat()
|
||||
agentID := uuid.New()
|
||||
db.EXPECT().ListChatContextResourcesByChatID(gomock.Any(), chat.ID).
|
||||
Return([]database.ChatContextResource{
|
||||
instructionResource(t, "/home/coder/AGENTS.md", "pinned content", database.WorkspaceAgentContextResourceStatusOk),
|
||||
@@ -503,48 +473,25 @@ func TestResolveTurnWorkspaceContext(t *testing.T) {
|
||||
}, nil)
|
||||
server := newPinServer(t, db)
|
||||
|
||||
// History rows are present too; the pinned path must take precedence.
|
||||
promptRows := []database.ChatMessage{historyContextMessage(t, agentID)}
|
||||
instruction, skills, err := server.resolveTurnWorkspaceContext(context.Background(), chat, database.WorkspaceAgent{OperatingSystem: "linux"}, promptRows)
|
||||
instruction, skills, err := server.resolveTurnWorkspaceContext(context.Background(), chat, database.WorkspaceAgent{OperatingSystem: "linux"})
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, instruction, "pinned content")
|
||||
require.NotContains(t, instruction, "history content")
|
||||
require.Len(t, skills, 1)
|
||||
require.Equal(t, "deploy", skills[0].Name)
|
||||
})
|
||||
|
||||
t.Run("HistoryFallbackWhenNoPin", func(t *testing.T) {
|
||||
t.Run("NoPinYieldsNothing", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctrl := gomock.NewController(t)
|
||||
db := dbmock.NewMockStore(ctrl)
|
||||
chat := workspaceChat()
|
||||
// No pinned rows: the resolver falls back to the per-turn history path.
|
||||
// No pinned rows: the turn carries no context.
|
||||
db.EXPECT().ListChatContextResourcesByChatID(gomock.Any(), chat.ID).
|
||||
Return([]database.ChatContextResource{}, nil)
|
||||
server := newPinServer(t, db)
|
||||
|
||||
agentID := uuid.New()
|
||||
promptRows := []database.ChatMessage{historyContextMessage(t, agentID)}
|
||||
instruction, skills, err := server.resolveTurnWorkspaceContext(context.Background(), chat, database.WorkspaceAgent{}, promptRows)
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, instruction, "history content")
|
||||
require.Len(t, skills, 1)
|
||||
require.Equal(t, "history-skill", skills[0].Name)
|
||||
})
|
||||
|
||||
t.Run("NoContextWhenHistoryEmpty", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctrl := gomock.NewController(t)
|
||||
db := dbmock.NewMockStore(ctrl)
|
||||
chat := workspaceChat()
|
||||
// No pinned rows and no history parts: the turn carries no context.
|
||||
db.EXPECT().ListChatContextResourcesByChatID(gomock.Any(), chat.ID).
|
||||
Return([]database.ChatContextResource{}, nil)
|
||||
server := newPinServer(t, db)
|
||||
|
||||
instruction, skills, err := server.resolveTurnWorkspaceContext(context.Background(), chat, database.WorkspaceAgent{}, nil)
|
||||
instruction, skills, err := server.resolveTurnWorkspaceContext(context.Background(), chat, database.WorkspaceAgent{})
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, instruction)
|
||||
require.Empty(t, skills)
|
||||
@@ -560,7 +507,7 @@ func TestResolveTurnWorkspaceContext(t *testing.T) {
|
||||
Return(nil, xerrors.New("boom"))
|
||||
server := newPinServer(t, db)
|
||||
|
||||
_, _, err := server.resolveTurnWorkspaceContext(context.Background(), chat, database.WorkspaceAgent{}, nil)
|
||||
_, _, err := server.resolveTurnWorkspaceContext(context.Background(), chat, database.WorkspaceAgent{})
|
||||
require.Error(t, err)
|
||||
})
|
||||
}
|
||||
@@ -807,7 +754,7 @@ func TestPinnedWorkspaceMCPTools(t *testing.T) {
|
||||
return nil, xerrors.New("not dialed in this test")
|
||||
}
|
||||
|
||||
t.Run("NoRowsFallsBack", func(t *testing.T) {
|
||||
t.Run("NoRowsYieldsNoTools", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctrl := gomock.NewController(t)
|
||||
@@ -817,9 +764,8 @@ func TestPinnedWorkspaceMCPTools(t *testing.T) {
|
||||
Return([]database.ChatContextResource{}, nil)
|
||||
server := newPinServer(t, db)
|
||||
|
||||
tools, ok, err := server.pinnedWorkspaceMCPTools(context.Background(), database.Chat{ID: chatID}, getConn)
|
||||
tools, err := server.pinnedWorkspaceMCPTools(context.Background(), database.Chat{ID: chatID}, getConn)
|
||||
require.NoError(t, err)
|
||||
require.False(t, ok)
|
||||
require.Empty(t, tools)
|
||||
})
|
||||
|
||||
@@ -833,9 +779,8 @@ func TestPinnedWorkspaceMCPTools(t *testing.T) {
|
||||
Return(nil, xerrors.New("boom"))
|
||||
server := newPinServer(t, db)
|
||||
|
||||
_, ok, err := server.pinnedWorkspaceMCPTools(context.Background(), database.Chat{ID: chatID}, getConn)
|
||||
_, err := server.pinnedWorkspaceMCPTools(context.Background(), database.Chat{ID: chatID}, getConn)
|
||||
require.Error(t, err)
|
||||
require.False(t, ok)
|
||||
})
|
||||
|
||||
t.Run("BuildsToolsFromMCPServers", func(t *testing.T) {
|
||||
@@ -857,9 +802,8 @@ func TestPinnedWorkspaceMCPTools(t *testing.T) {
|
||||
}, nil)
|
||||
server := newPinServer(t, db)
|
||||
|
||||
tools, ok, err := server.pinnedWorkspaceMCPTools(context.Background(), database.Chat{ID: chatID}, getConn)
|
||||
tools, err := server.pinnedWorkspaceMCPTools(context.Background(), database.Chat{ID: chatID}, getConn)
|
||||
require.NoError(t, err)
|
||||
require.True(t, ok)
|
||||
require.Len(t, tools, 2)
|
||||
require.Equal(t, "github__create_issue", tools[0].Info().Name)
|
||||
require.Equal(t, "github__search", tools[1].Info().Name)
|
||||
@@ -872,17 +816,16 @@ func TestPinnedWorkspaceMCPTools(t *testing.T) {
|
||||
db := dbmock.NewMockStore(ctrl)
|
||||
chatID := uuid.New()
|
||||
// The chat is pinned (an instruction file is present) but the agent
|
||||
// reported no MCP servers: ok is true with zero tools so the caller does
|
||||
// not fall back to a live pull that could resurrect stale tools.
|
||||
// reported no MCP servers: the pin is authoritative, yielding zero
|
||||
// tools without a live pull that could resurrect stale tools.
|
||||
db.EXPECT().ListChatContextResourcesByChatID(gomock.Any(), chatID).
|
||||
Return([]database.ChatContextResource{
|
||||
instructionResource(t, "/home/coder/AGENTS.md", "be helpful", database.WorkspaceAgentContextResourceStatusOk),
|
||||
}, nil)
|
||||
server := newPinServer(t, db)
|
||||
|
||||
tools, ok, err := server.pinnedWorkspaceMCPTools(context.Background(), database.Chat{ID: chatID}, getConn)
|
||||
tools, err := server.pinnedWorkspaceMCPTools(context.Background(), database.Chat{ID: chatID}, getConn)
|
||||
require.NoError(t, err)
|
||||
require.True(t, ok)
|
||||
require.Empty(t, tools)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,153 +0,0 @@
|
||||
package chatd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/sqlc-dev/pqtype"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"cdr.dev/slog/v3"
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
)
|
||||
|
||||
// AgentChatContextSentinelPath marks the synthetic empty context-file
|
||||
// part used to preserve skill-only workspace-agent additions across
|
||||
// turns without treating them as persisted instruction files.
|
||||
const AgentChatContextSentinelPath = ".coder/agent-chat-context-sentinel"
|
||||
|
||||
// FilterContextParts keeps only context-file and skill parts from parts.
|
||||
// When keepEmptyContextFiles is false, context-file parts with empty
|
||||
// content are dropped. When keepEmptyContextFiles is true, empty
|
||||
// context-file parts are preserved.
|
||||
// revive:disable-next-line:flag-parameter // Required by shared helper callers.
|
||||
func FilterContextParts(
|
||||
parts []codersdk.ChatMessagePart,
|
||||
keepEmptyContextFiles bool,
|
||||
) []codersdk.ChatMessagePart {
|
||||
var filtered []codersdk.ChatMessagePart
|
||||
for _, part := range parts {
|
||||
switch part.Type {
|
||||
case codersdk.ChatMessagePartTypeContextFile:
|
||||
if !keepEmptyContextFiles && part.ContextFileContent == "" {
|
||||
continue
|
||||
}
|
||||
case codersdk.ChatMessagePartTypeSkill:
|
||||
default:
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, part)
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
// CollectContextPartsFromMessages unmarshals chat message content and
|
||||
// collects the context-file and skill parts it contains. When
|
||||
// keepEmptyContextFiles is false, empty context-file parts are skipped.
|
||||
// When it is true, empty context-file parts are included in the result.
|
||||
func CollectContextPartsFromMessages(
|
||||
ctx context.Context,
|
||||
logger slog.Logger,
|
||||
messages []database.ChatMessage,
|
||||
keepEmptyContextFiles bool,
|
||||
) ([]codersdk.ChatMessagePart, error) {
|
||||
var collected []codersdk.ChatMessagePart
|
||||
for _, msg := range messages {
|
||||
if !msg.Content.Valid {
|
||||
continue
|
||||
}
|
||||
|
||||
var parts []codersdk.ChatMessagePart
|
||||
if err := json.Unmarshal(msg.Content.RawMessage, &parts); err != nil {
|
||||
logger.Warn(ctx, "skipping malformed chat context message",
|
||||
slog.F("chat_message_id", msg.ID),
|
||||
slog.Error(err),
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
collected = append(
|
||||
collected,
|
||||
FilterContextParts(parts, keepEmptyContextFiles)...,
|
||||
)
|
||||
}
|
||||
|
||||
return collected, nil
|
||||
}
|
||||
|
||||
func latestContextAgentIDFromParts(parts []codersdk.ChatMessagePart) (uuid.UUID, bool) {
|
||||
var lastID uuid.UUID
|
||||
found := false
|
||||
for _, part := range parts {
|
||||
if part.Type != codersdk.ChatMessagePartTypeContextFile ||
|
||||
!part.ContextFileAgentID.Valid {
|
||||
continue
|
||||
}
|
||||
lastID = part.ContextFileAgentID.UUID
|
||||
found = true
|
||||
}
|
||||
return lastID, found
|
||||
}
|
||||
|
||||
// FilterContextPartsToLatestAgent keeps parts stamped with the latest
|
||||
// workspace-agent ID seen in the slice, plus legacy unstamped parts.
|
||||
// When no stamped context-file parts exist, it returns the original
|
||||
// slice unchanged.
|
||||
func FilterContextPartsToLatestAgent(parts []codersdk.ChatMessagePart) []codersdk.ChatMessagePart {
|
||||
latestAgentID, ok := latestContextAgentIDFromParts(parts)
|
||||
if !ok {
|
||||
return parts
|
||||
}
|
||||
|
||||
filtered := make([]codersdk.ChatMessagePart, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
switch part.Type {
|
||||
case codersdk.ChatMessagePartTypeContextFile,
|
||||
codersdk.ChatMessagePartTypeSkill:
|
||||
if part.ContextFileAgentID.Valid &&
|
||||
part.ContextFileAgentID.UUID != latestAgentID {
|
||||
continue
|
||||
}
|
||||
default:
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, part)
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
// BuildLastInjectedContext filters parts down to non-empty context-file
|
||||
// and skill parts, strips their internal fields, and marshals the
|
||||
// result for LastInjectedContext. A nil or fully filtered input returns
|
||||
// an invalid NullRawMessage.
|
||||
func BuildLastInjectedContext(
|
||||
parts []codersdk.ChatMessagePart,
|
||||
) (pqtype.NullRawMessage, error) {
|
||||
if parts == nil {
|
||||
return pqtype.NullRawMessage{Valid: false}, nil
|
||||
}
|
||||
|
||||
filtered := FilterContextParts(parts, false)
|
||||
if len(filtered) == 0 {
|
||||
return pqtype.NullRawMessage{Valid: false}, nil
|
||||
}
|
||||
|
||||
stripped := make([]codersdk.ChatMessagePart, 0, len(filtered))
|
||||
for _, part := range filtered {
|
||||
cp := part
|
||||
cp.StripInternal()
|
||||
stripped = append(stripped, cp)
|
||||
}
|
||||
|
||||
raw, err := json.Marshal(stripped)
|
||||
if err != nil {
|
||||
return pqtype.NullRawMessage{}, xerrors.Errorf(
|
||||
"marshal injected context: %w",
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
return pqtype.NullRawMessage{RawMessage: raw, Valid: true}, nil
|
||||
}
|
||||
@@ -69,12 +69,6 @@ type generationPrepared struct {
|
||||
Cleanup func()
|
||||
|
||||
Debug *generationDebug
|
||||
|
||||
// WorkspaceContextEligible reports whether the current turn is allowed
|
||||
// by policy to inject workspace context. The decision helper combines
|
||||
// this fact with committed chat metadata and history to decide whether
|
||||
// the persist_workspace_context action should run.
|
||||
WorkspaceContextEligible bool
|
||||
}
|
||||
|
||||
// generationCompaction contains compaction inputs prepared for generation.
|
||||
@@ -94,16 +88,6 @@ type generationDebug struct {
|
||||
ModelConfig database.ChatModelConfig
|
||||
}
|
||||
|
||||
type workspaceContextBuildInput struct {
|
||||
Chat database.Chat
|
||||
Messages []database.ChatMessage
|
||||
ActiveAPIKeyID string
|
||||
}
|
||||
|
||||
type workspaceContextBuildResult struct {
|
||||
Messages []chatstate.Message
|
||||
}
|
||||
|
||||
// generationOutcome describes a completed generation outcome.
|
||||
type generationOutcome struct {
|
||||
Chat database.Chat
|
||||
@@ -117,12 +101,11 @@ type generationOutcome struct {
|
||||
type generationActionKind string
|
||||
|
||||
const (
|
||||
generationActionExecuteLocalTools generationActionKind = "execute_local_tools"
|
||||
generationActionEnterRequiresAction generationActionKind = "enter_requires_action"
|
||||
generationActionFinishTurn generationActionKind = "finish_turn"
|
||||
generationActionCompact generationActionKind = "compact"
|
||||
generationActionGenerateAssistant generationActionKind = "generate_assistant"
|
||||
generationActionPersistWorkspaceContext generationActionKind = "persist_workspace_context"
|
||||
generationActionExecuteLocalTools generationActionKind = "execute_local_tools"
|
||||
generationActionEnterRequiresAction generationActionKind = "enter_requires_action"
|
||||
generationActionFinishTurn generationActionKind = "finish_turn"
|
||||
generationActionCompact generationActionKind = "compact"
|
||||
generationActionGenerateAssistant generationActionKind = "generate_assistant"
|
||||
)
|
||||
|
||||
type generationFinishReason string
|
||||
@@ -193,34 +176,6 @@ type generationDecisionInput struct {
|
||||
compactionNeeded bool
|
||||
compactionThresholdPercent int32
|
||||
compactionContextLimit int64
|
||||
workspaceContextEligible bool
|
||||
}
|
||||
|
||||
// shouldPersistWorkspaceContext reports whether the committed chat
|
||||
// state and history indicate that the persistWorkspaceContext
|
||||
// generation action should run before the next assistant call. The
|
||||
// decision uses two facts:
|
||||
// - chat metadata says a workspace and selected agent are attached;
|
||||
// - committed history either has no context-file marker for the
|
||||
// currently selected workspace agent, or the latest non-sentinel
|
||||
// marker points to a different agent.
|
||||
//
|
||||
// The decision is intentionally pure so generation can choose the
|
||||
// action without dialing the workspace. Once the action commits a
|
||||
// context-file marker for the agent (with or without content), this
|
||||
// helper returns false on the next pass and the loop is broken.
|
||||
func shouldPersistWorkspaceContext(chat database.Chat, messages []database.ChatMessage) bool {
|
||||
if !chat.WorkspaceID.Valid || !chat.AgentID.Valid {
|
||||
return false
|
||||
}
|
||||
if hasPersistedContextFileForAgent(messages, chat.AgentID.UUID) {
|
||||
return false
|
||||
}
|
||||
persistedAgentID, found := contextFileAgentIDFromMessages(messages)
|
||||
if !found {
|
||||
return true
|
||||
}
|
||||
return persistedAgentID != chat.AgentID.UUID
|
||||
}
|
||||
|
||||
func decideGenerationAction(input generationDecisionInput) (generationDecision, error) {
|
||||
@@ -262,9 +217,6 @@ func decideGenerationAction(input generationDecisionInput) (generationDecision,
|
||||
if input.maxSteps > 0 && currentTurnStepCount(input.messages) >= input.maxSteps {
|
||||
return generationDecision{kind: generationActionFinishTurn, finishReason: generationFinishReasonMaxSteps}, nil
|
||||
}
|
||||
if input.workspaceContextEligible && shouldPersistWorkspaceContext(input.chat, input.messages) {
|
||||
return generationDecision{kind: generationActionPersistWorkspaceContext}, nil
|
||||
}
|
||||
compactionRequirement := compactionRequirementNotNeeded
|
||||
if input.compactionEnabled && input.compactionNeeded {
|
||||
compactionRequirement = compactionRequirementNeeded
|
||||
@@ -382,7 +334,6 @@ func (s *taskStarter) StartGeneration(ctx context.Context, input chatWorkerTaskS
|
||||
compactionNeeded: prepared.Compaction != nil && prepared.Compaction.Required,
|
||||
compactionThresholdPercent: generationCompactionThreshold(prepared.Compaction),
|
||||
compactionContextLimit: prepared.ContextLimitFallback,
|
||||
workspaceContextEligible: prepared.WorkspaceContextEligible,
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
@@ -415,8 +366,6 @@ func (s *taskStarter) StartGeneration(ctx context.Context, input chatWorkerTaskS
|
||||
actionErr = s.executeLocalTools(ctx, machine, input, prepared, decision)
|
||||
case generationActionCompact:
|
||||
actionErr = s.generateCompaction(ctx, machine, input, prepared)
|
||||
case generationActionPersistWorkspaceContext:
|
||||
actionErr = s.persistWorkspaceContext(ctx, machine, input, prepared.Chat)
|
||||
default:
|
||||
return s.finishGenerationError(ctx, machine, input, 0, xerrors.Errorf("unknown generation action %q", decision.kind), generationAttemptNotRequired)
|
||||
}
|
||||
@@ -805,77 +754,6 @@ func compactionModel(opts chatloop.GenerateCompactionOptions) string {
|
||||
return opts.Model.Model()
|
||||
}
|
||||
|
||||
// persistWorkspaceContext is the generation action that commits durable
|
||||
// workspace context messages (e.g. AGENTS.md, workspace skills) into
|
||||
// chat history. It records a generation attempt, calls the injected
|
||||
// workspace context builder without holding the DB lock, then commits
|
||||
// the returned messages fenced to the attempt. If context cannot be
|
||||
// fetched, it commits a marker for the selected agent so the generation
|
||||
// loop can continue.
|
||||
func (s *taskStarter) persistWorkspaceContext(
|
||||
ctx context.Context,
|
||||
machine *chatstate.ChatMachine,
|
||||
input chatWorkerTaskStartInput,
|
||||
locked database.Chat,
|
||||
) error {
|
||||
messages, err := s.opts.Store.GetChatMessagesByChatID(ctx, database.GetChatMessagesByChatIDParams{
|
||||
ChatID: input.ChatID,
|
||||
AfterID: 0,
|
||||
})
|
||||
if err != nil {
|
||||
return taskRetryableError{err: xerrors.Errorf("load chat messages for workspace context: %w", err)}
|
||||
}
|
||||
attempt, _, _, closeEpisode, err := s.beginGenerationAttempt(ctx, machine, input)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("beginGenerationAttempt: %w", err)
|
||||
}
|
||||
defer closeEpisode()
|
||||
modelOpts := modelBuildOptionsFromMessages(messages)
|
||||
result, err := s.server.buildWorkspaceContext(ctx, workspaceContextBuildInput{
|
||||
Chat: locked,
|
||||
Messages: messages,
|
||||
ActiveAPIKeyID: modelOpts.ActiveAPIKeyID,
|
||||
})
|
||||
if err != nil {
|
||||
s.opts.Logger.Warn(ctx, "failed to build workspace context, committing marker",
|
||||
slog.F("chat_id", input.ChatID),
|
||||
slog.F("worker_id", input.WorkerID),
|
||||
slogError(err),
|
||||
)
|
||||
marker, err := workspaceContextMarkerMessage(locked, modelOpts.ActiveAPIKeyID)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("build workspace context marker: %w", err)
|
||||
}
|
||||
result.Messages = []chatstate.Message{marker}
|
||||
}
|
||||
return s.commitGenerationStep(ctx, machine, input, attempt, generationActionPersistWorkspaceContext, stepMessagesForCommit{
|
||||
Messages: result.Messages,
|
||||
VisibleIndexes: visibleMessageIndexes(result.Messages),
|
||||
})
|
||||
}
|
||||
|
||||
// workspaceContextMarkerMessage builds an empty context-file sentinel
|
||||
// for the chat's selected agent. Committing this marker lets the
|
||||
// generation loop proceed when the agent is unreachable.
|
||||
func workspaceContextMarkerMessage(chat database.Chat, activeAPIKeyID string) (chatstate.Message, error) {
|
||||
content, err := chatprompt.MarshalParts([]codersdk.ChatMessagePart{{
|
||||
Type: codersdk.ChatMessagePartTypeContextFile,
|
||||
ContextFileAgentID: chat.AgentID,
|
||||
}})
|
||||
if err != nil {
|
||||
return chatstate.Message{}, xerrors.Errorf("marshal workspace context marker: %w", err)
|
||||
}
|
||||
modelConfigID := chat.LastModelConfigID
|
||||
return chatstate.Message{
|
||||
Role: database.ChatMessageRoleUser,
|
||||
Content: content,
|
||||
Visibility: database.ChatMessageVisibilityBoth,
|
||||
ModelConfigID: uuid.NullUUID{UUID: modelConfigID, Valid: modelConfigID != uuid.Nil},
|
||||
ContentVersion: chatprompt.CurrentContentVersion,
|
||||
APIKeyID: sql.NullString{String: activeAPIKeyID, Valid: activeAPIKeyID != ""},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *taskStarter) beginGenerationAttempt(
|
||||
ctx context.Context,
|
||||
machine *chatstate.ChatMachine,
|
||||
|
||||
@@ -213,6 +213,7 @@ func (server *Server) prepareGeneration(
|
||||
workspaceSkills []chattool.SkillMeta
|
||||
personalSkills []skillspkg.Skill
|
||||
resolvedUserPrompt string
|
||||
planPathBlock string
|
||||
)
|
||||
|
||||
if chat.WorkspaceID.Valid {
|
||||
@@ -225,8 +226,16 @@ func (server *Server) prepareGeneration(
|
||||
// history; only metadata is mutated here.
|
||||
agent, _ := workspaceCtx.getWorkspaceAgent(ctx)
|
||||
|
||||
// API-created chats bind their agent lazily here, after
|
||||
// hydrateChatContextOnCreate ran with no agent. Pin the chat to the
|
||||
// bound agent's pushed snapshot now if it is still unpinned, so the
|
||||
// first turn reads workspace context instead of waiting for the
|
||||
// agent's next push. Idempotent and snapshot-gated; runs before the
|
||||
// pinned context is read below.
|
||||
server.ensureChatContextPinnedOnFirstTurn(ctx, workspaceCtx.currentChatSnapshot())
|
||||
|
||||
var resolveErr error
|
||||
instruction, workspaceSkills, resolveErr = server.resolveTurnWorkspaceContext(ctx, chat, agent, promptRows)
|
||||
instruction, workspaceSkills, resolveErr = server.resolveTurnWorkspaceContext(ctx, chat, agent)
|
||||
if resolveErr != nil {
|
||||
cleanup()
|
||||
return generationPrepared{}, resolveErr
|
||||
@@ -271,6 +280,17 @@ func (server *Server) prepareGeneration(
|
||||
return nil
|
||||
})
|
||||
}
|
||||
// Resolve the per-chat plan path block in the parallel phase. It dials
|
||||
// the workspace agent to read the home directory, so running it here lets
|
||||
// the cold dial overlap with the rest of turn preparation instead of
|
||||
// blocking system prompt assembly on a sequential dial. Best-effort:
|
||||
// resolvePlanPathBlock logs and returns an empty block on failure.
|
||||
if chat.WorkspaceID.Valid && !chat.ParentChatID.Valid {
|
||||
g2.Go(func() error {
|
||||
planPathBlock = resolvePlanPathBlock(ctx)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
if err := g2.Wait(); err != nil {
|
||||
cleanup()
|
||||
return generationPrepared{}, err
|
||||
@@ -322,7 +342,7 @@ func (server *Server) prepareGeneration(
|
||||
if advisorRuntime != nil {
|
||||
prompt = chatprompt.InsertSystem(prompt, chatadvisor.ParentGuidanceBlock)
|
||||
}
|
||||
prompt = renderPlanPathPrompt(prompt, resolvePlanPathBlock(ctx))
|
||||
prompt = renderPlanPathPrompt(prompt, planPathBlock)
|
||||
setAdvisorPromptSnapshot(prompt)
|
||||
|
||||
storeChatAttachment := server.newStoreChatAttachmentFunc(&workspaceCtx)
|
||||
@@ -359,7 +379,6 @@ func (server *Server) prepareGeneration(
|
||||
resolvePlanPath: resolvePlanPathForTools,
|
||||
storeFile: storeChatAttachment,
|
||||
isPlanModeTurn: isPlanModeTurn,
|
||||
primerCtx: ctx,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -563,13 +582,10 @@ func (server *Server) prepareGeneration(
|
||||
compactionOptions.StepUsage = latestPromptUsage(promptRows)
|
||||
compactionNeeded := shouldCompactPromptUsage(compactionOptions.StepUsage, modelConfig.ContextLimit, effectiveThreshold)
|
||||
|
||||
workspaceContextEligible := chat.WorkspaceID.Valid && isRootChat && !isPlanModeTurn && !isExploreSubagent
|
||||
|
||||
// workspaceCtx.currentChatSnapshot may carry a freshly persisted
|
||||
// AgentID/BuildID binding from the getWorkspaceAgent call above.
|
||||
// Return that snapshot so the chatworker decision helper sees
|
||||
// the up-to-date metadata when deciding whether to run
|
||||
// persist_workspace_context.
|
||||
// Return that snapshot so downstream consumers see the up-to-date
|
||||
// metadata.
|
||||
refreshedChat := workspaceCtx.currentChatSnapshot()
|
||||
if refreshedChat.ID == uuid.Nil {
|
||||
refreshedChat = chat
|
||||
@@ -601,9 +617,8 @@ func (server *Server) prepareGeneration(
|
||||
Required: compactionNeeded,
|
||||
Options: compactionOptions,
|
||||
},
|
||||
Cleanup: cleanup,
|
||||
Debug: debug,
|
||||
WorkspaceContextEligible: workspaceContextEligible,
|
||||
Cleanup: cleanup,
|
||||
Debug: debug,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,8 @@
|
||||
package chatd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/x/chatd/chattool"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
)
|
||||
|
||||
@@ -58,109 +52,3 @@ func formatSystemInstructions(
|
||||
_, _ = b.WriteString("</workspace-context>")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// latestContextAgentID returns the most recent workspace-agent ID seen
|
||||
// on any persisted context-file part, including the skill-only sentinel.
|
||||
// Returns uuid.Nil, false when no stamped context-file parts exist.
|
||||
func latestContextAgentID(messages []database.ChatMessage) (uuid.UUID, bool) {
|
||||
var lastID uuid.UUID
|
||||
found := false
|
||||
for _, msg := range messages {
|
||||
if !msg.Content.Valid ||
|
||||
!bytes.Contains(msg.Content.RawMessage, []byte(`"context-file"`)) {
|
||||
continue
|
||||
}
|
||||
var parts []codersdk.ChatMessagePart
|
||||
if err := json.Unmarshal(msg.Content.RawMessage, &parts); err != nil {
|
||||
continue
|
||||
}
|
||||
for _, part := range parts {
|
||||
if part.Type != codersdk.ChatMessagePartTypeContextFile ||
|
||||
!part.ContextFileAgentID.Valid {
|
||||
continue
|
||||
}
|
||||
lastID = part.ContextFileAgentID.UUID
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
return lastID, found
|
||||
}
|
||||
|
||||
// instructionFromContextFiles reconstructs the formatted instruction
|
||||
// string from persisted context-file parts. This is used on non-first
|
||||
// turns so the instruction can be re-injected after compaction
|
||||
// without re-dialing the workspace agent.
|
||||
func instructionFromContextFiles(
|
||||
messages []database.ChatMessage,
|
||||
) string {
|
||||
filterAgentID, filterByAgent := latestContextAgentID(messages)
|
||||
var contextParts []codersdk.ChatMessagePart
|
||||
var os, dir string
|
||||
for _, msg := range messages {
|
||||
if !msg.Content.Valid ||
|
||||
!bytes.Contains(msg.Content.RawMessage, []byte(`"context-file"`)) {
|
||||
continue
|
||||
}
|
||||
var parts []codersdk.ChatMessagePart
|
||||
if err := json.Unmarshal(msg.Content.RawMessage, &parts); err != nil {
|
||||
continue
|
||||
}
|
||||
for _, part := range parts {
|
||||
if part.Type != codersdk.ChatMessagePartTypeContextFile {
|
||||
continue
|
||||
}
|
||||
if filterByAgent && part.ContextFileAgentID.Valid &&
|
||||
part.ContextFileAgentID.UUID != filterAgentID {
|
||||
continue
|
||||
}
|
||||
if part.ContextFileOS != "" {
|
||||
os = part.ContextFileOS
|
||||
}
|
||||
if part.ContextFileDirectory != "" {
|
||||
dir = part.ContextFileDirectory
|
||||
}
|
||||
if part.ContextFileContent != "" {
|
||||
contextParts = append(contextParts, part)
|
||||
}
|
||||
}
|
||||
}
|
||||
return formatSystemInstructions(os, dir, contextParts)
|
||||
}
|
||||
|
||||
// skillsFromParts reconstructs skill metadata from persisted
|
||||
// skill parts. This is analogous to instructionFromContextFiles
|
||||
// so the skill index can be re-injected after compaction without
|
||||
// re-dialing the workspace agent.
|
||||
func skillsFromParts(
|
||||
messages []database.ChatMessage,
|
||||
) []chattool.SkillMeta {
|
||||
filterAgentID, filterByAgent := latestContextAgentID(messages)
|
||||
var skills []chattool.SkillMeta
|
||||
for _, msg := range messages {
|
||||
if !msg.Content.Valid ||
|
||||
!bytes.Contains(msg.Content.RawMessage, []byte(`"skill"`)) {
|
||||
continue
|
||||
}
|
||||
var parts []codersdk.ChatMessagePart
|
||||
if err := json.Unmarshal(msg.Content.RawMessage, &parts); err != nil {
|
||||
continue
|
||||
}
|
||||
for _, part := range parts {
|
||||
if part.Type != codersdk.ChatMessagePartTypeSkill {
|
||||
continue
|
||||
}
|
||||
if filterByAgent && part.ContextFileAgentID.Valid &&
|
||||
part.ContextFileAgentID.UUID != filterAgentID {
|
||||
continue
|
||||
}
|
||||
skills = append(skills, chattool.SkillMeta{
|
||||
Name: part.SkillName,
|
||||
Description: part.SkillDescription,
|
||||
Dir: part.SkillDir,
|
||||
MetaFile: part.ContextFileSkillMetaFile,
|
||||
})
|
||||
}
|
||||
}
|
||||
return skills
|
||||
}
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
package chatd
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"charm.land/fantasy"
|
||||
"github.com/sqlc-dev/pqtype"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/x/chatd/chatprompt"
|
||||
"github.com/coder/coder/v2/coderd/x/chatd/chattool"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
@@ -290,55 +287,3 @@ func TestFormatSystemInstructions(t *testing.T) {
|
||||
require.Contains(t, got, "Source: /real/AGENTS.md")
|
||||
})
|
||||
}
|
||||
|
||||
func TestInstructionFromContextFiles(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
makeMsg := func(parts []codersdk.ChatMessagePart) database.ChatMessage {
|
||||
raw, _ := json.Marshal(parts)
|
||||
return database.ChatMessage{
|
||||
Content: pqtype.NullRawMessage{RawMessage: raw, Valid: true},
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("EmptyMessages", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := instructionFromContextFiles(nil)
|
||||
require.Empty(t, got)
|
||||
})
|
||||
|
||||
t.Run("NoContextFileParts", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
msgs := []database.ChatMessage{
|
||||
makeMsg([]codersdk.ChatMessagePart{
|
||||
{
|
||||
Type: codersdk.ChatMessagePartTypeSkill,
|
||||
SkillName: "test",
|
||||
SkillDescription: "test skill",
|
||||
},
|
||||
}),
|
||||
}
|
||||
got := instructionFromContextFiles(msgs)
|
||||
require.Empty(t, got)
|
||||
})
|
||||
|
||||
t.Run("ReconstructsFromContextFileParts", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
msgs := []database.ChatMessage{
|
||||
makeMsg([]codersdk.ChatMessagePart{
|
||||
{
|
||||
Type: codersdk.ChatMessagePartTypeContextFile,
|
||||
ContextFileOS: "linux",
|
||||
ContextFileDirectory: "/home/coder/project",
|
||||
ContextFileContent: "project rules",
|
||||
ContextFilePath: "/home/coder/project/AGENTS.md",
|
||||
},
|
||||
}),
|
||||
}
|
||||
got := instructionFromContextFiles(msgs)
|
||||
require.Contains(t, got, "Operating System: linux")
|
||||
require.Contains(t, got, "Working Directory: /home/coder/project")
|
||||
require.Contains(t, got, "Source: /home/coder/project/AGENTS.md")
|
||||
require.Contains(t, got, "project rules")
|
||||
})
|
||||
}
|
||||
|
||||
+13
-73
@@ -1029,27 +1029,10 @@ func (p *Server) createChildSubagentChatWithOptions(
|
||||
}
|
||||
initialMessages = append(initialMessages, systemMessage(workspaceAwarenessContent, modelConfigID))
|
||||
|
||||
copiedContextParts, err := copyParentContextMessages(ctx, p.logger, p.db, parent)
|
||||
if err != nil {
|
||||
return database.Chat{}, xerrors.Errorf("copy parent context messages: %w", err)
|
||||
}
|
||||
var lastInjectedContext pqtype.NullRawMessage
|
||||
if len(copiedContextParts) > 0 {
|
||||
filteredContent, err := chatprompt.MarshalParts(copiedContextParts)
|
||||
if err != nil {
|
||||
return database.Chat{}, xerrors.Errorf("marshal copied context parts: %w", err)
|
||||
}
|
||||
initialMessages = append(initialMessages, userMessageWithAPIKeyID(
|
||||
filteredContent,
|
||||
modelConfigID,
|
||||
parent.OwnerID,
|
||||
childAPIKeyID,
|
||||
))
|
||||
lastInjectedContext, err = BuildLastInjectedContext(FilterContextPartsToLatestAgent(copiedContextParts))
|
||||
if err != nil {
|
||||
return database.Chat{}, xerrors.Errorf("build inherited injected context: %w", err)
|
||||
}
|
||||
}
|
||||
// The child shares the parent's workspace and agent, so it inherits
|
||||
// workspace context the same way a top-level chat does: pinned from the
|
||||
// agent's latest snapshot (see hydrateChatContextOnCreate below). The
|
||||
// parent's context is not copied into child history.
|
||||
initialMessages = append(initialMessages, userMessageWithAPIKeyID(userContent, modelConfigID, parent.OwnerID, childAPIKeyID))
|
||||
|
||||
publisher := p.pubsub
|
||||
@@ -1073,10 +1056,9 @@ func (p *Server) createChildSubagentChatWithOptions(
|
||||
RawMessage: labelsJSON,
|
||||
Valid: true,
|
||||
},
|
||||
DynamicTools: pqtype.NullRawMessage{},
|
||||
ClientType: parent.ClientType,
|
||||
InitialMessages: initialMessages,
|
||||
LastInjectedContext: lastInjectedContext,
|
||||
DynamicTools: pqtype.NullRawMessage{},
|
||||
ClientType: parent.ClientType,
|
||||
InitialMessages: initialMessages,
|
||||
})
|
||||
if err != nil {
|
||||
return database.Chat{}, xerrors.Errorf("create child chat: %w", err)
|
||||
@@ -1084,58 +1066,16 @@ func (p *Server) createChildSubagentChatWithOptions(
|
||||
|
||||
child := result.Chat
|
||||
|
||||
// Pin the child to its agent's latest context snapshot, mirroring the
|
||||
// top-level create path. The child shares the parent's workspace agent,
|
||||
// so this reproduces the parent's workspace context without copying it
|
||||
// through chat history.
|
||||
p.hydrateChatContextOnCreate(ctx, child)
|
||||
|
||||
p.publishChatPubsubEvent(child, codersdk.ChatWatchEventKindCreated, nil)
|
||||
return child, nil
|
||||
}
|
||||
|
||||
// copyParentContextMessages reads persisted context-file and skill
|
||||
// messages from the parent chat. This ensures sub-agents inherit the
|
||||
// same instruction and skill context as their parent without
|
||||
// independently re-fetching from the agent.
|
||||
func copyParentContextMessages(
|
||||
ctx context.Context,
|
||||
logger slog.Logger,
|
||||
store database.Store,
|
||||
parent database.Chat,
|
||||
) ([]codersdk.ChatMessagePart, error) {
|
||||
parentMessages, err := store.GetChatMessagesByChatID(ctx, database.GetChatMessagesByChatIDParams{
|
||||
ChatID: parent.ID,
|
||||
AfterID: 0,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("get parent messages: %w", err)
|
||||
}
|
||||
|
||||
var copiedParts []codersdk.ChatMessagePart
|
||||
for _, msg := range parentMessages {
|
||||
if !msg.Content.Valid {
|
||||
continue
|
||||
}
|
||||
var parts []codersdk.ChatMessagePart
|
||||
if err := json.Unmarshal(msg.Content.RawMessage, &parts); err != nil {
|
||||
logger.Warn(ctx, "failed to unmarshal parent context message",
|
||||
slog.F("parent_chat_id", parent.ID),
|
||||
slog.F("message_id", msg.ID),
|
||||
slog.Error(err),
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
messageContextParts := FilterContextParts(parts, true)
|
||||
if len(messageContextParts) == 0 {
|
||||
continue
|
||||
}
|
||||
copiedParts = append(copiedParts, messageContextParts...)
|
||||
}
|
||||
if len(copiedParts) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
copiedParts = FilterContextPartsToLatestAgent(copiedParts)
|
||||
|
||||
return copiedParts, nil
|
||||
}
|
||||
|
||||
func (p *Server) sendSubagentMessage(
|
||||
ctx context.Context,
|
||||
parentChatID uuid.UUID,
|
||||
|
||||
@@ -2,484 +2,97 @@ package chatd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"charm.land/fantasy"
|
||||
"github.com/google/uuid"
|
||||
"github.com/sqlc-dev/pqtype"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"cdr.dev/slog/v3"
|
||||
"github.com/coder/coder/v2/coderd/aibridge"
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/dbgen"
|
||||
"github.com/coder/coder/v2/coderd/database/dbtestutil"
|
||||
"github.com/coder/coder/v2/coderd/x/chatd/chatprompt"
|
||||
"github.com/coder/coder/v2/coderd/database/dbtime"
|
||||
"github.com/coder/coder/v2/coderd/x/chatd/chatprovider"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
)
|
||||
|
||||
func TestCollectContextPartsFromMessagesSkipsSentinelContextFiles(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
content, err := json.Marshal([]codersdk.ChatMessagePart{
|
||||
{
|
||||
Type: codersdk.ChatMessagePartTypeContextFile,
|
||||
ContextFilePath: "/home/coder/project/.agents/skills/my-skill/SKILL.md",
|
||||
},
|
||||
{
|
||||
Type: codersdk.ChatMessagePartTypeSkill,
|
||||
SkillName: "my-skill",
|
||||
SkillDescription: "A test skill",
|
||||
},
|
||||
{
|
||||
Type: codersdk.ChatMessagePartTypeContextFile,
|
||||
ContextFilePath: "/home/coder/project/AGENTS.md",
|
||||
ContextFileContent: "# Project instructions",
|
||||
},
|
||||
codersdk.ChatMessageText("ignored"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
parts, err := CollectContextPartsFromMessages(context.Background(), slog.Make(), []database.ChatMessage{ //nolint:exhaustruct // Only content fields matter for this unit test.
|
||||
{
|
||||
ID: 1,
|
||||
Content: pqtype.NullRawMessage{
|
||||
RawMessage: content,
|
||||
Valid: true,
|
||||
},
|
||||
},
|
||||
}, false)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, parts, 2)
|
||||
require.Equal(t, codersdk.ChatMessagePartTypeSkill, parts[0].Type)
|
||||
require.Equal(t, "my-skill", parts[0].SkillName)
|
||||
require.Equal(t, codersdk.ChatMessagePartTypeContextFile, parts[1].Type)
|
||||
require.Equal(t, "/home/coder/project/AGENTS.md", parts[1].ContextFilePath)
|
||||
require.Equal(t, "# Project instructions", parts[1].ContextFileContent)
|
||||
}
|
||||
|
||||
func TestCollectContextPartsFromMessagesKeepsEmptyContextFilesWhenRequested(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
content, err := json.Marshal([]codersdk.ChatMessagePart{
|
||||
{
|
||||
Type: codersdk.ChatMessagePartTypeContextFile,
|
||||
ContextFilePath: AgentChatContextSentinelPath,
|
||||
ContextFileAgentID: uuid.NullUUID{
|
||||
UUID: uuid.New(),
|
||||
Valid: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
Type: codersdk.ChatMessagePartTypeSkill,
|
||||
SkillName: "my-skill",
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
parts, err := CollectContextPartsFromMessages(context.Background(), slog.Make(), []database.ChatMessage{ //nolint:exhaustruct // Only content fields matter for this unit test.
|
||||
{
|
||||
ID: 1,
|
||||
Content: pqtype.NullRawMessage{
|
||||
RawMessage: content,
|
||||
Valid: true,
|
||||
},
|
||||
},
|
||||
}, true)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, parts, 2)
|
||||
require.Equal(t, AgentChatContextSentinelPath, parts[0].ContextFilePath)
|
||||
require.Equal(t, "my-skill", parts[1].SkillName)
|
||||
}
|
||||
|
||||
func TestFilterContextPartsToLatestAgent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
oldAgentID := uuid.New()
|
||||
newAgentID := uuid.New()
|
||||
parts := []codersdk.ChatMessagePart{
|
||||
{
|
||||
Type: codersdk.ChatMessagePartTypeContextFile,
|
||||
ContextFilePath: "/legacy/AGENTS.md",
|
||||
ContextFileContent: "legacy instructions",
|
||||
},
|
||||
{
|
||||
Type: codersdk.ChatMessagePartTypeSkill,
|
||||
SkillName: "repo-helper-legacy",
|
||||
},
|
||||
{
|
||||
Type: codersdk.ChatMessagePartTypeContextFile,
|
||||
ContextFilePath: "/old/AGENTS.md",
|
||||
ContextFileAgentID: uuid.NullUUID{UUID: oldAgentID, Valid: true},
|
||||
},
|
||||
{
|
||||
Type: codersdk.ChatMessagePartTypeSkill,
|
||||
SkillName: "repo-helper-old",
|
||||
ContextFileAgentID: uuid.NullUUID{UUID: oldAgentID, Valid: true},
|
||||
},
|
||||
{
|
||||
Type: codersdk.ChatMessagePartTypeContextFile,
|
||||
ContextFilePath: AgentChatContextSentinelPath,
|
||||
ContextFileAgentID: uuid.NullUUID{
|
||||
UUID: newAgentID,
|
||||
Valid: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
Type: codersdk.ChatMessagePartTypeSkill,
|
||||
SkillName: "repo-helper-new",
|
||||
ContextFileAgentID: uuid.NullUUID{UUID: newAgentID, Valid: true},
|
||||
},
|
||||
}
|
||||
|
||||
got := FilterContextPartsToLatestAgent(parts)
|
||||
require.Len(t, got, 4)
|
||||
require.Equal(t, "/legacy/AGENTS.md", got[0].ContextFilePath)
|
||||
require.Equal(t, "repo-helper-legacy", got[1].SkillName)
|
||||
require.Equal(t, AgentChatContextSentinelPath, got[2].ContextFilePath)
|
||||
require.Equal(t, "repo-helper-new", got[3].SkillName)
|
||||
}
|
||||
|
||||
func createParentChatWithInheritedContext(
|
||||
// createWorkspaceBoundParentChat creates a parent chat bound to a workspace
|
||||
// agent that has already pushed a context snapshot, so CreateChat pins the chat
|
||||
// to that snapshot at create time. It returns the pinned parent and the source
|
||||
// of its single pinned resource, letting a spawned child be asserted to inherit
|
||||
// the same pin.
|
||||
func createWorkspaceBoundParentChat(
|
||||
ctx context.Context,
|
||||
t *testing.T,
|
||||
db database.Store,
|
||||
server *Server,
|
||||
) database.Chat {
|
||||
) (database.Chat, string) {
|
||||
t.Helper()
|
||||
|
||||
user, org, model := seedInternalChatDeps(t, db)
|
||||
|
||||
tv := dbgen.TemplateVersion(t, db, database.TemplateVersion{
|
||||
OrganizationID: org.ID,
|
||||
CreatedBy: user.ID,
|
||||
})
|
||||
tmpl := dbgen.Template(t, db, database.Template{
|
||||
OrganizationID: org.ID,
|
||||
ActiveVersionID: tv.ID,
|
||||
CreatedBy: user.ID,
|
||||
})
|
||||
ws := dbgen.Workspace(t, db, database.WorkspaceTable{
|
||||
OwnerID: user.ID,
|
||||
OrganizationID: org.ID,
|
||||
TemplateID: tmpl.ID,
|
||||
})
|
||||
pj := dbgen.ProvisionerJob(t, db, nil, database.ProvisionerJob{
|
||||
OrganizationID: org.ID,
|
||||
CompletedAt: sql.NullTime{Valid: true, Time: dbtime.Now()},
|
||||
})
|
||||
build := dbgen.WorkspaceBuild(t, db, database.WorkspaceBuild{
|
||||
WorkspaceID: ws.ID,
|
||||
TemplateVersionID: tv.ID,
|
||||
JobID: pj.ID,
|
||||
Transition: database.WorkspaceTransitionStart,
|
||||
})
|
||||
res := dbgen.WorkspaceResource(t, db, database.WorkspaceResource{
|
||||
Transition: database.WorkspaceTransitionStart,
|
||||
JobID: pj.ID,
|
||||
})
|
||||
agent := dbgen.WorkspaceAgent(t, db, database.WorkspaceAgent{ResourceID: res.ID})
|
||||
|
||||
const source = "/home/coder/project/AGENTS.md"
|
||||
seedAgentContext(ctx, t, db, agent.ID, source, []byte("instruction:parent"),
|
||||
database.WorkspaceAgentContextBodyKindInstructionFile,
|
||||
json.RawMessage(`{"instruction_file":{"content":"parent"}}`))
|
||||
|
||||
parent, err := server.CreateChat(ctx, CreateOptions{
|
||||
OrganizationID: org.ID,
|
||||
OwnerID: user.ID,
|
||||
APIKeyID: testAPIKeyID(t, db, user.ID),
|
||||
Title: "parent-with-context",
|
||||
ModelConfigID: model.ID,
|
||||
WorkspaceID: uuid.NullUUID{UUID: ws.ID, Valid: true},
|
||||
BuildID: uuid.NullUUID{UUID: build.ID, Valid: true},
|
||||
AgentID: uuid.NullUUID{UUID: agent.ID, Valid: true},
|
||||
InitialUserContent: []codersdk.ChatMessagePart{codersdk.ChatMessageText("hello")},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
inheritedParts := []codersdk.ChatMessagePart{
|
||||
{
|
||||
Type: codersdk.ChatMessagePartTypeContextFile,
|
||||
ContextFilePath: "/home/coder/project/AGENTS.md",
|
||||
ContextFileContent: "# Project instructions",
|
||||
ContextFileOS: "linux",
|
||||
ContextFileDirectory: "/home/coder/project",
|
||||
},
|
||||
{
|
||||
Type: codersdk.ChatMessagePartTypeSkill,
|
||||
SkillName: "my-skill",
|
||||
SkillDescription: "A test skill",
|
||||
SkillDir: "/home/coder/project/.agents/skills/my-skill",
|
||||
ContextFileSkillMetaFile: "SKILL.md",
|
||||
},
|
||||
{
|
||||
Type: codersdk.ChatMessagePartTypeContextFile,
|
||||
ContextFilePath: "/home/coder/project/.agents/skills/my-skill/SKILL.md",
|
||||
},
|
||||
}
|
||||
content, err := json.Marshal(inheritedParts)
|
||||
require.NoError(t, err)
|
||||
|
||||
_ = dbgen.ChatMessage(t, db, database.ChatMessage{
|
||||
ChatID: parent.ID,
|
||||
CreatedBy: uuid.NullUUID{UUID: user.ID, Valid: true},
|
||||
ModelConfigID: uuid.NullUUID{UUID: model.ID, Valid: true},
|
||||
Role: database.ChatMessageRoleUser,
|
||||
Content: pqtype.NullRawMessage{RawMessage: content, Valid: true},
|
||||
ContentVersion: chatprompt.CurrentContentVersion,
|
||||
})
|
||||
|
||||
parentChat, err := db.GetChatByID(ctx, parent.ID)
|
||||
require.NoError(t, err)
|
||||
return parentChat
|
||||
return parentChat, source
|
||||
}
|
||||
|
||||
func assertChildInheritedContext(
|
||||
ctx context.Context,
|
||||
t *testing.T,
|
||||
db database.Store,
|
||||
childID uuid.UUID,
|
||||
prompt string,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
childChat, err := db.GetChatByID(ctx, childID)
|
||||
require.NoError(t, err)
|
||||
require.True(t, childChat.LastInjectedContext.Valid)
|
||||
|
||||
var cached []codersdk.ChatMessagePart
|
||||
require.NoError(t, json.Unmarshal(childChat.LastInjectedContext.RawMessage, &cached))
|
||||
require.Len(t, cached, 2)
|
||||
|
||||
var sawContextFile bool
|
||||
var sawSkill bool
|
||||
for _, part := range cached {
|
||||
switch part.Type {
|
||||
case codersdk.ChatMessagePartTypeContextFile:
|
||||
sawContextFile = true
|
||||
require.Equal(t, "/home/coder/project/AGENTS.md", part.ContextFilePath)
|
||||
require.Empty(t, part.ContextFileContent)
|
||||
require.Empty(t, part.ContextFileOS)
|
||||
require.Empty(t, part.ContextFileDirectory)
|
||||
case codersdk.ChatMessagePartTypeSkill:
|
||||
sawSkill = true
|
||||
require.Equal(t, "my-skill", part.SkillName)
|
||||
require.Equal(t, "A test skill", part.SkillDescription)
|
||||
require.Empty(t, part.SkillDir)
|
||||
require.Empty(t, part.ContextFileSkillMetaFile)
|
||||
default:
|
||||
t.Fatalf("unexpected cached part type %q", part.Type)
|
||||
}
|
||||
}
|
||||
require.True(t, sawContextFile)
|
||||
require.True(t, sawSkill)
|
||||
|
||||
childMessages, err := db.GetChatMessagesByChatID(ctx, database.GetChatMessagesByChatIDParams{
|
||||
ChatID: childID,
|
||||
AfterID: 0,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
var (
|
||||
contextMessageIndexes []int
|
||||
userPromptIndex = -1
|
||||
sawDBAgentsContextFile bool
|
||||
sawDBSkillCompanionContext bool
|
||||
sawDBSkill bool
|
||||
)
|
||||
for i, msg := range childMessages {
|
||||
if !msg.Content.Valid {
|
||||
continue
|
||||
}
|
||||
|
||||
var parts []codersdk.ChatMessagePart
|
||||
require.NoError(t, json.Unmarshal(msg.Content.RawMessage, &parts))
|
||||
|
||||
if len(parts) == 1 && parts[0].Type == codersdk.ChatMessagePartTypeText && parts[0].Text == prompt {
|
||||
require.Equal(t, database.ChatMessageRoleUser, msg.Role)
|
||||
userPromptIndex = i
|
||||
continue
|
||||
}
|
||||
|
||||
hasInheritedContext := false
|
||||
for _, part := range parts {
|
||||
switch part.Type {
|
||||
case codersdk.ChatMessagePartTypeContextFile:
|
||||
hasInheritedContext = true
|
||||
switch part.ContextFilePath {
|
||||
case "/home/coder/project/AGENTS.md":
|
||||
sawDBAgentsContextFile = true
|
||||
require.Equal(t, "# Project instructions", part.ContextFileContent)
|
||||
require.Equal(t, "linux", part.ContextFileOS)
|
||||
require.Equal(t, "/home/coder/project", part.ContextFileDirectory)
|
||||
case "/home/coder/project/.agents/skills/my-skill/SKILL.md":
|
||||
sawDBSkillCompanionContext = true
|
||||
require.Empty(t, part.ContextFileContent)
|
||||
require.Empty(t, part.ContextFileOS)
|
||||
require.Empty(t, part.ContextFileDirectory)
|
||||
default:
|
||||
t.Fatalf("unexpected child inherited context file path %q", part.ContextFilePath)
|
||||
}
|
||||
case codersdk.ChatMessagePartTypeSkill:
|
||||
hasInheritedContext = true
|
||||
sawDBSkill = true
|
||||
require.Equal(t, "my-skill", part.SkillName)
|
||||
require.Equal(t, "A test skill", part.SkillDescription)
|
||||
require.Equal(t, "/home/coder/project/.agents/skills/my-skill", part.SkillDir)
|
||||
require.Equal(t, "SKILL.md", part.ContextFileSkillMetaFile)
|
||||
default:
|
||||
t.Fatalf("unexpected child inherited part type %q", part.Type)
|
||||
}
|
||||
}
|
||||
if hasInheritedContext {
|
||||
require.Equal(t, database.ChatMessageRoleUser, msg.Role)
|
||||
contextMessageIndexes = append(contextMessageIndexes, i)
|
||||
}
|
||||
}
|
||||
|
||||
require.NotEmpty(t, contextMessageIndexes)
|
||||
require.NotEqual(t, -1, userPromptIndex)
|
||||
for _, idx := range contextMessageIndexes {
|
||||
require.Less(t, idx, userPromptIndex)
|
||||
}
|
||||
require.True(t, sawDBAgentsContextFile)
|
||||
require.True(t, sawDBSkillCompanionContext)
|
||||
require.True(t, sawDBSkill)
|
||||
}
|
||||
|
||||
func createParentChatWithRotatedInheritedContext(
|
||||
ctx context.Context,
|
||||
t *testing.T,
|
||||
db database.Store,
|
||||
server *Server,
|
||||
) database.Chat {
|
||||
t.Helper()
|
||||
|
||||
user, org, model := seedInternalChatDeps(t, db)
|
||||
|
||||
parent, err := server.CreateChat(ctx, CreateOptions{
|
||||
OrganizationID: org.ID,
|
||||
OwnerID: user.ID,
|
||||
APIKeyID: testAPIKeyID(t, db, user.ID),
|
||||
Title: "parent-with-rotated-context",
|
||||
ModelConfigID: model.ID,
|
||||
InitialUserContent: []codersdk.ChatMessagePart{codersdk.ChatMessageText("hello")},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
oldAgentID := uuid.New()
|
||||
newAgentID := uuid.New()
|
||||
oldContent, err := json.Marshal([]codersdk.ChatMessagePart{
|
||||
{
|
||||
Type: codersdk.ChatMessagePartTypeContextFile,
|
||||
ContextFilePath: "/home/coder/project-old/AGENTS.md",
|
||||
ContextFileContent: "# Old instructions",
|
||||
ContextFileOS: "darwin",
|
||||
ContextFileDirectory: "/home/coder/project-old",
|
||||
ContextFileAgentID: uuid.NullUUID{UUID: oldAgentID, Valid: true},
|
||||
},
|
||||
{
|
||||
Type: codersdk.ChatMessagePartTypeSkill,
|
||||
SkillName: "old-skill",
|
||||
SkillDescription: "Old skill",
|
||||
SkillDir: "/home/coder/project-old/.agents/skills/old-skill",
|
||||
ContextFileAgentID: uuid.NullUUID{UUID: oldAgentID, Valid: true},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
newContent, err := json.Marshal([]codersdk.ChatMessagePart{
|
||||
{
|
||||
Type: codersdk.ChatMessagePartTypeContextFile,
|
||||
ContextFilePath: "/home/coder/project-new/AGENTS.md",
|
||||
ContextFileContent: "# New instructions",
|
||||
ContextFileOS: "linux",
|
||||
ContextFileDirectory: "/home/coder/project-new",
|
||||
ContextFileAgentID: uuid.NullUUID{UUID: newAgentID, Valid: true},
|
||||
},
|
||||
{
|
||||
Type: codersdk.ChatMessagePartTypeSkill,
|
||||
SkillName: "new-skill",
|
||||
SkillDescription: "New skill",
|
||||
SkillDir: "/home/coder/project-new/.agents/skills/new-skill",
|
||||
ContextFileAgentID: uuid.NullUUID{UUID: newAgentID, Valid: true},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_ = dbgen.ChatMessage(t, db, database.ChatMessage{
|
||||
ChatID: parent.ID,
|
||||
CreatedBy: uuid.NullUUID{UUID: user.ID, Valid: true},
|
||||
ModelConfigID: uuid.NullUUID{UUID: model.ID, Valid: true},
|
||||
Role: database.ChatMessageRoleUser,
|
||||
Content: pqtype.NullRawMessage{RawMessage: oldContent, Valid: true},
|
||||
ContentVersion: chatprompt.CurrentContentVersion,
|
||||
})
|
||||
_ = dbgen.ChatMessage(t, db, database.ChatMessage{
|
||||
ChatID: parent.ID,
|
||||
CreatedBy: uuid.NullUUID{UUID: user.ID, Valid: true},
|
||||
ModelConfigID: uuid.NullUUID{UUID: model.ID, Valid: true},
|
||||
Role: database.ChatMessageRoleUser,
|
||||
Content: pqtype.NullRawMessage{RawMessage: newContent, Valid: true},
|
||||
ContentVersion: chatprompt.CurrentContentVersion,
|
||||
})
|
||||
|
||||
parentChat, err := db.GetChatByID(ctx, parent.ID)
|
||||
require.NoError(t, err)
|
||||
return parentChat
|
||||
}
|
||||
|
||||
func TestCreateChildSubagentChatCopiesOnlyLatestAgentContext(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db, ps := dbtestutil.NewDB(t)
|
||||
server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{})
|
||||
|
||||
ctx := chatdTestContext(t)
|
||||
parentChat := createParentChatWithRotatedInheritedContext(ctx, t, db, server)
|
||||
|
||||
ctx = aibridge.WithDelegatedAPIKeyID(ctx, testAPIKeyID(t, server.db, parentChat.OwnerID))
|
||||
child, err := server.createChildSubagentChat(ctx, parentChat, "inspect bindings", "")
|
||||
require.NoError(t, err)
|
||||
|
||||
childChat, err := db.GetChatByID(ctx, child.ID)
|
||||
require.NoError(t, err)
|
||||
require.True(t, childChat.LastInjectedContext.Valid)
|
||||
|
||||
var cached []codersdk.ChatMessagePart
|
||||
require.NoError(t, json.Unmarshal(childChat.LastInjectedContext.RawMessage, &cached))
|
||||
require.Len(t, cached, 2)
|
||||
require.Equal(t, "/home/coder/project-new/AGENTS.md", cached[0].ContextFilePath)
|
||||
require.Equal(t, "new-skill", cached[1].SkillName)
|
||||
|
||||
childMessages, err := db.GetChatMessagesByChatID(ctx, database.GetChatMessagesByChatIDParams{
|
||||
ChatID: child.ID,
|
||||
AfterID: 0,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
var inherited [][]codersdk.ChatMessagePart
|
||||
for _, msg := range childMessages {
|
||||
if !msg.Content.Valid {
|
||||
continue
|
||||
}
|
||||
var parts []codersdk.ChatMessagePart
|
||||
require.NoError(t, json.Unmarshal(msg.Content.RawMessage, &parts))
|
||||
if len(parts) == 0 || parts[0].Type == codersdk.ChatMessagePartTypeText {
|
||||
continue
|
||||
}
|
||||
inherited = append(inherited, parts)
|
||||
}
|
||||
require.Len(t, inherited, 1)
|
||||
require.Len(t, inherited[0], 2)
|
||||
require.Equal(t, "/home/coder/project-new/AGENTS.md", inherited[0][0].ContextFilePath)
|
||||
require.Equal(t, "# New instructions", inherited[0][0].ContextFileContent)
|
||||
require.Equal(t, "new-skill", inherited[0][1].SkillName)
|
||||
}
|
||||
|
||||
func TestCreateChildSubagentChatUpdatesInheritedLastInjectedContext(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db, ps := dbtestutil.NewDB(t)
|
||||
server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{})
|
||||
|
||||
ctx := chatdTestContext(t)
|
||||
parentChat := createParentChatWithInheritedContext(ctx, t, db, server)
|
||||
|
||||
// Set a delegated API key so that copied user-role context messages
|
||||
// are stamped with api_key_id, preserving AI Gateway routing.
|
||||
apiKey, _ := dbgen.APIKey(t, db, database.APIKey{UserID: parentChat.OwnerID})
|
||||
ctx = aibridge.WithDelegatedAPIKeyID(ctx, apiKey.ID)
|
||||
|
||||
child, err := server.createChildSubagentChat(ctx, parentChat, "inspect bindings", "")
|
||||
require.NoError(t, err)
|
||||
|
||||
assertChildInheritedContext(ctx, t, db, child.ID, "inspect bindings")
|
||||
|
||||
// Verify that all user-role messages in the child chat carry
|
||||
// api_key_id so activeTurnAPIKeyIDFromMessages resolves correctly.
|
||||
childMessages, err := db.GetChatMessagesByChatID(ctx, database.GetChatMessagesByChatIDParams{
|
||||
ChatID: child.ID,
|
||||
AfterID: 0,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
var userMsgCount int
|
||||
for _, msg := range childMessages {
|
||||
if msg.Role != database.ChatMessageRoleUser {
|
||||
continue
|
||||
}
|
||||
userMsgCount++
|
||||
require.True(t, msg.APIKeyID.Valid, "child user message (id=%d) should have api_key_id set", msg.ID)
|
||||
require.Equal(t, apiKey.ID, msg.APIKeyID.String, "child user message (id=%d) api_key_id mismatch", msg.ID)
|
||||
}
|
||||
require.Greater(t, userMsgCount, 0, "expected at least one user-role message in child chat")
|
||||
}
|
||||
|
||||
func TestSpawnComputerUseAgentInheritsContext(t *testing.T) {
|
||||
// TestSpawnComputerUseAgentInheritsPinnedContext verifies that a spawned
|
||||
// subagent inherits its parent's pinned workspace context. The child shares the
|
||||
// parent's workspace agent, so create-time hydration pins it to the same
|
||||
// snapshot instead of copying any context through chat history. It also asserts
|
||||
// the child is created in computer_use mode.
|
||||
func TestSpawnComputerUseAgentInheritsPinnedContext(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db, ps := dbtestutil.NewDB(t)
|
||||
@@ -487,13 +100,19 @@ func TestSpawnComputerUseAgentInheritsContext(t *testing.T) {
|
||||
server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{})
|
||||
|
||||
ctx := chatdTestContext(t)
|
||||
parentChat := createParentChatWithInheritedContext(ctx, t, db, server)
|
||||
parentChat, wantSource := createWorkspaceBoundParentChat(ctx, t, db, server)
|
||||
|
||||
parentRes, err := db.ListChatContextResourcesByChatID(ctx, parentChat.ID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, parentRes, 1, "parent is pinned to its agent's snapshot at create")
|
||||
require.Equal(t, wantSource, parentRes[0].Source)
|
||||
require.NotNil(t, parentChat.ContextAggregateHash)
|
||||
|
||||
insertEnabledAnthropicProvider(t, db, parentChat.OwnerID)
|
||||
// The direct DB insert above bypasses the pubsub event that
|
||||
// production uses to invalidate the provider cache. Explicitly
|
||||
// invalidate here so the background processing goroutine does
|
||||
// not serve a stale provider list (OpenAI only) that was cached
|
||||
// before the Anthropic provider was inserted.
|
||||
// The direct DB insert above bypasses the pubsub event that production
|
||||
// uses to invalidate the provider cache. Explicitly invalidate here so the
|
||||
// background processing goroutine does not serve a stale provider list
|
||||
// (OpenAI only) that was cached before the Anthropic provider was inserted.
|
||||
server.configCache.InvalidateProviders()
|
||||
|
||||
ctx = aibridge.WithDelegatedAPIKeyID(ctx, testAPIKeyID(t, db, parentChat.OwnerID))
|
||||
@@ -522,5 +141,15 @@ func TestSpawnComputerUseAgentInheritsContext(t *testing.T) {
|
||||
require.True(t, childChat.Mode.Valid)
|
||||
require.Equal(t, database.ChatModeComputerUse, childChat.Mode.ChatMode)
|
||||
|
||||
assertChildInheritedContext(ctx, t, db, childID, "inspect bindings")
|
||||
// The child shares the parent's workspace agent, so create-time hydration
|
||||
// pins it to the same snapshot: it inherits the parent's pinned resources
|
||||
// verbatim, with nothing copied through chat history.
|
||||
childRes, err := db.ListChatContextResourcesByChatID(ctx, childID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, childRes, len(parentRes), "child inherits the parent's pinned resource set")
|
||||
require.Equal(t, parentRes[0].Source, childRes[0].Source)
|
||||
require.Equal(t, parentRes[0].ContentHash, childRes[0].ContentHash)
|
||||
require.JSONEq(t, string(parentRes[0].Body), string(childRes[0].Body))
|
||||
require.Equal(t, parentChat.ContextAggregateHash, childChat.ContextAggregateHash,
|
||||
"child pins the same aggregate hash as the parent")
|
||||
}
|
||||
|
||||
@@ -1,148 +0,0 @@
|
||||
package chatd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"sync"
|
||||
|
||||
"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/x/chatd/chatprompt"
|
||||
"github.com/coder/coder/v2/coderd/x/chatd/chatstate"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/codersdk/workspacesdk"
|
||||
)
|
||||
|
||||
// errWorkspaceContextUnavailable is returned by buildWorkspaceContext
|
||||
// when there is nothing safe to persist for the current committed
|
||||
// metadata, e.g. the chat has no bound workspace agent or the agent is
|
||||
// no longer resolvable.
|
||||
var errWorkspaceContextUnavailable = xerrors.New("workspace context unavailable")
|
||||
|
||||
// buildWorkspaceContext fetches workspace context for the chat's
|
||||
// bound workspace agent and returns durable chatstate.Message values
|
||||
// for the generation action to commit. It returns
|
||||
// errWorkspaceContextUnavailable when there is nothing safe to
|
||||
// persist for the current committed metadata.
|
||||
func (server *Server) buildWorkspaceContext(
|
||||
ctx context.Context,
|
||||
input workspaceContextBuildInput,
|
||||
) (workspaceContextBuildResult, error) {
|
||||
chat := input.Chat
|
||||
if !chat.WorkspaceID.Valid || !chat.AgentID.Valid {
|
||||
return workspaceContextBuildResult{}, errWorkspaceContextUnavailable
|
||||
}
|
||||
logger := server.logger.With(
|
||||
slog.F("chat_id", chat.ID),
|
||||
slog.F("owner_id", chat.OwnerID),
|
||||
)
|
||||
|
||||
// Build a per-call workspace context with the latest committed
|
||||
// chat snapshot so getWorkspaceAgent and getWorkspaceConn dial
|
||||
// the agent we actually want to fetch context from.
|
||||
currentChat := chat
|
||||
var chatStateMu sync.Mutex
|
||||
wsCtx := turnWorkspaceContext{
|
||||
server: server,
|
||||
chatStateMu: &chatStateMu,
|
||||
currentChat: ¤tChat,
|
||||
loadChatSnapshot: server.db.GetChatByID,
|
||||
}
|
||||
defer wsCtx.close()
|
||||
|
||||
parts, expectedAgentID := server.fetchContextForBuild(ctx, chat, &wsCtx, logger)
|
||||
// If the workspace or agent is gone, report unavailable.
|
||||
if expectedAgentID == uuid.Nil {
|
||||
return workspaceContextBuildResult{}, errWorkspaceContextUnavailable
|
||||
}
|
||||
|
||||
hasContent := false
|
||||
hasContextFilePart := false
|
||||
for _, part := range parts {
|
||||
if part.Type == codersdk.ChatMessagePartTypeContextFile {
|
||||
hasContextFilePart = true
|
||||
if part.ContextFileContent != "" {
|
||||
hasContent = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
agentID := uuid.NullUUID{UUID: expectedAgentID, Valid: true}
|
||||
|
||||
// If we have no content but the agent is known, commit a blank
|
||||
// context-file marker (sentinel) so subsequent turns skip the
|
||||
// workspace-agent dial and the decision helper observes the
|
||||
// attempt in committed history. This applies whether the
|
||||
// workspace connection succeeded but returned no AGENTS.md, or
|
||||
// the agent's context config fetch failed: in both cases we
|
||||
// have a known agent and committing a sentinel breaks the
|
||||
// otherwise-infinite decision loop.
|
||||
if !hasContent {
|
||||
if !hasContextFilePart {
|
||||
parts = append([]codersdk.ChatMessagePart{{
|
||||
Type: codersdk.ChatMessagePartTypeContextFile,
|
||||
ContextFileAgentID: agentID,
|
||||
}}, parts...)
|
||||
}
|
||||
}
|
||||
|
||||
content, err := chatprompt.MarshalParts(parts)
|
||||
if err != nil {
|
||||
return workspaceContextBuildResult{}, xerrors.Errorf("marshal workspace context parts: %w", err)
|
||||
}
|
||||
|
||||
modelConfigID := chat.LastModelConfigID
|
||||
msg := chatstate.Message{
|
||||
Role: database.ChatMessageRoleUser,
|
||||
Content: content,
|
||||
Visibility: database.ChatMessageVisibilityBoth,
|
||||
ModelConfigID: uuid.NullUUID{UUID: modelConfigID, Valid: modelConfigID != uuid.Nil},
|
||||
ContentVersion: chatprompt.CurrentContentVersion,
|
||||
APIKeyID: sql.NullString{String: input.ActiveAPIKeyID, Valid: input.ActiveAPIKeyID != ""},
|
||||
}
|
||||
|
||||
// Update the cache column so subsequent turns can read the last
|
||||
// injected context without scanning messages. This is a
|
||||
// best-effort write that does not mutate chat history; the
|
||||
// generation action separately commits the durable message
|
||||
// below.
|
||||
stripped := make([]codersdk.ChatMessagePart, len(parts))
|
||||
copy(stripped, parts)
|
||||
for i := range stripped {
|
||||
stripped[i].StripInternal()
|
||||
}
|
||||
server.updateLastInjectedContext(ctx, chat.ID, stripped)
|
||||
|
||||
return workspaceContextBuildResult{Messages: []chatstate.Message{msg}}, nil
|
||||
}
|
||||
|
||||
// fetchContextForBuild fetches workspace context parts from the
|
||||
// agent, returning the parts to persist. expectedAgentID is the agent
|
||||
// ID the fetch was bound to, or uuid.Nil if the agent could not be
|
||||
// resolved.
|
||||
func (server *Server) fetchContextForBuild(
|
||||
ctx context.Context,
|
||||
chat database.Chat,
|
||||
wsCtx *turnWorkspaceContext,
|
||||
logger slog.Logger,
|
||||
) (parts []codersdk.ChatMessagePart, expectedAgentID uuid.UUID) {
|
||||
agent, agentParts, _, _ := server.fetchWorkspaceContext(
|
||||
ctx, chat, wsCtx.getWorkspaceAgent,
|
||||
func(instructionCtx context.Context) (workspacesdk.AgentConn, error) {
|
||||
if _, _, err := wsCtx.workspaceAgentIDForConn(instructionCtx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return wsCtx.getWorkspaceConn(instructionCtx)
|
||||
},
|
||||
)
|
||||
if agent == nil {
|
||||
// fetchWorkspaceContext returns nil for the agent when the
|
||||
// chat has no valid workspace or the agent lookup fails.
|
||||
logger.Debug(ctx, "workspace context build: workspace agent not resolvable")
|
||||
return nil, uuid.Nil
|
||||
}
|
||||
return agentParts, agent.ID
|
||||
}
|
||||
Reference in New Issue
Block a user