Skip to content

Commit cb5540c

Browse files
committed
feat(coderd): wire chat lifecycle hooks into chatd
Dispatches session_start, user_prompt_submit, pre/post_tool_use, stop, pre/post_compact, and subagent spawn hooks from chatd, applies hook responses (prompt overrides, prefix notices, allowed-tools narrowing, stop nudges, compaction summary hints), persists dispatch effects transactionally with each step, and injects the dispatcher from coderd based on the new deployment configuration (hook URL, secret, timeout, experiment) documented in the admin setup doc.
1 parent af39f14 commit cb5540c

41 files changed

Lines changed: 6860 additions & 170 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

cli/testdata/coder_server_--help.golden

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,20 @@ Configure the background chat processing daemon.
281281
Force chat debug logging on for every chat, bypassing the runtime
282282
admin and user opt-in settings.
283283

284+
--chat-hook-enabled bool, $CODER_CHAT_HOOK_ENABLED (default: true)
285+
Whether to dispatch chat agent lifecycle hooks when a hook URL is
286+
configured. Requires the agent-lifecycle-hooks experiment.
287+
288+
--chat-hook-secret string, $CODER_CHAT_HOOK_SECRET
289+
Shared secret used to sign chat agent lifecycle hook JWTs.
290+
291+
--chat-hook-timeout duration, $CODER_CHAT_HOOK_TIMEOUT (default: 1.5s)
292+
Maximum time to wait for a chat agent lifecycle hook response.
293+
294+
--chat-hook-url url, $CODER_CHAT_HOOK_URL
295+
HTTPS URL to receive chat agent lifecycle hook events. Hooks are
296+
disabled when unset. Requires the agent-lifecycle-hooks experiment.
297+
284298
CLIENT OPTIONS:
285299
These options change the behavior of how clients interact with the Coder.
286300
Clients include the Coder CLI, Coder Desktop, IDE extensions, and the web UI.

cli/testdata/server-config.yaml.golden

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -803,6 +803,17 @@ chat:
803803
# opt-in settings.
804804
# (default: false, type: bool)
805805
debugLoggingEnabled: false
806+
# HTTPS URL to receive chat agent lifecycle hook events. Hooks are disabled when
807+
# unset. Requires the agent-lifecycle-hooks experiment.
808+
# (default: <unset>, type: url)
809+
hookURL:
810+
# Maximum time to wait for a chat agent lifecycle hook response.
811+
# (default: 1.5s, type: duration)
812+
hookTimeout: 1.5s
813+
# Whether to dispatch chat agent lifecycle hooks when a hook URL is configured.
814+
# Requires the agent-lifecycle-hooks experiment.
815+
# (default: true, type: bool)
816+
hookEnabled: true
806817
# Deprecated: AI Gateway routing is now the only routing path. Setting this value
807818
# has no effect. This option will be removed in a future release.
808819
# (default: true, type: bool)

coderd/apidoc/docs.go

Lines changed: 23 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/apidoc/swagger.json

