From a5da18b710b94a68abe2f29581d48d6f4607d382 Mon Sep 17 00:00:00 2001 From: weichen Date: Sat, 10 Oct 2026 08:48:26 +0000 Subject: [PATCH] Inject the active tool call into the Execute context Why: With parallel tool execution the host-side tool wrapper can no longer infer which invocation it is serving from a single in-flight slot; tools need the call identity (ID/name/arguments) to pair process state and per-call bookkeeping. Context injection keeps the tools registry signature unchanged. Evidence: go test ./... green; TestLoopToolSeesOwnCallInContext runs two parallel calls and asserts each reads its own ID from ctx. Impact: Additive WithToolCall/ToolCallFromContext helpers; runOne now wraps the ctx before Execute. Callers that never inject keep ok=false and are unaffected. Refs: Dev task feature/agent-e1-parallel-fixes (F5 parallel wiring). --- runtime/loop.go | 17 +++++++++ runtime/loop_callctx_test.go | 69 ++++++++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+) create mode 100644 runtime/loop_callctx_test.go diff --git a/runtime/loop.go b/runtime/loop.go index c6f898e..72426ac 100644 --- a/runtime/loop.go +++ b/runtime/loop.go @@ -709,6 +709,22 @@ func noteToolHook(cfg Config, call llm.ToolCall, msg llm.Message) { cfg.Hooks.Emit(hooks.Call{Event: ev, Tool: call.Name, Args: call.Arguments, Result: msg.Content, IsError: msg.IsError}) } +// toolCallKey 是把当前工具调用注入 Execute ctx 的私有键:并行执行下工具实现 +// 需要知道自己服务于哪次调用(配对过程事件、按调用记账),经 +// ToolCallFromContext 读取;旧宿主/直接构造的 ctx 返回 ok=false。 +type toolCallKey struct{} + +// WithToolCall 把 call 挂进 ctx(内核 runOne 在执行前注入)。 +func WithToolCall(ctx context.Context, call llm.ToolCall) context.Context { + return context.WithValue(ctx, toolCallKey{}, call) +} + +// ToolCallFromContext 取出当前正在执行的调用;不存在时 ok=false。 +func ToolCallFromContext(ctx context.Context) (llm.ToolCall, bool) { + call, ok := ctx.Value(toolCallKey{}).(llm.ToolCall) + return call, ok +} + func runOne(ctx context.Context, cfg Config, call llm.ToolCall) llm.Message { if err := ctx.Err(); err != nil { return cancelledToolMessage(call, err) @@ -757,6 +773,7 @@ func runOne(ctx context.Context, cfg Config, call llm.ToolCall) llm.Message { defer endSpan() res := tools.Result{Content: "no tools configured", IsError: true} known := cfg.Tools != nil && cfg.Tools.Has(call.Name) + ctx = WithToolCall(ctx, call) switch { case known: res = cfg.Tools.Execute(ctx, call.Name, json.RawMessage(call.Arguments)) diff --git a/runtime/loop_callctx_test.go b/runtime/loop_callctx_test.go new file mode 100644 index 0000000..61849e6 --- /dev/null +++ b/runtime/loop_callctx_test.go @@ -0,0 +1,69 @@ +package runtime + +import ( + "context" + "encoding/json" + "sync" + "testing" + + "agent-native/runtime/llm" + "agent-native/runtime/tools" +) + +// ctxEchoTool 把自己从 ctx 读到的调用 ID 记下来(并发安全),验证内核 +// 在 Execute 前注入了当前调用。 +type ctxEchoTool struct { + mu sync.Mutex + ids []string + bad int +} + +func (c *ctxEchoTool) Name() string { return "echo" } +func (c *ctxEchoTool) Description() string { return "echo" } +func (c *ctxEchoTool) Parameters() json.RawMessage { return json.RawMessage(`{"type":"object"}`) } +func (c *ctxEchoTool) Execute(ctx context.Context, _ json.RawMessage) (tools.Result, error) { + call, ok := ToolCallFromContext(ctx) + c.mu.Lock() + defer c.mu.Unlock() + if !ok || call.ID == "" || call.Name != "echo" { + c.bad++ + return tools.Result{Content: "call missing from context", IsError: true}, nil + } + c.ids = append(c.ids, call.ID) + return tools.Result{Content: "ok " + call.ID}, nil +} + +// TestLoopToolSeesOwnCallInContext 并行执行两个调用,各自读到自己的 ID。 +func TestLoopToolSeesOwnCallInContext(t *testing.T) { + tool := &ctxEchoTool{} + reg := tools.NewRegistry(t.TempDir()) + reg.Register(tool) + res, err := Run(context.Background(), Config{ + Model: "fake", + Tools: reg, + ToolExecution: ExecParallel, + Stream: script( + llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{ + {ID: "pa", Name: "echo", Arguments: `{}`}, + {ID: "pb", Name: "echo", Arguments: `{}`}, + }}, + llm.Message{Role: llm.RoleAssistant, Content: "done", StopReason: llm.StopEnd}, + ), + }, llm.Message{Role: llm.RoleUser, Content: "go"}) + if err != nil { + t.Fatal(err) + } + tool.mu.Lock() + defer tool.mu.Unlock() + if tool.bad != 0 || len(tool.ids) != 2 { + t.Fatalf("bad=%d ids=%v", tool.bad, tool.ids) + } + seen := map[string]bool{} + for _, id := range tool.ids { + seen[id] = true + } + if !seen["pa"] || !seen["pb"] { + t.Fatalf("ids = %v", tool.ids) + } + _ = res +} -- 2.54.0