SubmitToolResults validates and persists client-provided tool results, transitions the chat to pending, and wakes the run loop. The caller is responsible for the fast-path status check; this method performs an authoritative re-check under a row lock.
( ctx context.Context, opts SubmitToolResultsOptions, )
| 2657 | // loop. The caller is responsible for the fast-path status check; |
| 2658 | // this method performs an authoritative re-check under a row lock. |
| 2659 | func (p *Server) SubmitToolResults( |
| 2660 | ctx context.Context, |
| 2661 | opts SubmitToolResultsOptions, |
| 2662 | ) error { |
| 2663 | dynamicToolNames, err := parseDynamicToolNames(pqtype.NullRawMessage{ |
| 2664 | RawMessage: opts.DynamicTools, |
| 2665 | Valid: len(opts.DynamicTools) > 0, |
| 2666 | }) |
| 2667 | if err != nil { |
| 2668 | return xerrors.Errorf("parse chat dynamic tools: %w", err) |
| 2669 | } |
| 2670 | |
| 2671 | // The GetLastChatMessageByRole lookup and all subsequent |
| 2672 | // validation and persistence run inside a single transaction |
| 2673 | // so the assistant message cannot change between reads. |
| 2674 | var statusConflict *ToolResultStatusConflictError |
| 2675 | txErr := p.db.InTx(func(tx database.Store) error { |
| 2676 | // Authoritative status check under row lock. |
| 2677 | locked, lockErr := tx.GetChatByIDForUpdate(ctx, opts.ChatID) |
| 2678 | if lockErr != nil { |
| 2679 | return xerrors.Errorf("lock chat for update: %w", lockErr) |
| 2680 | } |
| 2681 | if locked.Archived { |
| 2682 | return ErrChatArchived |
| 2683 | } |
| 2684 | if locked.Status != database.ChatStatusRequiresAction { |
| 2685 | statusConflict = &ToolResultStatusConflictError{ |
| 2686 | ActualStatus: locked.Status, |
| 2687 | } |
| 2688 | return statusConflict |
| 2689 | } |
| 2690 | |
| 2691 | // Get the last assistant message inside the transaction |
| 2692 | // for consistency with the row lock above. |
| 2693 | lastAssistant, err := tx.GetLastChatMessageByRole(ctx, database.GetLastChatMessageByRoleParams{ |
| 2694 | ChatID: opts.ChatID, |
| 2695 | Role: database.ChatMessageRoleAssistant, |
| 2696 | }) |
| 2697 | if err != nil { |
| 2698 | return xerrors.Errorf("get last assistant message: %w", err) |
| 2699 | } |
| 2700 | |
| 2701 | // Collect tool-call IDs that already have results. |
| 2702 | // When a dynamic tool name collides with a built-in, |
| 2703 | // the chatloop executes it as a built-in and persists |
| 2704 | // the result. Those calls must not count as pending. |
| 2705 | afterMsgs, afterErr := tx.GetChatMessagesByChatID(ctx, database.GetChatMessagesByChatIDParams{ |
| 2706 | ChatID: opts.ChatID, |
| 2707 | AfterID: lastAssistant.ID, |
| 2708 | }) |
| 2709 | if afterErr != nil { |
| 2710 | return xerrors.Errorf("get messages after assistant: %w", afterErr) |
| 2711 | } |
| 2712 | handledCallIDs := make(map[string]bool) |
| 2713 | for _, msg := range afterMsgs { |
| 2714 | if msg.Role != database.ChatMessageRoleTool { |
| 2715 | continue |
| 2716 | } |