Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit 92de546

Browse files
committed
feat: mark chat tool calls a lifecycle hook rewrote
A pre_tool_use input_override replaced the tool input, and the stored call then rendered exactly like one the model authored. The persisted tool-call part now records that policy replaced the input, and the chat row carries a "Modified by policy" badge, so the rewrite is legible without the consumer returning a user_message. The denied-tool story also gained the parsed_commands that real stored calls carry, so it asserts the summarized label users actually see.
1 parent 36ed194 commit 92de546

22 files changed

Lines changed: 441 additions & 62 deletions

coderd/apidoc/docs.go

Lines changed: 4 additions & 0 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: 4 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/exp_chats_hooks_test.go

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -309,17 +309,26 @@ func TestChatLifecycleHooksWorkedExample(t *testing.T) {
309309

310310
messages, err := client.GetChatMessages(ctx, chat.ID, nil)
311311
require.NoError(t, err)
312-
var allowedCall *codersdk.ChatMessagePart
312+
var allowedCall, deniedCall *codersdk.ChatMessagePart
313313
for _, message := range messages.Messages {
314314
for i := range message.Content {
315315
part := &message.Content[i]
316-
if part.Type == codersdk.ChatMessagePartTypeToolCall && part.ToolCallID == allowedToolCallID {
316+
if part.Type != codersdk.ChatMessagePartTypeToolCall {
317+
continue
318+
}
319+
switch part.ToolCallID {
320+
case allowedToolCallID:
317321
allowedCall = part
322+
case deniedToolCallID:
323+
deniedCall = part
318324
}
319325
}
320326
}
321327
require.NotNil(t, allowedCall)
322328
require.JSONEq(t, `{"query":"public documentation"}`, string(allowedCall.Args))
329+
require.True(t, allowedCall.HookRewritten)
330+
require.NotNil(t, deniedCall)
331+
require.False(t, deniedCall.HookRewritten)
323332

324333
err = client.SubmitToolResults(ctx, chat.ID, codersdk.SubmitToolResultsRequest{
325334
Results: []codersdk.ToolResult{{

coderd/x/chatd/generation.go

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -748,12 +748,13 @@ func (s *taskStarter) generateAssistant(
748748
}
749749
outcome.Step.Content = chathooks.ApplyAdmittedToolCalls(outcome.Step.Content, preflight)
750750
messages, err := buildCommitStepMessages(buildCommitStepMessagesInput{
751-
modelConfigID: prepared.ModelConfigID,
752-
modelCallConfig: prepared.ModelConfig,
753-
step: stepDataFromPersisted(outcome.Step),
754-
toolNameToConfigID: prepared.ToolNameToConfigID,
755-
logger: s.opts.Logger,
756-
contentVersion: chatprompt.CurrentContentVersion,
751+
modelConfigID: prepared.ModelConfigID,
752+
modelCallConfig: prepared.ModelConfig,
753+
step: stepDataFromPersisted(outcome.Step),
754+
toolNameToConfigID: prepared.ToolNameToConfigID,
755+
logger: s.opts.Logger,
756+
contentVersion: chatprompt.CurrentContentVersion,
757+
hookRewrittenToolCalls: preflight.Overrides,
757758
})
758759
if err != nil {
759760
return s.finishGenerationError(ctx, machine, input, err, requireGenerationAttempt(attempt.number))

coderd/x/chatd/message_conversion.go

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -28,12 +28,13 @@ import (
2828
const interruptedToolResultErrorMessage = "tool call was interrupted before it produced a result"
2929

3030
type buildCommitStepMessagesInput struct {
31-
modelConfigID uuid.UUID
32-
modelCallConfig codersdk.ChatModelCallConfig
33-
step stepData
34-
toolNameToConfigID map[string]uuid.UUID
35-
logger slog.Logger
36-
contentVersion int16
31+
modelConfigID uuid.UUID
32+
modelCallConfig codersdk.ChatModelCallConfig
33+
step stepData
34+
toolNameToConfigID map[string]uuid.UUID
35+
logger slog.Logger
36+
contentVersion int16
37+
hookRewrittenToolCalls map[string]json.RawMessage
3738
}
3839

3940
type stepMessagesForCommit struct {
@@ -51,7 +52,7 @@ func buildCommitStepMessages(input buildCommitStepMessagesInput) (stepMessagesFo
5152
}
5253

5354
assistantBlocks, toolResults := splitStepContent(input.step.Content)
54-
assistantParts := buildAssistantParts(input.logger, assistantBlocks, toolResults, input.step, input.toolNameToConfigID)
55+
assistantParts := buildAssistantParts(input.logger, assistantBlocks, toolResults, input.step, input.toolNameToConfigID, input.hookRewrittenToolCalls)
5556

5657
messages := make([]chatstate.Message, 0, 1+len(toolResults))
5758
if len(assistantParts) > 0 {
@@ -112,6 +113,7 @@ func buildAssistantParts(
112113
toolResults []fantasy.ToolResultContent,
113114
step stepData,
114115
toolNameToConfigID map[string]uuid.UUID,
116+
hookRewrittenToolCalls map[string]json.RawMessage,
115117
) []codersdk.ChatMessagePart {
116118
parts := make([]codersdk.ChatMessagePart, 0, len(assistantBlocks)+len(toolResults))
117119
reasoningIdx := 0
@@ -125,6 +127,9 @@ func buildAssistantParts(
125127
part.CreatedAt = &ts
126128
}
127129
}
130+
if part.ToolCallID != "" {
131+
_, part.HookRewritten = hookRewrittenToolCalls[part.ToolCallID]
132+
}
128133
case codersdk.ChatMessagePartTypeToolResult:
129134
if part.ToolCallID != "" && step.ToolResultCreatedAt != nil {
130135
if ts, ok := step.ToolResultCreatedAt[part.ToolCallID]; ok {

coderd/x/chatd/message_conversion_test.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -832,3 +832,37 @@ func (s *partialConversionLogSink) entriesAtLevelWithMessage(level slog.Level, m
832832
}
833833
return entries
834834
}
835+
836+
func TestBuildCommitStepMessages_MarksHookRewrittenToolCalls(t *testing.T) {
837+
t.Parallel()
838+
839+
got, err := buildCommitStepMessages(buildCommitStepMessagesInput{
840+
modelConfigID: uuid.New(),
841+
contentVersion: chatprompt.CurrentContentVersion,
842+
logger: slog.Make(),
843+
step: stepData{
844+
Content: []fantasy.Content{
845+
fantasy.ToolCallContent{
846+
ToolCallID: "rewritten",
847+
ToolName: "execute",
848+
Input: `{"command":"echo admitted"}`,
849+
},
850+
fantasy.ToolCallContent{
851+
ToolCallID: "untouched",
852+
ToolName: "execute",
853+
Input: `{"command":"echo original"}`,
854+
},
855+
},
856+
},
857+
hookRewrittenToolCalls: map[string]json.RawMessage{"rewritten": {}},
858+
})
859+
require.NoError(t, err)
860+
require.Len(t, got.Messages, 1)
861+
862+
parts := parseMessageParts(t, got.Messages[0].Role, got.Messages[0].Content)
863+
require.Len(t, parts, 2)
864+
require.Equal(t, "rewritten", parts[0].ToolCallID)
865+
require.True(t, parts[0].HookRewritten)
866+
require.Equal(t, "untouched", parts[1].ToolCallID)
867+
require.False(t, parts[1].HookRewritten)
868+
}

codersdk/chats.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -380,6 +380,10 @@ type ChatMessagePart struct {
380380
// ProviderExecuted indicates the tool call was executed by
381381
// the provider (e.g. Anthropic computer use).
382382
ProviderExecuted bool `json:"provider_executed,omitempty" variants:"tool-call?,tool-result?"`
383+
// HookRewritten indicates a lifecycle hook replaced the input the
384+
// model proposed for this call. Without it the stored call is
385+
// indistinguishable from one the model authored.
386+
HookRewritten bool `json:"hook_rewritten,omitempty" variants:"tool-call?"`
383387
// CreatedAt is the timestamp this part carries. The semantics
384388
// depend on the part type: for tool-call and tool-result parts
385389
// it is the time the call was emitted or the result was

docs/admin/setup/chat-lifecycle-hooks.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,8 @@ Permission rules depend on the event:
126126
- For `pre_tool_use`, `allow` requires `input_override` containing the replacement tool input.
127127
Coder persists the replacement with the tool call and executes the tool with it.
128128
An override for a built-in tool must not repeat a key or vary the capitalization of a schema property; an ambiguous override fails the dispatch closed because the model can't correct it.
129-
Nothing marks the call as rewritten in the chat, so the model may misattribute the changed behavior; a consumer that rewrites input should also return `user_message` explaining the change.
129+
The stored call is marked as rewritten, and the chat shows a "Modified by policy" badge.
130+
The marker is client-facing, so return `model_context` if the model also needs an explanation of the rewrite.
130131
- For either event, `deny` blocks the input and must not include `input_override`.
131132
A denied prompt isn't persisted: Coder rejects the submission and surfaces any returned `user_message` in the rejection, ignoring `model_context`.
132133
A denied tool call becomes a synthetic error result, and any returned `model_context` reaches the model separately, so the model can choose another action.

docs/reference/api/chats.md

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

docs/reference/api/schemas.md

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

0 commit comments

Comments
 (0)