WeKnora deployment source with the reviewed wiki identity, summary and recovery fixes
Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s
Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s
This commit is contained in:
@@ -0,0 +1,589 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
agenttools "github.com/Tencent/WeKnora/internal/agent/tools"
|
||||
"github.com/Tencent/WeKnora/internal/common"
|
||||
"github.com/Tencent/WeKnora/internal/event"
|
||||
"github.com/Tencent/WeKnora/internal/logger"
|
||||
"github.com/Tencent/WeKnora/internal/modelcontext"
|
||||
"github.com/Tencent/WeKnora/internal/tracing/langfuse"
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
"golang.org/x/sync/errgroup"
|
||||
)
|
||||
|
||||
// langfuseToolOutputPreview caps the Output field we send to Langfuse for a
|
||||
// tool call. Tool outputs are already truncated by the registry to
|
||||
// DefaultMaxToolOutput (16KB) before this point, but rendering 16KB in the
|
||||
// Langfuse UI for every tool call is noisy. We keep a generous slice so the
|
||||
// gist is preserved, and include the original length in metadata.
|
||||
const langfuseToolOutputPreview = 4000
|
||||
|
||||
// truncateForLangfuse returns s truncated to at most n runes, with a "…"
|
||||
// marker appended when truncated. Runes (not bytes) are used so multi-byte
|
||||
// CJK content is never split mid-character.
|
||||
func truncateForLangfuse(s string, n int) string {
|
||||
if n <= 0 || len(s) == 0 {
|
||||
return s
|
||||
}
|
||||
r := []rune(s)
|
||||
if len(r) <= n {
|
||||
return s
|
||||
}
|
||||
return string(r[:n]) + "…"
|
||||
}
|
||||
|
||||
// argKeys returns the sorted list of top-level keys in a tool's argument
|
||||
// map. Used when we choose not to send the raw arguments to Langfuse
|
||||
// (e.g. database_query's SQL) but still want to signal what was passed in.
|
||||
func argKeys(args map[string]any) []string {
|
||||
keys := make([]string, 0, len(args))
|
||||
for k := range args {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
|
||||
// traceArgumentValue keeps valid model-emitted JSON structured in Langfuse
|
||||
// while preserving malformed payloads verbatim for diagnosis.
|
||||
func traceArgumentValue(raw string) interface{} {
|
||||
var value interface{}
|
||||
if err := json.Unmarshal([]byte(raw), &value); err != nil {
|
||||
return raw
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// buildToolSpanInput exposes both sides of the model-context boundary:
|
||||
// model_arguments is exactly what the model emitted (including temporary
|
||||
// handles), while resolved_arguments is the durable payload actually executed.
|
||||
// Langfuse never performs mapping itself; it only observes the registry's audit.
|
||||
func buildToolSpanInput(tc types.LLMToolCall, resolvedArgs map[string]any, sensitive bool) map[string]interface{} {
|
||||
modelArguments := tc.ModelArguments
|
||||
if modelArguments == "" {
|
||||
modelArguments = tc.Function.Arguments
|
||||
}
|
||||
resolution := tc.ArgumentResolution
|
||||
if resolution == "" {
|
||||
resolution = modelcontext.ArgumentResolutionUnchanged
|
||||
}
|
||||
if sensitive {
|
||||
modelArgKeys := []string(nil)
|
||||
if parsed, ok := traceArgumentValue(modelArguments).(map[string]interface{}); ok {
|
||||
modelArgKeys = argKeys(parsed)
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"tool_call_id": tc.ID,
|
||||
"model_arg_keys": modelArgKeys,
|
||||
"resolved_arg_keys": argKeys(resolvedArgs),
|
||||
"argument_resolution": resolution,
|
||||
"unresolved_handle_count": len(tc.UnresolvedHandles),
|
||||
"args_redacted": true,
|
||||
}
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"tool_call_id": tc.ID,
|
||||
"model_arguments": traceArgumentValue(modelArguments),
|
||||
"resolved_arguments": resolvedArgs,
|
||||
"argument_resolution": resolution,
|
||||
"unresolved_handles": tc.UnresolvedHandles,
|
||||
}
|
||||
}
|
||||
|
||||
// finishToolSpan serialises a completed tool call into a Langfuse span
|
||||
// update. Extracted from runToolCall so the tool-call pipeline keeps
|
||||
// a single assignment per line and the observability-specific logic
|
||||
// (payload shaping, error classification) lives in one place.
|
||||
func finishToolSpan(span *langfuse.Span, tc types.ToolCall, execErr error, durationMs int64) {
|
||||
if span == nil {
|
||||
return
|
||||
}
|
||||
success := tc.Result != nil && tc.Result.Success
|
||||
output := map[string]interface{}{
|
||||
"success": success,
|
||||
"duration_ms": durationMs,
|
||||
}
|
||||
if tc.Result != nil {
|
||||
if tc.Result.Output != "" {
|
||||
output["output"] = truncateForLangfuse(tc.Result.Output, langfuseToolOutputPreview)
|
||||
output["output_len"] = len(tc.Result.Output)
|
||||
}
|
||||
if tc.Result.Error != "" {
|
||||
output["error"] = tc.Result.Error
|
||||
}
|
||||
if len(tc.Result.Data) > 0 {
|
||||
// Data is structured but can be arbitrarily large (e.g. full
|
||||
// search-result payloads). Only report key shape so Langfuse
|
||||
// users see what was surfaced without blowing up trace size.
|
||||
output["data_keys"] = dataKeys(tc.Result.Data)
|
||||
}
|
||||
if len(tc.Result.Images) > 0 {
|
||||
output["image_count"] = len(tc.Result.Images)
|
||||
}
|
||||
}
|
||||
// Classify the span's outcome: a non-nil execErr is always an error, and
|
||||
// a result with Success=false is treated as an error too (matches the
|
||||
// user-visible behaviour — the LLM would see this as a failed tool call
|
||||
// and try a different approach).
|
||||
var spanErr error
|
||||
switch {
|
||||
case execErr != nil:
|
||||
spanErr = execErr
|
||||
case tc.Result != nil && !tc.Result.Success:
|
||||
msg := tc.Result.Error
|
||||
if msg == "" {
|
||||
msg = "tool returned success=false"
|
||||
}
|
||||
spanErr = errors.New(msg)
|
||||
}
|
||||
span.Finish(output, map[string]interface{}{
|
||||
"success": success,
|
||||
"duration_ms": durationMs,
|
||||
}, spanErr)
|
||||
}
|
||||
|
||||
// dataKeys returns the sorted top-level keys of a tool's Data map.
|
||||
func dataKeys(data map[string]interface{}) []string {
|
||||
keys := make([]string, 0, len(data))
|
||||
for k := range data {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
|
||||
// toolDisplayNames maps internal tool names to user-friendly display labels.
|
||||
var toolDisplayNames = map[string]string{
|
||||
agenttools.ToolThinking: "深度思考",
|
||||
agenttools.ToolTodoWrite: "制定计划",
|
||||
agenttools.ToolGrepChunks: "关键词搜索",
|
||||
agenttools.ToolKnowledgeSearch: "知识搜索",
|
||||
agenttools.ToolListKnowledgeChunks: "查看文档分块",
|
||||
agenttools.ToolQueryKnowledgeGraph: "查询知识图谱",
|
||||
agenttools.ToolGetDocumentInfo: "获取文档信息",
|
||||
agenttools.ToolSearchConversations: "回顾历史对话",
|
||||
agenttools.ToolSearchMemory: "查询长期记忆",
|
||||
agenttools.ToolDatabaseQuery: "查询数据",
|
||||
agenttools.ToolDataAnalysis: "数据分析",
|
||||
agenttools.ToolDataSchema: "查看数据结构",
|
||||
agenttools.ToolWebSearch: "搜索网页",
|
||||
agenttools.ToolWebFetch: "获取网页",
|
||||
agenttools.ToolExecuteSkillScript: "执行技能脚本",
|
||||
agenttools.ToolReadSkill: "读取技能",
|
||||
agenttools.ToolListSandboxFiles: "列出沙箱文件",
|
||||
agenttools.ToolReadSandboxFile: "读取沙箱文件",
|
||||
agenttools.ToolWriteSandboxFile: "写入沙箱文件",
|
||||
agenttools.ToolEditSandboxFile: "编辑沙箱文件",
|
||||
agenttools.ToolShellExec: "执行沙箱命令",
|
||||
}
|
||||
|
||||
// toolHintSensitiveArgs lists tools whose arguments should NOT be shown in hints
|
||||
// (e.g., database_query exposes raw SQL which leaks implementation details).
|
||||
var toolHintSensitiveArgs = map[string]bool{
|
||||
agenttools.ToolDatabaseQuery: true,
|
||||
}
|
||||
|
||||
// formatToolHint returns a concise human-readable hint for a tool call, e.g. `搜索网页("query text")`.
|
||||
// Uses display names instead of internal tool names, and hides sensitive arguments.
|
||||
func formatToolHint(name string, args map[string]any) string {
|
||||
displayName := name
|
||||
if dn, ok := toolDisplayNames[name]; ok {
|
||||
displayName = dn
|
||||
}
|
||||
|
||||
if len(args) == 0 || toolHintSensitiveArgs[name] {
|
||||
return displayName
|
||||
}
|
||||
for _, v := range args {
|
||||
if s, ok := v.(string); ok {
|
||||
if len(s) > 40 {
|
||||
s = s[:40] + "…"
|
||||
}
|
||||
return fmt.Sprintf(`%s("%s")`, displayName, s)
|
||||
}
|
||||
}
|
||||
return displayName
|
||||
}
|
||||
|
||||
// executeToolCalls runs every tool call in the LLM response, appending results to step.ToolCalls.
|
||||
// It also emits tool-call and tool-result events, and optionally runs reflection after each call.
|
||||
// When ParallelToolCalls is enabled and there are 2+ tool calls, they execute concurrently.
|
||||
func (e *AgentEngine) executeToolCalls(
|
||||
ctx context.Context, response *types.ChatResponse,
|
||||
step *types.AgentStep, iteration int, sessionID, assistantMessageID string,
|
||||
) {
|
||||
if len(response.ToolCalls) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
round := iteration + 1
|
||||
n := len(response.ToolCalls)
|
||||
|
||||
// A completion-token cap cuts the response mid-serialization, so every call
|
||||
// in it may carry incomplete arguments. Running them is worse than failing
|
||||
// them: a truncated write_sandbox_file lands a half-written file and still
|
||||
// reports success, which the model only discovers by reading the file back.
|
||||
if isLengthFinishReason(response.FinishReason) {
|
||||
logger.Warnf(ctx, "[Agent][Round-%d] Response hit the completion-token cap (finish=%s); "+
|
||||
"refusing %d tool call(s) with possibly truncated arguments",
|
||||
round, response.FinishReason, n)
|
||||
e.failTruncatedToolCalls(ctx, response, step, iteration, sessionID)
|
||||
return
|
||||
}
|
||||
|
||||
logger.Infof(ctx, "[Agent][Round-%d] Executing %d tool call(s)", round, n)
|
||||
|
||||
// Use parallel execution when enabled and there are multiple tool calls
|
||||
if e.config.ParallelToolCalls && n >= 2 {
|
||||
e.executeToolCallsParallel(ctx, response, step, iteration, sessionID, assistantMessageID)
|
||||
return
|
||||
}
|
||||
|
||||
for i, tc := range response.ToolCalls {
|
||||
e.executeSingleToolCall(ctx, tc, i, step, iteration, round, sessionID, assistantMessageID)
|
||||
}
|
||||
}
|
||||
|
||||
// truncatedArgumentsError is handed to the model instead of a tool result when
|
||||
// the arguments were cut off. It stays tool-neutral: any tool can be the one
|
||||
// that got truncated, and naming another tool's fields would send the model
|
||||
// chasing arguments the failing call does not have.
|
||||
const truncatedArgumentsError = "Tool call was not executed: the model output was cut off " +
|
||||
"before the arguments finished, so they are incomplete rather than wrong. " +
|
||||
"Re-issue the call with a complete JSON object. If the payload is large, " +
|
||||
"split it across several smaller calls."
|
||||
|
||||
// failTruncatedToolCalls records every call in a truncated response as failed
|
||||
// without running any of them, emitting the same events a real execution would
|
||||
// so the UI and the transcript stay consistent.
|
||||
func (e *AgentEngine) failTruncatedToolCalls(
|
||||
ctx context.Context, response *types.ChatResponse,
|
||||
step *types.AgentStep, iteration int, sessionID string,
|
||||
) {
|
||||
for i, tc := range response.ToolCalls {
|
||||
toolCall := types.ToolCall{
|
||||
ID: agenttools.NormalizeToolCallID(tc.ID, tc.Function.Name, i),
|
||||
Name: tc.Function.Name,
|
||||
Args: map[string]any{"_raw": tc.Function.Arguments},
|
||||
ProviderMetadata: tc.ProviderMetadata,
|
||||
Result: &types.ToolResult{Success: false, Error: truncatedArgumentsError},
|
||||
}
|
||||
step.ToolCalls = append(step.ToolCalls, toolCall)
|
||||
e.emitToolOutcome(ctx, toolCall, iteration, sessionID)
|
||||
}
|
||||
}
|
||||
|
||||
// executeToolCallsParallel runs all tool calls concurrently using errgroup,
|
||||
// collecting results in original order.
|
||||
func (e *AgentEngine) executeToolCallsParallel(
|
||||
ctx context.Context, response *types.ChatResponse,
|
||||
step *types.AgentStep, iteration int, sessionID, assistantMessageID string,
|
||||
) {
|
||||
round := iteration + 1
|
||||
n := len(response.ToolCalls)
|
||||
logger.Infof(ctx, "[Agent][Round-%d] Parallel execution of %d tool calls", round, n)
|
||||
|
||||
results := make([]types.ToolCall, n)
|
||||
var mu sync.Mutex
|
||||
g, gCtx := errgroup.WithContext(ctx)
|
||||
|
||||
for i, tc := range response.ToolCalls {
|
||||
i, tc := i, tc // capture loop vars
|
||||
g.Go(func() error {
|
||||
toolCall := e.runToolCall(gCtx, tc, i, iteration, round, sessionID, assistantMessageID)
|
||||
mu.Lock()
|
||||
results[i] = toolCall
|
||||
mu.Unlock()
|
||||
return nil // best-effort: don't cancel siblings on failure
|
||||
})
|
||||
}
|
||||
|
||||
_ = g.Wait()
|
||||
|
||||
// Append results and emit events in original order
|
||||
for _, toolCall := range results {
|
||||
step.ToolCalls = append(step.ToolCalls, toolCall)
|
||||
e.emitToolOutcome(ctx, toolCall, iteration, sessionID)
|
||||
}
|
||||
}
|
||||
|
||||
// emitToolOutcome publishes the result and action events for one finished tool
|
||||
// call. Every path that produces a ToolCall goes through here — sequential,
|
||||
// parallel, and refused-as-truncated — so the UI sees one event shape.
|
||||
func (e *AgentEngine) emitToolOutcome(
|
||||
ctx context.Context, toolCall types.ToolCall, iteration int, sessionID string,
|
||||
) {
|
||||
result := toolCall.Result
|
||||
if result == nil {
|
||||
result = &types.ToolResult{Success: false, Error: "no result"}
|
||||
}
|
||||
|
||||
e.eventBus.Emit(ctx, event.Event{
|
||||
ID: toolCall.ID + "-tool-result",
|
||||
Type: event.EventAgentToolResult,
|
||||
SessionID: sessionID,
|
||||
Data: event.AgentToolResultData{
|
||||
ToolCallID: toolCall.ID,
|
||||
ToolName: toolCall.Name,
|
||||
Output: result.Output,
|
||||
Error: result.Error,
|
||||
Success: result.Success,
|
||||
Duration: toolCall.Duration,
|
||||
Iteration: iteration,
|
||||
Data: agenttools.SanitizeToolDataForPersist(toolCall.Name, result.Data),
|
||||
},
|
||||
})
|
||||
|
||||
e.eventBus.Emit(ctx, event.Event{
|
||||
ID: toolCall.ID + "-tool-exec",
|
||||
Type: event.EventAgentTool,
|
||||
SessionID: sessionID,
|
||||
Data: event.AgentActionData{
|
||||
Iteration: iteration,
|
||||
ToolName: toolCall.Name,
|
||||
ToolInput: toolCall.Args,
|
||||
ToolOutput: result.Output,
|
||||
Success: result.Success,
|
||||
Error: result.Error,
|
||||
Duration: toolCall.Duration,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// executeSingleToolCall runs one tool call sequentially (original behavior).
|
||||
func (e *AgentEngine) executeSingleToolCall(
|
||||
ctx context.Context, tc types.LLMToolCall, i int,
|
||||
step *types.AgentStep, iteration, round int, sessionID, assistantMessageID string,
|
||||
) {
|
||||
toolCall := e.runToolCall(ctx, tc, i, iteration, round, sessionID, assistantMessageID)
|
||||
step.ToolCalls = append(step.ToolCalls, toolCall)
|
||||
e.emitToolOutcome(ctx, toolCall, iteration, sessionID)
|
||||
}
|
||||
|
||||
// runToolCall handles argument parsing, execution, logging, and pipeline events for a single tool call.
|
||||
// It returns the completed ToolCall struct. Safe to call from multiple goroutines.
|
||||
func (e *AgentEngine) runToolCall(
|
||||
ctx context.Context, tc types.LLMToolCall, i int,
|
||||
iteration, round int, sessionID, assistantMessageID string,
|
||||
) types.ToolCall {
|
||||
tc.ID = agenttools.NormalizeToolCallID(tc.ID, tc.Function.Name, i)
|
||||
total := "?" // unknown in isolation; callers log the batch size
|
||||
toolTag := fmt.Sprintf("[Agent][Round-%d][Tool %s (%d/%s)]",
|
||||
round, tc.Function.Name, i+1, total)
|
||||
|
||||
var args map[string]any
|
||||
argsStr := tc.Function.Arguments
|
||||
if err := json.Unmarshal([]byte(argsStr), &args); err != nil {
|
||||
repaired, truncated := agenttools.RepairJSONDetail(argsStr)
|
||||
if repairErr := json.Unmarshal([]byte(repaired), &args); repairErr != nil {
|
||||
logger.Errorf(ctx, "%s Failed to parse arguments (repair failed): %v", toolTag, err)
|
||||
return types.ToolCall{
|
||||
ID: tc.ID,
|
||||
Name: tc.Function.Name,
|
||||
Args: map[string]any{"_raw": argsStr},
|
||||
ProviderMetadata: tc.ProviderMetadata,
|
||||
Result: &types.ToolResult{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf(
|
||||
"Failed to parse tool arguments: %v", err,
|
||||
) + "\n\nIf the JSON looks cut off, the previous round likely hit the output token cap. " +
|
||||
"Retry with complete JSON (required fields first) and a smaller payload.\n\n" +
|
||||
"[Analyze the error above and try a different approach.]",
|
||||
},
|
||||
}
|
||||
}
|
||||
// Closing off an unterminated string or bracket makes the payload
|
||||
// parse, but the values inside are still the partial ones the provider
|
||||
// managed to emit. Executing that writes half a file or searches half a
|
||||
// query, and the tool reports success either way — so refuse instead.
|
||||
// This is the belt for streams that break without a finish reason,
|
||||
// where the length check in executeToolCalls has nothing to match on.
|
||||
if truncated {
|
||||
logger.Warnf(ctx, "%s Arguments were cut off mid-emission (%d bytes); refusing to execute",
|
||||
toolTag, len(argsStr))
|
||||
return types.ToolCall{
|
||||
ID: tc.ID,
|
||||
Name: tc.Function.Name,
|
||||
Args: map[string]any{"_raw": argsStr},
|
||||
ProviderMetadata: tc.ProviderMetadata,
|
||||
Result: &types.ToolResult{Success: false, Error: truncatedArgumentsError},
|
||||
}
|
||||
}
|
||||
logger.Warnf(ctx, "%s Repaired malformed JSON arguments", toolTag)
|
||||
// The initial model-context pass could not inspect malformed JSON.
|
||||
// Decode the repaired payload before execution, while preserving the
|
||||
// exact provider payload already stored in tc.ModelArguments.
|
||||
decoded := tc
|
||||
decoded.ModelArguments = ""
|
||||
decoded.Function.Arguments = repaired
|
||||
decodedCalls := []types.LLMToolCall{decoded}
|
||||
e.modelContext.DecodeToolCalls(decodedCalls)
|
||||
tc.Function.Arguments = decodedCalls[0].Function.Arguments
|
||||
tc.ArgumentResolution = decodedCalls[0].ArgumentResolution
|
||||
tc.UnresolvedHandles = decodedCalls[0].UnresolvedHandles
|
||||
if err := json.Unmarshal([]byte(tc.Function.Arguments), &args); err != nil {
|
||||
return types.ToolCall{
|
||||
ID: tc.ID,
|
||||
Name: tc.Function.Name,
|
||||
Args: map[string]any{"_raw": tc.Function.Arguments},
|
||||
ProviderMetadata: tc.ProviderMetadata,
|
||||
Result: &types.ToolResult{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("Failed to parse repaired tool arguments: %v", err),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.Debugf(ctx, "%s Args: %s", toolTag, tc.Function.Arguments)
|
||||
|
||||
toolCallStartTime := time.Now()
|
||||
|
||||
// Emit tool hint for UI progress display
|
||||
toolHint := formatToolHint(tc.Function.Name, args)
|
||||
e.eventBus.Emit(ctx, event.Event{
|
||||
ID: tc.ID + "-tool-hint",
|
||||
Type: event.EventAgentToolCall,
|
||||
SessionID: sessionID,
|
||||
Data: event.AgentToolCallData{
|
||||
ToolCallID: tc.ID,
|
||||
ToolName: tc.Function.Name,
|
||||
Arguments: agenttools.SanitizeSandboxFileCallArgs(tc.Function.Name, args),
|
||||
Iteration: iteration,
|
||||
Hint: toolHint,
|
||||
},
|
||||
})
|
||||
|
||||
common.PipelineInfo(ctx, "Agent", "tool_call_start", map[string]interface{}{
|
||||
"iteration": iteration,
|
||||
"round": round,
|
||||
"tool": tc.Function.Name,
|
||||
"tool_call_id": tc.ID,
|
||||
"tool_index": fmt.Sprintf("%d/%s", i+1, total),
|
||||
})
|
||||
|
||||
// Open a Langfuse span for the tool invocation so the Langfuse UI shows
|
||||
// trace → agent.execute → agent.round.N → agent.tool.<name>, alongside
|
||||
// any nested generations (embedding/rerank/VLM) that the tool itself
|
||||
// triggers. No-op when Langfuse is disabled.
|
||||
mgr := langfuse.GetManager()
|
||||
// database_query's SQL is treated as sensitive by the UI hint layer
|
||||
// (toolHintSensitiveArgs) because it exposes implementation details.
|
||||
// Mirror that policy for Langfuse: redact raw arguments to avoid
|
||||
// leaking raw SQL into the observability backend.
|
||||
toolSpanInput := buildToolSpanInput(tc, args, toolHintSensitiveArgs[tc.Function.Name])
|
||||
argumentResolution, _ := toolSpanInput["argument_resolution"].(string)
|
||||
toolCtx, toolSpan := mgr.StartSpan(ctx, langfuse.SpanOptions{
|
||||
Name: "agent.tool." + tc.Function.Name,
|
||||
Input: toolSpanInput,
|
||||
Metadata: map[string]interface{}{
|
||||
"iteration": iteration,
|
||||
"round": round,
|
||||
"tool_index": i + 1,
|
||||
"tool_call_id": tc.ID,
|
||||
"session_id": sessionID,
|
||||
"argument_resolution": argumentResolution,
|
||||
"unresolved_handle_count": len(tc.UnresolvedHandles),
|
||||
},
|
||||
})
|
||||
|
||||
principal, _ := types.PrincipalFromContext(ctx)
|
||||
execTimeout := toolExecutionTimeout(tc.Function.Name)
|
||||
toolExecCtx := agenttools.WithToolExecContext(toolCtx, &agenttools.ToolExecContext{
|
||||
SessionID: sessionID,
|
||||
AssistantMessageID: assistantMessageID,
|
||||
EventBus: e.eventBus,
|
||||
ToolCallID: tc.ID,
|
||||
UserID: principal.StorageID(),
|
||||
// ApprovalCtx keeps the round-level ctx without the per-tool execution timeout,
|
||||
// so MCP tool human-approval (issue #1173) can legitimately block longer.
|
||||
ApprovalCtx: toolCtx,
|
||||
ExecTimeout: execTimeout,
|
||||
})
|
||||
|
||||
var result *types.ToolResult
|
||||
var err error
|
||||
if len(tc.UnresolvedHandles) > 0 {
|
||||
// A temporary handle is not an application identity. Fail before tool
|
||||
// execution so a hallucinated/stale cN/dN/bN/wN/iN/res:// token can
|
||||
// never reach persistence, an external service, or a routing decision.
|
||||
err = fmt.Errorf("tool arguments contain unresolved model handles: %v", tc.UnresolvedHandles)
|
||||
} else {
|
||||
execCtx, toolCancel := context.WithTimeout(toolExecCtx, execTimeout)
|
||||
result, err = e.toolRegistry.ExecuteTool(
|
||||
execCtx, tc.Function.Name,
|
||||
json.RawMessage(tc.Function.Arguments),
|
||||
)
|
||||
toolCancel()
|
||||
}
|
||||
duration := time.Since(toolCallStartTime).Milliseconds()
|
||||
|
||||
toolCall := types.ToolCall{
|
||||
ID: tc.ID,
|
||||
Name: tc.Function.Name,
|
||||
Args: args,
|
||||
Result: result,
|
||||
Duration: duration,
|
||||
ProviderMetadata: tc.ProviderMetadata,
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
logger.Errorf(ctx, "%s Failed in %dms: %v", toolTag, duration, err)
|
||||
toolCall.Result = &types.ToolResult{
|
||||
Success: false,
|
||||
Error: err.Error(),
|
||||
}
|
||||
} else {
|
||||
success := result != nil && result.Success
|
||||
outputLen := 0
|
||||
if result != nil {
|
||||
outputLen = len(result.Output)
|
||||
}
|
||||
logger.Infof(ctx, "%s Completed in %dms: success=%v, output=%d chars",
|
||||
toolTag, duration, success, outputLen)
|
||||
}
|
||||
|
||||
finishToolSpan(toolSpan, toolCall, err, duration)
|
||||
|
||||
// Pipeline event for monitoring
|
||||
toolSuccess := toolCall.Result != nil && toolCall.Result.Success
|
||||
pipelineFields := map[string]interface{}{
|
||||
"iteration": iteration,
|
||||
"round": round,
|
||||
"tool": tc.Function.Name,
|
||||
"tool_call_id": tc.ID,
|
||||
"duration_ms": duration,
|
||||
"success": toolSuccess,
|
||||
}
|
||||
if toolCall.Result != nil && toolCall.Result.Error != "" {
|
||||
pipelineFields["error"] = toolCall.Result.Error
|
||||
}
|
||||
if err != nil {
|
||||
common.PipelineError(ctx, "Agent", "tool_call_result", pipelineFields)
|
||||
} else if toolSuccess {
|
||||
common.PipelineInfo(ctx, "Agent", "tool_call_result", pipelineFields)
|
||||
} else {
|
||||
common.PipelineWarn(ctx, "Agent", "tool_call_result", pipelineFields)
|
||||
}
|
||||
|
||||
if toolCall.Result != nil && toolCall.Result.Output != "" {
|
||||
preview := toolCall.Result.Output
|
||||
if len(preview) > 500 {
|
||||
preview = preview[:500] + "... (truncated)"
|
||||
}
|
||||
logger.Debugf(ctx, "%s Output preview:\n%s", toolTag, preview)
|
||||
}
|
||||
if toolCall.Result != nil && toolCall.Result.Error != "" {
|
||||
logger.Debugf(ctx, "%s Tool error: %s", toolTag, toolCall.Result.Error)
|
||||
}
|
||||
|
||||
return toolCall
|
||||
}
|
||||
Reference in New Issue
Block a user