Skip to content

Commit df2a1c9

Browse files
committed
fix: enforce tool-use lineage invariants before chat requests
1 parent 8c926f1 commit df2a1c9

4 files changed

Lines changed: 131 additions & 14 deletions

File tree

‎internal/chat/client.go‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,7 @@ func (c *client) StreamSnapshots(ctx context.Context, req Request, onSnapshot fu
170170
for _, summary := range summarizeRequestMessages(req.Messages) {
171171
c.scope.Debug("chat request message", "summary", summary)
172172
}
173+
c.scope.Debug("chat request tool lineage", "summary", summarizeToolLineage(req.Messages))
173174

174175
// Get fresh token for this request
175176
token, err := c.auth.GetAccessToken(ctx)
@@ -344,3 +345,19 @@ func summarizeRequestMessages(messages []domain.Message) []string {
344345
}
345346
return out
346347
}
348+
349+
func summarizeToolLineage(messages []domain.Message) string {
350+
toolUses := make([]string, 0)
351+
toolResults := make([]string, 0)
352+
for _, msg := range messages {
353+
for _, b := range msg.Content {
354+
if b.ToolUse != nil && b.ToolUse.ID != "" {
355+
toolUses = append(toolUses, b.ToolUse.ID)
356+
}
357+
if b.ToolResult != nil && b.ToolResult.ToolUseID != "" {
358+
toolResults = append(toolResults, b.ToolResult.ToolUseID)
359+
}
360+
}
361+
}
362+
return fmt.Sprintf("tool_use_ids=[%s] tool_result_ids=[%s]", strings.Join(toolUses, ","), strings.Join(toolResults, ","))
363+
}

‎internal/chat/protocol_mapping_test.go‎