Lines changed: 23 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/coderd.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ import (
100100
"github.com/coder/coder/v2/coderd/x/chatd"
101101
"github.com/coder/coder/v2/coderd/x/chatd/chatprovider"
102102
"github.com/coder/coder/v2/coderd/x/chatd/mcpclient"
103+
"github.com/coder/coder/v2/coderd/x/chathooks"
103104
"github.com/coder/coder/v2/coderd/x/gitsync"
104105
"github.com/coder/coder/v2/codersdk"
105106
"github.com/coder/coder/v2/codersdk/drpcsdk"
@@ -878,6 +879,28 @@ func New(options *Options) *API {
878879
// the chat daemon stays nil and chat HTTP handlers return a
879880
// service-unavailable error with a clear remediation message.
880881
if options.DeploymentValues.AI.BridgeConfig.Enabled.Value() {
882+
var hookDispatcher *chathooks.Dispatcher
883+
chatConfig := options.DeploymentValues.AI.Chat
884+
hooksConfigured := chatConfig.HookURL.String() != "" && chatConfig.HookEnabled.Value()
885+
hooksExperimentEnabled := experiments.Enabled(codersdk.ExperimentAgentLifecycleHooks)
886+
if hooksConfigured && !hooksExperimentEnabled {
887+
options.Logger.Warn(ctx, "chat lifecycle hooks are configured but inactive; enable the agent-lifecycle-hooks experiment to activate them",
888+
slog.F("experiment", codersdk.ExperimentAgentLifecycleHooks),
889+
)
890+
}
891+
if hooksConfigured && hooksExperimentEnabled {
892+
hookDispatcher = chathooks.New(
893+
options.Logger,
894+
options.Database,
895+
nil,
896+
chatConfig.HookURL.String(),
897+
chatConfig.HookSecret.Value(),
898+
chatConfig.HookTimeout.Value(),
899+
api.DeploymentID,
900+
buildinfo.Version(),
901+
options.PrometheusRegistry,
902+
)
903+
}
881904
api.chatDaemon = chatd.New(options.Pubsub, chatd.Config{
882905
Logger: options.Logger.Named("chatd"),
883906
Database: options.Database,
@@ -897,6 +920,7 @@ func New(options *Options) *API {
897920
StartWorkspace: api.chatStartWorkspace,
898921
StopWorkspace: api.chatStopWorkspace,
899922
WebpushDispatcher: options.WebPushDispatcher,
923+
HookDispatcher: hookDispatcher,
900924
UsageTracker: options.WorkspaceUsageTracker,
901925
PrometheusRegistry: options.PrometheusRegistry,
902926
OIDCTokenSource: oidcMCPSrc,

coderd/exp_chats.go

Lines changed: 71 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ import (
5555
"github.com/coder/coder/v2/coderd/x/chatd/chatstate"
5656
"github.com/coder/coder/v2/coderd/x/chatd/chattool"
5757
"github.com/coder/coder/v2/coderd/x/chatfiles"
58+
"github.com/coder/coder/v2/coderd/x/chathooks"
5859
"github.com/coder/coder/v2/coderd/x/gitsync"
5960
"github.com/coder/coder/v2/codersdk"
6061
"github.com/coder/coder/v2/codersdk/wsjson"
@@ -110,6 +111,17 @@ func writeChatUsageLimitExceeded(
110111
})
111112
}
112113

114+
// Avoid returning raw dispatch errors, which may expose deployment internals.
115+
func writeChatHookDispatchFailed(ctx context.Context, rw http.ResponseWriter, hookErr *chathooks.DispatchError) {
116+
httpapi.Write(ctx, rw, http.StatusBadGateway, codersdk.ChatHookDispatchFailedResponse{
117+
Response: codersdk.Response{
118+
Message: "Chat lifecycle hook dispatch failed.",
119+
Detail: fmt.Sprintf("Lifecycle hook dispatch %s failed (%s).", hookErr.DispatchID, hookErr.Class),
120+
},
121+
Kind: codersdk.ChatErrorKindHookDispatchFailed,
122+
})
123+
}
124+
113125
func maybeWriteLimitErr(ctx context.Context, rw http.ResponseWriter, err error) bool {
114126
var limitErr *chatd.UsageLimitExceededError
115127
if errors.As(err, &limitErr) {
@@ -1424,23 +1436,38 @@ func (api *API) postChats(rw http.ResponseWriter, r *http.Request) {
14241436
}
14251437

14261438
chat, err := api.chatDaemon.CreateChat(ctx, chatd.CreateOptions{
1427-
OrganizationID: req.OrganizationID,
1428-
OwnerID: apiKey.UserID,
1429-
WorkspaceID: workspaceSelection.WorkspaceID,
1430-
Title: title,
1431-
ModelConfigID: modelConfigID,
1432-
ReasoningEffort: reasoningEffort,
1433-
PlanMode: planModeToNullChatPlanMode(req.PlanMode),
1434-
ClientType: clientType,
1435-
SystemPrompt: req.SystemPrompt,
1436-
InitialUserContent: contentBlocks,
1437-
MCPServerIDs: mcpServerIDs,
1438-
Labels: labels,
1439-
DynamicTools: dynamicToolsJSON,
1439+
OrganizationID: req.OrganizationID,
1440+
OwnerID: apiKey.UserID,
1441+
WorkspaceID: workspaceSelection.WorkspaceID,
1442+
Title: title,
1443+
TitleDerivedFromContent: true,
1444+
ModelConfigID: modelConfigID,
1445+
ReasoningEffort: reasoningEffort,
1446+
PlanMode: planModeToNullChatPlanMode(req.PlanMode),
1447+
ClientType: clientType,
1448+
SystemPrompt: req.SystemPrompt,
1449+
InitialUserContent: contentBlocks,
1450+
MCPServerIDs: mcpServerIDs,
1451+
Labels: labels,
1452+
DynamicTools: dynamicToolsJSON,
14401453
// IMPORTANT: users can only create root chats at the time of writing.
14411454
ParentChatID: uuid.NullUUID{},
14421455
})
14431456
if err != nil {
1457+
var denied *chatd.UserPromptDeniedError
1458+
if errors.As(err, &denied) {
1459+
message := denied.UserMessage
1460+
if message == "" {
1461+
message = "Chat creation denied by lifecycle hook."
1462+
}
1463+
httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{Message: message})
1464+
return
1465+
}
1466+
var hookErr *chathooks.DispatchError
1467+
if errors.As(err, &hookErr) {
1468+
writeChatHookDispatchFailed(ctx, rw, hookErr)
1469+
return
1470+
}
14441471
if maybeWriteLimitErr(ctx, rw, err) {
14451472
return
14461473
}
@@ -3381,6 +3408,20 @@ func (api *API) postChatMessages(rw http.ResponseWriter, r *http.Request) {
33813408
},
33823409
)
33833410
if sendErr != nil {
3411+
var denied *chatd.UserPromptDeniedError
3412+
if errors.As(sendErr, &denied) {
3413+
message := denied.UserMessage
3414+
if message == "" {
3415+
message = "Chat message denied by lifecycle hook."
3416+
}
3417+
httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{Message: message})
3418+
return
3419+
}
3420+
var hookErr *chathooks.DispatchError
3421+
if errors.As(sendErr, &hookErr) {
3422+
writeChatHookDispatchFailed(ctx, rw, hookErr)
3423+
return
3424+
}
33843425
if maybeWriteLimitErr(ctx, rw, sendErr) {
33853426
return
33863427
}
@@ -3550,6 +3591,20 @@ func (api *API) patchChatMessage(rw http.ResponseWriter, r *http.Request) {
35503591
ReasoningEffort: editReasoningEffort,
35513592
})
35523593
if editErr != nil {
3594+
var denied *chatd.UserPromptDeniedError
3595+
if errors.As(editErr, &denied) {
3596+
message := denied.UserMessage
3597+
if message == "" {
3598+
message = "Chat message denied by lifecycle hook."
3599+
}
3600+
httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{Message: message})
3601+
return
3602+
}
3603+
var hookErr *chathooks.DispatchError
3604+
if errors.As(editErr, &hookErr) {
3605+
writeChatHookDispatchFailed(ctx, rw, hookErr)
3606+
return
3607+
}
35533608
if maybeWriteLimitErr(ctx, rw, editErr) {
35543609
return
35553610
}
@@ -8157,7 +8212,10 @@ func (api *API) postChatToolResults(rw http.ResponseWriter, r *http.Request) {
81578212
if err != nil {
81588213
var validationErr *chatd.ToolResultValidationError
81598214
var conflictErr *chatd.ToolResultStatusConflictError
8215+
var hookErr *chathooks.DispatchError
81608216
switch {
8217+
case errors.As(err, &hookErr):
8218+
writeChatHookDispatchFailed(ctx, rw, hookErr)
81618219
case xerrors.Is(err, chatd.ErrChatArchived):
81628220
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
81638221
Message: "Cannot submit tool results to an archived chat.",

0 commit comments

Comments
 (0)