|
4 | 4 | "context" |
5 | 5 | "encoding/json" |
6 | 6 | "errors" |
| 7 | + "fmt" |
7 | 8 | "log/slog" |
8 | 9 | "strings" |
9 | 10 |
|
@@ -167,10 +168,21 @@ func (c *Client) CreateChatCompletionStream( |
167 | 168 | return nil, err |
168 | 169 | } |
169 | 170 |
|
| 171 | + converted := convertMessages(messages) |
| 172 | + // Preflight validation to ensure tool_use/tool_result sequencing is valid |
| 173 | + if err := validateAnthropicSequencing(converted); err != nil { |
| 174 | + slog.Warn("Invalid message sequencing for Anthropic detected, attempting self-repair", "error", err) |
| 175 | + converted = repairAnthropicSequencing(converted) |
| 176 | + if err2 := validateAnthropicSequencing(converted); err2 != nil { |
| 177 | + slog.Error("Failed to self-repair Anthropic sequencing", "error", err2) |
| 178 | + return nil, err |
| 179 | + } |
| 180 | + } |
| 181 | + |
170 | 182 | params := anthropic.MessageNewParams{ |
171 | 183 | Model: anthropic.Model(c.config.Model), |
172 | 184 | MaxTokens: maxTokens, |
173 | | - Messages: convertMessages(messages), |
| 185 | + Messages: converted, |
174 | 186 | Tools: allTools, |
175 | 187 | } |
176 | 188 |
|
@@ -219,8 +231,11 @@ func (c *Client) CreateChatCompletionStream( |
219 | 231 |
|
220 | 232 | func convertMessages(messages []chat.Message) []anthropic.MessageParam { |
221 | 233 | var anthropicMessages []anthropic.MessageParam |
| 234 | + // Track whether the last appended assistant message included tool_use blocks |
| 235 | + // so we can ensure the immediate next message is the grouped tool_result user message. |
| 236 | + pendingAssistantToolUse := false |
222 | 237 |
|
223 | | - for i := range messages { |
| 238 | + for i := 0; i < len(messages); i++ { |
224 | 239 | msg := &messages[i] |
225 | 240 | if msg.Role == chat.MessageRoleSystem { |
226 | 241 | // System messages are handled via the top-level params.System |
@@ -333,19 +348,43 @@ func convertMessages(messages []chat.Message) []anthropic.MessageParam { |
333 | 348 | } |
334 | 349 | } |
335 | 350 | anthropicMessages = append(anthropicMessages, anthropic.NewAssistantMessage(toolUseBlocks...)) |
| 351 | + // Mark that we expect the very next message to be the grouped tool_result blocks. |
| 352 | + pendingAssistantToolUse = true |
336 | 353 | } else { |
337 | 354 | if txt := strings.TrimSpace(msg.Content); txt != "" { |
338 | 355 | contentBlocks = append(contentBlocks, anthropic.NewTextBlock(txt)) |
339 | 356 | } |
340 | 357 | if len(contentBlocks) > 0 { |
341 | 358 | anthropicMessages = append(anthropicMessages, anthropic.NewAssistantMessage(contentBlocks...)) |
342 | 359 | } |
| 360 | + // No tool_use in this assistant message |
| 361 | + pendingAssistantToolUse = false |
343 | 362 | } |
344 | 363 | continue |
345 | 364 | } |
346 | 365 | if msg.Role == chat.MessageRoleTool { |
347 | | - toolResult := anthropic.NewToolResultBlock(msg.ToolCallID, strings.TrimSpace(msg.Content), false) |
348 | | - anthropicMessages = append(anthropicMessages, anthropic.NewUserMessage(toolResult)) |
| 366 | + // Group consecutive tool results into a single user message. |
| 367 | + // |
| 368 | + // This is to satisfy Anthropic's requirement that tool_use blocks are immediately followed |
| 369 | + // by a single user message containing all corresponding tool_result blocks. |
| 370 | + var blocks []anthropic.ContentBlockParamUnion |
| 371 | + j := i |
| 372 | + for j < len(messages) && messages[j].Role == chat.MessageRoleTool { |
| 373 | + tr := anthropic.NewToolResultBlock(messages[j].ToolCallID, strings.TrimSpace(messages[j].Content), false) |
| 374 | + blocks = append(blocks, tr) |
| 375 | + j++ |
| 376 | + } |
| 377 | + if len(blocks) > 0 { |
| 378 | + // Only include tool_result blocks if they immediately follow an assistant |
| 379 | + // message that contained tool_use. Otherwise, drop them to avoid invalid |
| 380 | + // sequencing errors. |
| 381 | + if pendingAssistantToolUse { |
| 382 | + anthropicMessages = append(anthropicMessages, anthropic.NewUserMessage(blocks...)) |
| 383 | + } |
| 384 | + // Whether we used them or not, we've now handled the expected tool_result slot. |
| 385 | + pendingAssistantToolUse = false |
| 386 | + } |
| 387 | + i = j - 1 |
349 | 388 | continue |
350 | 389 | } |
351 | 390 | } |
@@ -417,3 +456,144 @@ func (c *Client) ID() string { |
417 | 456 | func (c *Client) Options() options.ModelOptions { |
418 | 457 | return c.modelOptions |
419 | 458 | } |
| 459 | + |
| 460 | +// validateAnthropicSequencing verifies that for every assistant message that includes |
| 461 | +// one or more tool_use blocks, the immediately following message is a user message |
| 462 | +// that includes tool_result blocks for all those tool_use IDs (grouped into that single message). |
| 463 | +func validateAnthropicSequencing(msgs []anthropic.MessageParam) error { |
| 464 | + // Marshal-based inspection to avoid depending on SDK internals of union types |
| 465 | + for i := range msgs { |
| 466 | + m, ok := marshalToMap(msgs[i]) |
| 467 | + if !ok || m["role"] != "assistant" { |
| 468 | + continue |
| 469 | + } |
| 470 | + |
| 471 | + toolUseIDs := collectToolUseIDs(contentArray(m)) |
| 472 | + if len(toolUseIDs) == 0 { |
| 473 | + continue |
| 474 | + } |
| 475 | + |
| 476 | + if i+1 >= len(msgs) { |
| 477 | + slog.Warn("Anthropic sequencing invalid: assistant tool_use present but no next user tool_result message", "assistant_index", i) |
| 478 | + return errors.New("assistant tool_use present but no subsequent user message with tool_result blocks") |
| 479 | + } |
| 480 | + |
| 481 | + next, ok := marshalToMap(msgs[i+1]) |
| 482 | + if !ok || next["role"] != "user" { |
| 483 | + slog.Warn("Anthropic sequencing invalid: next message after assistant tool_use is not user", "assistant_index", i, "next_role", next["role"]) |
| 484 | + return errors.New("assistant tool_use must be followed by a user message containing corresponding tool_result blocks") |
| 485 | + } |
| 486 | + |
| 487 | + toolResultIDs := collectToolResultIDs(contentArray(next)) |
| 488 | + missing := differenceIDs(toolUseIDs, toolResultIDs) |
| 489 | + if len(missing) > 0 { |
| 490 | + slog.Warn("Anthropic sequencing invalid: missing tool_result for tool_use id in next user message", "assistant_index", i, "tool_use_id", missing[0], "missing_count", len(missing)) |
| 491 | + return fmt.Errorf("missing tool_result for tool_use id %s in the next user message", missing[0]) |
| 492 | + } |
| 493 | + } |
| 494 | + return nil |
| 495 | +} |
| 496 | + |
| 497 | +// repairAnthropicSequencing inserts a synthetic user message containing tool_result blocks |
| 498 | +// immediately after any assistant message that has tool_use blocks missing a corresponding |
| 499 | +// tool_result in the next user message. This is a best-effort local repair to keep the |
| 500 | +// conversation valid for Anthropic while preserving original messages, to keep the agent loop running. |
| 501 | +func repairAnthropicSequencing(msgs []anthropic.MessageParam) []anthropic.MessageParam { |
| 502 | + if len(msgs) == 0 { |
| 503 | + return msgs |
| 504 | + } |
| 505 | + repaired := make([]anthropic.MessageParam, 0, len(msgs)+2) |
| 506 | + for i := range msgs { |
| 507 | + repaired = append(repaired, msgs[i]) |
| 508 | + |
| 509 | + m, ok := marshalToMap(msgs[i]) |
| 510 | + if !ok || m["role"] != "assistant" { |
| 511 | + continue |
| 512 | + } |
| 513 | + |
| 514 | + toolUseIDs := collectToolUseIDs(contentArray(m)) |
| 515 | + if len(toolUseIDs) == 0 { |
| 516 | + continue |
| 517 | + } |
| 518 | + |
| 519 | + // Remove any IDs that already have results in the next user message |
| 520 | + if i+1 < len(msgs) { |
| 521 | + if next, ok := marshalToMap(msgs[i+1]); ok && next["role"] == "user" { |
| 522 | + toolResultIDs := collectToolResultIDs(contentArray(next)) |
| 523 | + for id := range toolResultIDs { |
| 524 | + delete(toolUseIDs, id) |
| 525 | + } |
| 526 | + } |
| 527 | + } |
| 528 | + |
| 529 | + if len(toolUseIDs) > 0 { |
| 530 | + blocks := make([]anthropic.ContentBlockParamUnion, 0, len(toolUseIDs)) |
| 531 | + for id := range toolUseIDs { |
| 532 | + blocks = append(blocks, anthropic.NewToolResultBlock(id, "(tool execution failed)", false)) |
| 533 | + } |
| 534 | + repaired = append(repaired, anthropic.NewUserMessage(blocks...)) |
| 535 | + } |
| 536 | + } |
| 537 | + return repaired |
| 538 | +} |
| 539 | + |
| 540 | +// Helpers for map-based inspection |
| 541 | +func marshalToMap(v any) (map[string]any, bool) { |
| 542 | + b, err := json.Marshal(v) |
| 543 | + if err != nil { |
| 544 | + return nil, false |
| 545 | + } |
| 546 | + var m map[string]any |
| 547 | + if json.Unmarshal(b, &m) != nil { |
| 548 | + return nil, false |
| 549 | + } |
| 550 | + return m, true |
| 551 | +} |
| 552 | + |
| 553 | +func contentArray(m map[string]any) []any { |
| 554 | + if a, ok := m["content"].([]any); ok { |
| 555 | + return a |
| 556 | + } |
| 557 | + return nil |
| 558 | +} |
| 559 | + |
| 560 | +func collectToolUseIDs(content []any) map[string]struct{} { |
| 561 | + ids := make(map[string]struct{}) |
| 562 | + for _, c := range content { |
| 563 | + if cb, ok := c.(map[string]any); ok { |
| 564 | + if t, _ := cb["type"].(string); t == "tool_use" { |
| 565 | + if id, _ := cb["id"].(string); id != "" { |
| 566 | + ids[id] = struct{}{} |
| 567 | + } |
| 568 | + } |
| 569 | + } |
| 570 | + } |
| 571 | + return ids |
| 572 | +} |
| 573 | + |
| 574 | +func collectToolResultIDs(content []any) map[string]struct{} { |
| 575 | + ids := make(map[string]struct{}) |
| 576 | + for _, c := range content { |
| 577 | + if cb, ok := c.(map[string]any); ok { |
| 578 | + if t, _ := cb["type"].(string); t == "tool_result" { |
| 579 | + if id, _ := cb["tool_use_id"].(string); id != "" { |
| 580 | + ids[id] = struct{}{} |
| 581 | + } |
| 582 | + } |
| 583 | + } |
| 584 | + } |
| 585 | + return ids |
| 586 | +} |
| 587 | + |
| 588 | +func differenceIDs(a, b map[string]struct{}) []string { |
| 589 | + if len(a) == 0 { |
| 590 | + return nil |
| 591 | + } |
| 592 | + var missing []string |
| 593 | + for id := range a { |
| 594 | + if _, ok := b[id]; !ok { |
| 595 | + missing = append(missing, id) |
| 596 | + } |
| 597 | + } |
| 598 | + return missing |
| 599 | +} |
0 commit comments