Lines changed: 57 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -119,27 +119,40 @@ func TestToWireRequest_SanitizesToolResultContent(t *testing.T) {
119119

120120
wireReq, err := toWireRequest(Request{
121121
ConversationID: "00000000-0000-0000-0000-000000000001",
122-
Messages: []domain.Message{{
123-
Role: domain.RoleUser,
124-
Content: []domain.Block{{
125-
Type: domain.BlockTypeToolResult,
126-
ToolResult: &domain.ToolResult{
127-
ToolUseID: "tool-1",
128-
Content: map[string]any{
129-
"tool_use_id": "tool-1",
130-
"rows": []map[string]any{{"id": "svc-1"}},
122+
Messages: []domain.Message{
123+
{
124+
Role: domain.RoleAssistant,
125+
Content: []domain.Block{{
126+
Type: domain.BlockTypeToolUse,
127+
ToolUse: &domain.ToolUse{
128+
ID: "tool-1",
129+
Name: "query",
130+
Input: json.RawMessage(`{"sql":"select 1"}`),
131131
},
132-
},
133-
}},
134-
}},
132+
}},
133+
},
134+
{
135+
Role: domain.RoleUser,
136+
Content: []domain.Block{{
137+
Type: domain.BlockTypeToolResult,
138+
ToolResult: &domain.ToolResult{
139+
ToolUseID: "tool-1",
140+
Content: map[string]any{
141+
"tool_use_id": "tool-1",
142+
"rows": []map[string]any{{"id": "svc-1"}},
143+
},
144+
},
145+
}},
146+
},
147+
},
135148
})
136149
if err != nil {
137150
t.Fatalf("toWireRequest() error = %v", err)
138151
}
139-
if len(wireReq.Messages) != 1 || len(wireReq.Messages[0].Content) != 1 {
152+
if len(wireReq.Messages) != 2 || len(wireReq.Messages[1].Content) != 1 {
140153
t.Fatalf("unexpected wire request shape: %#v", wireReq)
141154
}
142-
content := wireReq.Messages[0].Content[0].ToolResult.Content
155+
content := wireReq.Messages[1].Content[0].ToolResult.Content
143156
var parsed map[string]any
144157
if err := json.Unmarshal(content, &parsed); err != nil {
145158
t.Fatalf("tool_result.content should be JSON object, got error: %v", err)
@@ -196,3 +209,33 @@ func TestToWireRequest_RejectsInvalidContextEntity(t *testing.T) {
196209
t.Fatalf("error = %v", err)
197210
}
198211
}
212+
213+
func TestToWireRequest_RejectsUnknownToolUseReference(t *testing.T) {
214+
t.Parallel()
215+
216+
_, err := toWireRequest(Request{
217+
ConversationID: "00000000-0000-0000-0000-000000000001",
218+
Messages: []domain.Message{
219+
{
220+
Role: domain.RoleUser,
221+
Content: []domain.Block{{
222+
Type: domain.BlockTypeText,
223+
Text: &domain.TextBlock{Content: "hello"},
224+
}},
225+
},
226+
{
227+
Role: domain.RoleUser,
228+
Content: []domain.Block{{
229+
Type: domain.BlockTypeToolResult,
230+
ToolResult: &domain.ToolResult{
231+
ToolUseID: "toolu_missing",
232+
Content: map[string]any{"rows": []any{}},
233+
},
234+
}},
235+
},
236+
},
237+
})
238+
if err == nil || !strings.Contains(err.Error(), `unknown tool_use_id "toolu_missing"`) {
239+
t.Fatalf("error = %v", err)
240+
}
241+
}

‎internal/chat/protocolv2/request.go‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,9 @@ func Validate(req Request) error {
121121
return fmt.Errorf("context_entities[%d]: %w", i, err)
122122
}
123123
}
124+
if err := validateToolReferences(req.Messages); err != nil {
125+
return err
126+
}
124127
return nil
125128
}
126129

@@ -224,3 +227,27 @@ func validateContextEntity(entity ContextEntity) error {
224227
}
225228
return nil
226229
}
230+
231+
func validateToolReferences(messages []Message) error {
232+
seen := make(map[string]struct{})
233+
for i, msg := range messages {
234+
for j, block := range msg.Content {
235+
switch block.Type {
236+
case BlockTypeText, BlockTypeThinking:
237+
// No cross-message linkage requirements.
238+
case BlockTypeToolUse:
239+
id := block.ToolUse.ID
240+
if _, exists := seen[id]; exists {
241+
return fmt.Errorf("messages[%d].content[%d]: duplicate tool_use id %q", i, j, id)
242+
}
243+
seen[id] = struct{}{}
244+
case BlockTypeToolResult:
245+
id := block.ToolResult.ToolUseID
246+
if _, exists := seen[id]; !exists {
247+
return fmt.Errorf("messages[%d].content[%d]: unknown tool_use_id %q", i, j, id)
248+
}
249+
}
250+
}
251+
}
252+
return nil
253+
}

‎internal/chat/protocolv2/request_test.go‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,4 +195,34 @@ func TestValidate(t *testing.T) {
195195
t.Fatalf("error = %v", err)
196196
}
197197
})
198+
199+
t.Run("unknown tool_use_id fails", func(t *testing.T) {
200+
t.Parallel()
201+
req := newValidRequest()
202+
req.Messages[1].Content[0].ToolResult.ToolUseID = "toolu_missing"
203+
err := Validate(req)
204+
if err == nil || !strings.Contains(err.Error(), `unknown tool_use_id "toolu_missing"`) {
205+
t.Fatalf("error = %v", err)
206+
}
207+
})
208+
209+
t.Run("duplicate tool_use id fails", func(t *testing.T) {
210+
t.Parallel()
211+
req := newValidRequest()
212+
req.Messages = append(req.Messages, Message{
213+
Role: RoleAssistant,
214+
Content: []Block{{
215+
Type: BlockTypeToolUse,
216+
ToolUse: &ToolUse{
217+
ID: "toolu_1",
218+
Name: "query",
219+
Input: json.RawMessage(`{"sql":"select 2"}`),
220+
},
221+
}},
222+
})
223+
err := Validate(req)
224+
if err == nil || !strings.Contains(err.Error(), `duplicate tool_use id "toolu_1"`) {
225+
t.Fatalf("error = %v", err)
226+
}
227+
})
198228
}

0 commit comments

Comments
 (0)