Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -968,6 +968,10 @@ func (s *Service) sendMessageInternal(
toolSpan.End()
toolCallRows = append(toolCallRows, toolResult.Rows...)
remainingToolCalls -= len(toolResult.Rows)
if toolResult.FatalErr != nil {
retErr = wrapUpstreamRequestError(toolResult.FatalErr)
return nil, retErr
}
if len(toolResult.ToolResults) == 0 {
break
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package conversation
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"

Expand Down Expand Up @@ -31,6 +32,7 @@ type executeAssistantToolCallsResult struct {
Rows []model.ToolCall
ToolResults []llm.ToolResult
ExecutedToolCalls []llm.ToolCall
FatalErr error
}

type toolExecutionRecord struct {
Expand Down Expand Up @@ -66,6 +68,7 @@ func (s *Service) executeAssistantToolCalls(ctx context.Context, input executeAs
}

slots := make([]toolExecutionSlot, len(toolCalls))
var fatalErr error
for i, item := range toolCalls {
modelToolName := strings.TrimSpace(item.ToolName)
executionToolName := resolveExecutionToolName(modelToolName, input.ToolNameMap)
Expand All @@ -81,6 +84,23 @@ func (s *Service) executeAssistantToolCalls(ctx context.Context, input executeAs
ErrorJSON: "",
}

mcpConfig := resolveMCPConfig(modelToolName, input.MCPConfigs)
if mcpConfig == nil {
row.Status = "error"
row.ErrorJSON = toolNotEnabledForRunMessage(modelToolName)
slots[i] = toolExecutionSlot{
row: row,
result: buildToolResultForModel(row, modelToolName),
}
if fatalErr == nil {
fatalErr = fmt.Errorf("model requested tool %q, but it is not enabled for this run", modelToolName)
}
if input.Ledger != nil {
input.Ledger.store(row.ToolName, row.InputJSON, toolExecutionRecord{row: row, result: slots[i].result})
}
continue
}

normalizedInput, validationErr := normalizeToolArguments(row.InputJSON, input.ToolSchemas[modelToolName])
if validationErr != nil {
row.Status = "error"
Expand Down Expand Up @@ -110,7 +130,7 @@ func (s *Service) executeAssistantToolCalls(ctx context.Context, input executeAs
RequestID: strings.TrimSpace(input.RequestID),
ToolName: row.ToolName,
ArgumentsJSON: row.InputJSON,
MCPConfig: resolveMCPConfig(modelToolName, input.MCPConfigs),
MCPConfig: mcpConfig,
})
row.LatencyMS = time.Since(toolStartedAt).Milliseconds()
if row.LatencyMS < 0 {
Expand Down Expand Up @@ -151,6 +171,7 @@ func (s *Service) executeAssistantToolCalls(ctx context.Context, input executeAs
Rows: rows,
ToolResults: toolResults,
ExecutedToolCalls: executedToolCalls,
FatalErr: fatalErr,
}
}

Expand Down Expand Up @@ -218,6 +239,14 @@ func buildToolResultForModel(row model.ToolCall, modelToolName string) llm.ToolR
}
}

func toolNotEnabledForRunMessage(toolName string) string {
name := strings.TrimSpace(toolName)
if name == "" {
return "tool is not enabled for this run"
}
return fmt.Sprintf("tool %s is not enabled for this run", name)
}

func budgetToolOutputForModel(raw string, maxChars int) string {
value := strings.TrimSpace(raw)
if value == "" || maxChars <= 0 || len([]rune(value)) <= maxChars {
Expand Down
25 changes: 25 additions & 0 deletions backend/internal/application/conversation/service_tool_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"testing"

"github.com/DEEIX-AI/DEEIX-Chat/backend/internal/infra/config"
"github.com/DEEIX-AI/DEEIX-Chat/backend/internal/infra/llm"
)

func TestExecuteToolCallRejectsToolsNotEnabledForRun(t *testing.T) {
Expand All @@ -19,6 +20,30 @@ func TestExecuteToolCallRejectsToolsNotEnabledForRun(t *testing.T) {
}
}

func TestExecuteAssistantToolCallsStopsWhenToolNotEnabledForRun(t *testing.T) {
svc := &Service{}
result := svc.executeAssistantToolCalls(context.Background(), executeAssistantToolCallsInput{
RunID: "run_1",
ToolCalls: []llm.ToolCall{{
ToolCallID: "toolu_1",
ToolType: "function",
ToolName: "web_search",
ArgumentsJSON: `{"query":"weather"}`,
Status: "requested",
}},
})

if result.FatalErr == nil || !strings.Contains(result.FatalErr.Error(), "not enabled for this run") {
t.Fatalf("expected fatal disabled tool error, got %v", result.FatalErr)
}
if len(result.Rows) != 1 || result.Rows[0].Status != "error" || result.Rows[0].ToolName != "web_search" {
t.Fatalf("expected one failed tool row, got %#v", result.Rows)
}
if len(result.ToolResults) != 1 || result.ToolResults[0].Status != "error" {
t.Fatalf("expected failed model tool result, got %#v", result.ToolResults)
}
}

func TestResolveMaxLLMCallsPerRunRequiresFollowUpRound(t *testing.T) {
svc := &Service{cfg: config.NewRuntime(config.Config{MCPMaxLLMCallsPerRun: 1})}
if got := svc.resolveMaxLLMCallsPerRun(); got != 2 {
Expand Down
Loading
Loading