Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cff69355f7 |
@@ -475,6 +475,9 @@ func (b *Backend) run(ctx context.Context, model string, budget *runtime.Budget,
|
|||||||
Text: ev.Text,
|
Text: ev.Text,
|
||||||
Tool: ev.Tool,
|
Tool: ev.Tool,
|
||||||
Detail: ev.Detail,
|
Detail: ev.Detail,
|
||||||
|
CallID: ev.CallID,
|
||||||
|
Status: ev.Status,
|
||||||
|
ErrorReason: ev.ErrorReason,
|
||||||
PromptTokens: ev.PromptTokens,
|
PromptTokens: ev.PromptTokens,
|
||||||
CompletionTokens: ev.CompletionTokens,
|
CompletionTokens: ev.CompletionTokens,
|
||||||
LastPromptTokens: ev.LastPromptTokens,
|
LastPromptTokens: ev.LastPromptTokens,
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
package native
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"agent-native/exec"
|
||||||
|
"agent-native/runtime/llm"
|
||||||
|
"agent-native/runtime/tools"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fidelityEcho struct {
|
||||||
|
fail bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f fidelityEcho) Name() string { return "echo" }
|
||||||
|
func (f fidelityEcho) Description() string { return "echo" }
|
||||||
|
func (f fidelityEcho) Parameters() json.RawMessage { return json.RawMessage(`{"type":"object"}`) }
|
||||||
|
func (f fidelityEcho) Execute(context.Context, json.RawMessage) (tools.Result, error) {
|
||||||
|
if f.fail {
|
||||||
|
return tools.Result{Content: "native-boom", IsError: true}, nil
|
||||||
|
}
|
||||||
|
return tools.Result{Content: "ok"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNativeProcessEventPassesCallIDAndStatus(t *testing.T) {
|
||||||
|
b := New(Options{
|
||||||
|
ModelOptions: ModelOptions{
|
||||||
|
Model: "fake",
|
||||||
|
Stream: script(
|
||||||
|
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{
|
||||||
|
{ID: "n-a", Name: "echo", Arguments: `{}`},
|
||||||
|
{ID: "n-b", Name: "echo", Arguments: `{}`},
|
||||||
|
}},
|
||||||
|
llm.Message{Role: llm.RoleAssistant, Content: "done", StopReason: llm.StopEnd},
|
||||||
|
),
|
||||||
|
},
|
||||||
|
ToolOptions: ToolOptions{
|
||||||
|
HostTools: []tools.Tool{fidelityEcho{}},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
var starts, ends []exec.ProcessEvent
|
||||||
|
_, err := b.Execute(context.Background(), exec.Request{
|
||||||
|
WorkDir: t.TempDir(), Prompt: "go", Targets: []string{"output.md"},
|
||||||
|
OnEvent: func(ev exec.ProcessEvent) {
|
||||||
|
switch ev.Type {
|
||||||
|
case "tool_execution_start":
|
||||||
|
starts = append(starts, ev)
|
||||||
|
case "tool_execution_end":
|
||||||
|
ends = append(ends, ev)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(starts) != 2 || len(ends) != 2 {
|
||||||
|
t.Fatalf("starts=%d ends=%d", len(starts), len(ends))
|
||||||
|
}
|
||||||
|
ids := map[string]bool{}
|
||||||
|
for _, ev := range starts {
|
||||||
|
if ev.CallID == "" {
|
||||||
|
t.Fatalf("start missing CallID: %+v", ev)
|
||||||
|
}
|
||||||
|
ids[ev.CallID] = true
|
||||||
|
}
|
||||||
|
if !ids["n-a"] || !ids["n-b"] {
|
||||||
|
t.Fatalf("expected distinct CallIDs n-a/n-b, got %v", ids)
|
||||||
|
}
|
||||||
|
for _, ev := range ends {
|
||||||
|
if ev.CallID == "" || !ids[ev.CallID] {
|
||||||
|
t.Fatalf("end CallID unpaired: %+v", ev)
|
||||||
|
}
|
||||||
|
if ev.Status != "ok" {
|
||||||
|
t.Fatalf("end Status=%q want ok: %+v", ev.Status, ev)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNativeProcessEventFailingToolStatusError(t *testing.T) {
|
||||||
|
b := New(Options{
|
||||||
|
ModelOptions: ModelOptions{
|
||||||
|
Model: "fake",
|
||||||
|
Stream: script(
|
||||||
|
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
|
||||||
|
ID: "n-fail", Name: "echo", Arguments: `{}`,
|
||||||
|
}}},
|
||||||
|
llm.Message{Role: llm.RoleAssistant, Content: "done", StopReason: llm.StopEnd},
|
||||||
|
),
|
||||||
|
},
|
||||||
|
ToolOptions: ToolOptions{
|
||||||
|
HostTools: []tools.Tool{fidelityEcho{fail: true}},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
var end exec.ProcessEvent
|
||||||
|
_, err := b.Execute(context.Background(), exec.Request{
|
||||||
|
WorkDir: t.TempDir(), Prompt: "go", Targets: []string{"output.md"},
|
||||||
|
OnEvent: func(ev exec.ProcessEvent) {
|
||||||
|
if ev.Type == "tool_execution_end" {
|
||||||
|
end = ev
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if end.CallID != "n-fail" || end.Status != "error" || end.ErrorReason != "native-boom" {
|
||||||
|
t.Fatalf("end = %+v", end)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -54,6 +54,14 @@ type ProcessEvent struct {
|
|||||||
Text string // delta 文本(Type 为 *_delta 时)
|
Text string // delta 文本(Type 为 *_delta 时)
|
||||||
Tool string // 工具名(tool_* 时)
|
Tool string // 工具名(tool_* 时)
|
||||||
Detail string // 工具参数/结果摘要(tool_execution_* 时)
|
Detail string // 工具参数/结果摘要(tool_execution_* 时)
|
||||||
|
// CallID 是提供方 tool-call id;在 tool_call_delta / tool_execution_start /
|
||||||
|
// tool_execution_end 时填写。零值 = 旧行为(消费方仍可按 Tool 名启发式配对)。
|
||||||
|
CallID string `json:"call_id,omitempty"`
|
||||||
|
// Status 在 tool_execution_end 时填写:ok | error | denied | canceled。
|
||||||
|
// 空串 = legacy/unknown,不得臆造。
|
||||||
|
Status string `json:"status,omitempty"`
|
||||||
|
// ErrorReason 在 Status 为 error/denied/canceled 时携带真实失败文本。
|
||||||
|
ErrorReason string `json:"error_reason,omitempty"`
|
||||||
// PromptTokens / CompletionTokens 在 Type=usage 时填写。
|
// PromptTokens / CompletionTokens 在 Type=usage 时填写。
|
||||||
PromptTokens int
|
PromptTokens int
|
||||||
CompletionTokens int
|
CompletionTokens int
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package exec
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestProcessEventLegacyOmitempty:零值新字段不出现在 JSON 中,旧消费方
|
||||||
|
// 忽略未知字段时看到的遗留载荷与仅填 Type/Tool/Text/Detail 一致。
|
||||||
|
func TestProcessEventLegacyOmitempty(t *testing.T) {
|
||||||
|
legacy := ProcessEvent{Type: "tool_execution_end", Tool: "echo", Text: "ran", Detail: "{}"}
|
||||||
|
withZeros := ProcessEvent{
|
||||||
|
Type: "tool_execution_end", Tool: "echo", Text: "ran", Detail: "{}",
|
||||||
|
CallID: "", Status: "", ErrorReason: "",
|
||||||
|
}
|
||||||
|
a, err := json.Marshal(legacy)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
b, err := json.Marshal(withZeros)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if string(a) != string(b) {
|
||||||
|
t.Fatalf("zero new fields changed JSON:\n legacy=%s\n zeros=%s", a, b)
|
||||||
|
}
|
||||||
|
if strings.Contains(string(a), "call_id") || strings.Contains(string(a), "status") ||
|
||||||
|
strings.Contains(string(a), "error_reason") {
|
||||||
|
t.Fatalf("omitempty leaked zero fields: %s", a)
|
||||||
|
}
|
||||||
|
|
||||||
|
populated := ProcessEvent{
|
||||||
|
Type: "tool_execution_end", Tool: "echo", Text: "boom",
|
||||||
|
CallID: "c1", Status: "error", ErrorReason: "boom",
|
||||||
|
}
|
||||||
|
raw, err := json.Marshal(populated)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
s := string(raw)
|
||||||
|
for _, want := range []string{`"call_id":"c1"`, `"status":"error"`, `"error_reason":"boom"`} {
|
||||||
|
if !strings.Contains(s, want) {
|
||||||
|
t.Fatalf("missing %s in %s", want, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+48
-8
@@ -43,7 +43,13 @@ type Event struct {
|
|||||||
ToolIndex int
|
ToolIndex int
|
||||||
// CallID 在 tool_execution_start / tool_execution_end 时携带工具调用的
|
// CallID 在 tool_execution_start / tool_execution_end 时携带工具调用的
|
||||||
// ID;tool_call_delta 也携带同一 ID。消费方按 ID 配对而非按顺序。
|
// ID;tool_call_delta 也携带同一 ID。消费方按 ID 配对而非按顺序。
|
||||||
|
// 不得从 ToolIndex 反推;ToolIndex 语义不变。
|
||||||
CallID string
|
CallID string
|
||||||
|
// Status 在 tool_execution_end 时填写:ok | error | denied | canceled。
|
||||||
|
// 空串 = legacy/unknown,不得臆造。
|
||||||
|
Status string
|
||||||
|
// ErrorReason 在 Status 为 error/denied/canceled 时携带真实失败文本。
|
||||||
|
ErrorReason string
|
||||||
// Detail carries tool arguments/results or reasoning deltas for
|
// Detail carries tool arguments/results or reasoning deltas for
|
||||||
// process-visualization consumers; log writers may ignore it.
|
// process-visualization consumers; log writers may ignore it.
|
||||||
Detail string
|
Detail string
|
||||||
@@ -598,6 +604,9 @@ func appendSkippedToolResults(cfg Config, calls []llm.ToolCall, messages []llm.M
|
|||||||
}
|
}
|
||||||
|
|
||||||
// serializeHostCallbacks 让同一次 Run 的宿主回调不会并发进入。
|
// serializeHostCallbacks 让同一次 Run 的宿主回调不会并发进入。
|
||||||
|
// 并行工具执行下各 goroutine 各自构造 Event 值(无跨调用共享缓冲);
|
||||||
|
// 此处再串行化 OnEvent,避免消费方在回调内聚合时字段交错。回调须保持
|
||||||
|
// 同步、非阻塞(与 Config.OnEvent 文档一致)。
|
||||||
func serializeHostCallbacks(cfg *Config) {
|
func serializeHostCallbacks(cfg *Config) {
|
||||||
var mu sync.Mutex
|
var mu sync.Mutex
|
||||||
if cfg.OnEvent != nil {
|
if cfg.OnEvent != nil {
|
||||||
@@ -636,7 +645,9 @@ func executeTools(ctx context.Context, cfg Config, assistant llm.Message) []llm.
|
|||||||
out := make([]llm.Message, 0, len(assistant.ToolCalls))
|
out := make([]llm.Message, 0, len(assistant.ToolCalls))
|
||||||
for _, call := range assistant.ToolCalls {
|
for _, call := range assistant.ToolCalls {
|
||||||
if err := ctx.Err(); err != nil {
|
if err := ctx.Err(); err != nil {
|
||||||
out = append(out, cancelledToolMessage(call, err))
|
msg := cancelledToolMessage(call, err)
|
||||||
|
emitCancelledTool(cfg, call, msg)
|
||||||
|
out = append(out, msg)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
out = append(out, runOne(ctx, cfg, call))
|
out = append(out, runOne(ctx, cfg, call))
|
||||||
@@ -664,7 +675,10 @@ func executeMixed(ctx context.Context, cfg Config, assistant llm.Message) []llm.
|
|||||||
}
|
}
|
||||||
for _, i := range serialIdx {
|
for _, i := range serialIdx {
|
||||||
if err := ctx.Err(); err != nil {
|
if err := ctx.Err(); err != nil {
|
||||||
out[i] = cancelledToolMessage(assistant.ToolCalls[i], err)
|
call := assistant.ToolCalls[i]
|
||||||
|
msg := cancelledToolMessage(call, err)
|
||||||
|
emitCancelledTool(cfg, call, msg)
|
||||||
|
out[i] = msg
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
out[i] = runOne(ctx, cfg, assistant.ToolCalls[i])
|
out[i] = runOne(ctx, cfg, assistant.ToolCalls[i])
|
||||||
@@ -697,6 +711,22 @@ func cancelledToolMessage(call llm.ToolCall, err error) llm.Message {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// emitCancelledTool 发出未执行/中止的 start+end;Status=canceled,不得报 ok。
|
||||||
|
func emitCancelledTool(cfg Config, call llm.ToolCall, msg llm.Message) {
|
||||||
|
emit(cfg, Event{Type: "tool_execution_start", Tool: call.Name, CallID: call.ID, Detail: call.Arguments})
|
||||||
|
emitToolEnd(cfg, call, msg.Content, "canceled")
|
||||||
|
}
|
||||||
|
|
||||||
|
// emitToolEnd 投影 tool_execution_end:CallID 取自 call,Status/ErrorReason
|
||||||
|
// 仅在已知时填写(空 Status = legacy/unknown)。
|
||||||
|
func emitToolEnd(cfg Config, call llm.ToolCall, text, status string) {
|
||||||
|
ev := Event{Type: "tool_execution_end", Tool: call.Name, CallID: call.ID, Text: text, Status: status}
|
||||||
|
if status == "error" || status == "denied" || status == "canceled" {
|
||||||
|
ev.ErrorReason = text
|
||||||
|
}
|
||||||
|
emit(cfg, ev)
|
||||||
|
}
|
||||||
|
|
||||||
func rejectIncompleteToolCall(call llm.ToolCall) string {
|
func rejectIncompleteToolCall(call llm.ToolCall) string {
|
||||||
if strings.TrimSpace(call.Name) == "" {
|
if strings.TrimSpace(call.Name) == "" {
|
||||||
return "工具调用未执行:名称不完整"
|
return "工具调用未执行:名称不完整"
|
||||||
@@ -790,12 +820,14 @@ func ToolCallFromContext(ctx context.Context) (llm.ToolCall, bool) {
|
|||||||
|
|
||||||
func runOne(ctx context.Context, cfg Config, call llm.ToolCall) llm.Message {
|
func runOne(ctx context.Context, cfg Config, call llm.ToolCall) llm.Message {
|
||||||
if err := ctx.Err(); err != nil {
|
if err := ctx.Err(); err != nil {
|
||||||
return cancelledToolMessage(call, err)
|
msg := cancelledToolMessage(call, err)
|
||||||
|
emitCancelledTool(cfg, call, msg)
|
||||||
|
return msg
|
||||||
}
|
}
|
||||||
if why := rejectIncompleteToolCall(call); why != "" {
|
if why := rejectIncompleteToolCall(call); why != "" {
|
||||||
msg := llm.Message{Role: llm.RoleTool, Name: call.Name, ToolCallID: call.ID, Content: why, IsError: true}
|
msg := llm.Message{Role: llm.RoleTool, Name: call.Name, ToolCallID: call.ID, Content: why, IsError: true}
|
||||||
emit(cfg, Event{Type: "tool_execution_start", Tool: call.Name, CallID: call.ID, Detail: call.Arguments})
|
emit(cfg, Event{Type: "tool_execution_start", Tool: call.Name, CallID: call.ID, Detail: call.Arguments})
|
||||||
emit(cfg, Event{Type: "tool_execution_end", Tool: call.Name, CallID: call.ID, Text: why})
|
emitToolEnd(cfg, call, why, "error")
|
||||||
if cfg.AfterTool != nil {
|
if cfg.AfterTool != nil {
|
||||||
cfg.AfterTool(call, msg)
|
cfg.AfterTool(call, msg)
|
||||||
}
|
}
|
||||||
@@ -803,7 +835,7 @@ func runOne(ctx context.Context, cfg Config, call llm.ToolCall) llm.Message {
|
|||||||
}
|
}
|
||||||
emit(cfg, Event{Type: "tool_execution_start", Tool: call.Name, CallID: call.ID, Detail: call.Arguments})
|
emit(cfg, Event{Type: "tool_execution_start", Tool: call.Name, CallID: call.ID, Detail: call.Arguments})
|
||||||
if gated, msg, blocked := gateTool(ctx, cfg, call); blocked {
|
if gated, msg, blocked := gateTool(ctx, cfg, call); blocked {
|
||||||
emit(cfg, Event{Type: "tool_execution_end", Tool: call.Name, CallID: call.ID, Text: msg.Content})
|
emitToolEnd(cfg, call, msg.Content, "denied")
|
||||||
noteToolHook(cfg, call, msg)
|
noteToolHook(cfg, call, msg)
|
||||||
if cfg.AfterTool != nil {
|
if cfg.AfterTool != nil {
|
||||||
cfg.AfterTool(call, msg)
|
cfg.AfterTool(call, msg)
|
||||||
@@ -815,7 +847,7 @@ func runOne(ctx context.Context, cfg Config, call llm.ToolCall) llm.Message {
|
|||||||
if cfg.BeforeTool != nil {
|
if cfg.BeforeTool != nil {
|
||||||
if err := cfg.BeforeTool(call); err != nil {
|
if err := cfg.BeforeTool(call); err != nil {
|
||||||
msg := llm.Message{Role: llm.RoleTool, Name: call.Name, ToolCallID: call.ID, Content: err.Error(), IsError: true}
|
msg := llm.Message{Role: llm.RoleTool, Name: call.Name, ToolCallID: call.ID, Content: err.Error(), IsError: true}
|
||||||
emit(cfg, Event{Type: "tool_execution_end", Tool: call.Name, CallID: call.ID, Text: msg.Content})
|
emitToolEnd(cfg, call, msg.Content, "error")
|
||||||
noteToolHook(cfg, call, msg)
|
noteToolHook(cfg, call, msg)
|
||||||
if cfg.AfterTool != nil {
|
if cfg.AfterTool != nil {
|
||||||
cfg.AfterTool(call, msg)
|
cfg.AfterTool(call, msg)
|
||||||
@@ -826,7 +858,7 @@ func runOne(ctx context.Context, cfg Config, call llm.ToolCall) llm.Message {
|
|||||||
emit(cfg, Event{Type: "tool_execution_update", Tool: call.Name, Detail: call.Arguments})
|
emit(cfg, Event{Type: "tool_execution_update", Tool: call.Name, Detail: call.Arguments})
|
||||||
if err := commitToolIntent(cfg, call); err != nil {
|
if err := commitToolIntent(cfg, call); err != nil {
|
||||||
msg := llm.Message{Role: llm.RoleTool, Name: call.Name, ToolCallID: call.ID, Content: err.Error(), IsError: true}
|
msg := llm.Message{Role: llm.RoleTool, Name: call.Name, ToolCallID: call.ID, Content: err.Error(), IsError: true}
|
||||||
emit(cfg, Event{Type: "tool_execution_end", Tool: call.Name, CallID: call.ID, Text: msg.Content})
|
emitToolEnd(cfg, call, msg.Content, "error")
|
||||||
return msg
|
return msg
|
||||||
}
|
}
|
||||||
endSpan := startSpan(cfg, "execute_tool "+call.Name, map[string]string{
|
endSpan := startSpan(cfg, "execute_tool "+call.Name, map[string]string{
|
||||||
@@ -852,7 +884,15 @@ func runOne(ctx context.Context, cfg Config, call llm.ToolCall) llm.Message {
|
|||||||
Content: res.Content,
|
Content: res.Content,
|
||||||
IsError: res.IsError,
|
IsError: res.IsError,
|
||||||
}
|
}
|
||||||
emit(cfg, Event{Type: "tool_execution_end", Tool: call.Name, CallID: call.ID, Text: res.Content})
|
status := "ok"
|
||||||
|
switch {
|
||||||
|
case ctx.Err() != nil:
|
||||||
|
// 取消优先于工具自报的 ok/error:过程事件不得把中止报成 ok。
|
||||||
|
status = "canceled"
|
||||||
|
case res.IsError:
|
||||||
|
status = "error"
|
||||||
|
}
|
||||||
|
emitToolEnd(cfg, call, res.Content, status)
|
||||||
noteToolHook(cfg, call, msg)
|
noteToolHook(cfg, call, msg)
|
||||||
if cfg.AfterTool != nil {
|
if cfg.AfterTool != nil {
|
||||||
cfg.AfterTool(call, msg)
|
cfg.AfterTool(call, msg)
|
||||||
|
|||||||
@@ -45,5 +45,8 @@ func TestLoopToolEventsCarryCallID(t *testing.T) {
|
|||||||
if ends[0].CallID != "call-7" || ends[0].Tool != "echo" {
|
if ends[0].CallID != "call-7" || ends[0].Tool != "echo" {
|
||||||
t.Fatalf("end = %+v", ends[0])
|
t.Fatalf("end = %+v", ends[0])
|
||||||
}
|
}
|
||||||
|
if ends[0].Status != "ok" || ends[0].ErrorReason != "" {
|
||||||
|
t.Fatalf("successful end must be ok with empty ErrorReason: %+v", ends[0])
|
||||||
|
}
|
||||||
_ = res
|
_ = res
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,286 @@
|
|||||||
|
package runtime
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"agent-native/runtime/hooks"
|
||||||
|
"agent-native/runtime/llm"
|
||||||
|
"agent-native/runtime/tools"
|
||||||
|
)
|
||||||
|
|
||||||
|
type failTool struct{ reason string }
|
||||||
|
|
||||||
|
func (f failTool) Name() string { return "echo" }
|
||||||
|
func (f failTool) Description() string { return "echo that fails" }
|
||||||
|
func (f failTool) Parameters() json.RawMessage { return json.RawMessage(`{"type":"object"}`) }
|
||||||
|
func (f failTool) Execute(context.Context, json.RawMessage) (tools.Result, error) {
|
||||||
|
return tools.Result{Content: f.reason, IsError: true}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLoopSameNameToolsCarryDistinctCallIDs 同名工具连续/并行调用时,
|
||||||
|
// start/end 的 CallID 必须互异且可按 ID 配对(不得按 Tool 名合并)。
|
||||||
|
func TestLoopSameNameToolsCarryDistinctCallIDs(t *testing.T) {
|
||||||
|
for _, mode := range []string{ExecSequential, ExecParallel} {
|
||||||
|
t.Run(mode, func(t *testing.T) {
|
||||||
|
hits := 0
|
||||||
|
reg := tools.NewRegistry(t.TempDir())
|
||||||
|
reg.Register(hitTool{hits: &hits})
|
||||||
|
var starts, ends []Event
|
||||||
|
var mu sync.Mutex
|
||||||
|
_, err := Run(context.Background(), Config{
|
||||||
|
Model: "fake",
|
||||||
|
Tools: reg,
|
||||||
|
ToolExecution: mode,
|
||||||
|
Stream: script(
|
||||||
|
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{
|
||||||
|
{ID: "call-a", Name: "echo", Arguments: `{}`},
|
||||||
|
{ID: "call-b", Name: "echo", Arguments: `{}`},
|
||||||
|
}},
|
||||||
|
llm.Message{Role: llm.RoleAssistant, Content: "done", StopReason: llm.StopEnd},
|
||||||
|
),
|
||||||
|
OnEvent: func(ev Event) {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
switch ev.Type {
|
||||||
|
case "tool_execution_start":
|
||||||
|
starts = append(starts, ev)
|
||||||
|
case "tool_execution_end":
|
||||||
|
ends = append(ends, ev)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}, llm.Message{Role: llm.RoleUser, Content: "go"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if hits != 2 || len(starts) != 2 || len(ends) != 2 {
|
||||||
|
t.Fatalf("hits=%d starts=%d ends=%d", hits, len(starts), len(ends))
|
||||||
|
}
|
||||||
|
byStart := map[string]Event{}
|
||||||
|
for _, ev := range starts {
|
||||||
|
if ev.CallID == "" || ev.Tool != "echo" {
|
||||||
|
t.Fatalf("start missing identity: %+v", ev)
|
||||||
|
}
|
||||||
|
if _, dup := byStart[ev.CallID]; dup {
|
||||||
|
t.Fatalf("duplicate start CallID %q", ev.CallID)
|
||||||
|
}
|
||||||
|
byStart[ev.CallID] = ev
|
||||||
|
}
|
||||||
|
for _, ev := range ends {
|
||||||
|
if ev.Status != "ok" {
|
||||||
|
t.Fatalf("end status = %q, want ok: %+v", ev.Status, ev)
|
||||||
|
}
|
||||||
|
if _, ok := byStart[ev.CallID]; !ok {
|
||||||
|
t.Fatalf("end CallID %q has no matching start", ev.CallID)
|
||||||
|
}
|
||||||
|
delete(byStart, ev.CallID)
|
||||||
|
}
|
||||||
|
if len(byStart) != 0 {
|
||||||
|
t.Fatalf("unpaired starts: %+v", byStart)
|
||||||
|
}
|
||||||
|
if _, ok := map[string]Event{ends[0].CallID: {}, ends[1].CallID: {}}["call-a"]; !ok {
|
||||||
|
t.Fatalf("missing call-a in ends %+v", ends)
|
||||||
|
}
|
||||||
|
if ends[0].CallID == ends[1].CallID {
|
||||||
|
t.Fatalf("CallIDs must differ, both %q", ends[0].CallID)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoopFailingToolEndStatusError(t *testing.T) {
|
||||||
|
reg := tools.NewRegistry(t.TempDir())
|
||||||
|
reg.Register(failTool{reason: "boom-detail"})
|
||||||
|
var end Event
|
||||||
|
_, err := Run(context.Background(), Config{
|
||||||
|
Model: "fake",
|
||||||
|
Tools: reg,
|
||||||
|
Stream: script(
|
||||||
|
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
|
||||||
|
ID: "c-fail", Name: "echo", Arguments: `{}`,
|
||||||
|
}}},
|
||||||
|
llm.Message{Role: llm.RoleAssistant, Content: "done", StopReason: llm.StopEnd},
|
||||||
|
),
|
||||||
|
OnEvent: func(ev Event) {
|
||||||
|
if ev.Type == "tool_execution_end" {
|
||||||
|
end = ev
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}, llm.Message{Role: llm.RoleUser, Content: "go"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if end.CallID != "c-fail" || end.Status != "error" || end.ErrorReason != "boom-detail" {
|
||||||
|
t.Fatalf("end = %+v", end)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoopDeniedToolEndStatusDenied(t *testing.T) {
|
||||||
|
hits := 0
|
||||||
|
reg := tools.NewRegistry(t.TempDir())
|
||||||
|
reg.Register(hitTool{hits: &hits})
|
||||||
|
bus := hooks.New()
|
||||||
|
bus.On(func(c hooks.Call) hooks.Outcome {
|
||||||
|
if c.Event == hooks.PreToolUse {
|
||||||
|
return hooks.Outcome{Decision: hooks.Deny, Reason: "策略拒绝"}
|
||||||
|
}
|
||||||
|
return hooks.Outcome{}
|
||||||
|
})
|
||||||
|
var end Event
|
||||||
|
_, err := Run(context.Background(), Config{
|
||||||
|
Model: "fake",
|
||||||
|
Tools: reg,
|
||||||
|
Hooks: bus,
|
||||||
|
Stream: script(
|
||||||
|
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
|
||||||
|
ID: "c-deny", Name: "echo", Arguments: `{}`,
|
||||||
|
}}},
|
||||||
|
llm.Message{Role: llm.RoleAssistant, Content: "done", StopReason: llm.StopEnd},
|
||||||
|
),
|
||||||
|
OnEvent: func(ev Event) {
|
||||||
|
if ev.Type == "tool_execution_end" {
|
||||||
|
end = ev
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}, llm.Message{Role: llm.RoleUser, Content: "go"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if hits != 0 {
|
||||||
|
t.Fatalf("tool ran %d times", hits)
|
||||||
|
}
|
||||||
|
if end.CallID != "c-deny" || end.Status != "denied" || end.ErrorReason != "策略拒绝" {
|
||||||
|
t.Fatalf("end = %+v", end)
|
||||||
|
}
|
||||||
|
if end.Status == "ok" {
|
||||||
|
t.Fatal("denied must not be reported ok")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoopCanceledToolEndStatusCanceled(t *testing.T) {
|
||||||
|
bt := &blockingTool{canceled: make(chan struct{})}
|
||||||
|
reg := tools.NewRegistry(t.TempDir())
|
||||||
|
reg.Register(bt)
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
firstCall := true
|
||||||
|
stream := func(ctx context.Context, model string, messages []llm.Message, decls []llm.ToolDecl, opts llm.StreamOptions) <-chan llm.Event {
|
||||||
|
ch := make(chan llm.Event, 4)
|
||||||
|
go func() {
|
||||||
|
defer close(ch)
|
||||||
|
if firstCall {
|
||||||
|
firstCall = false
|
||||||
|
ch <- llm.Event{Kind: llm.KindMessageEnd, Message: llm.Message{
|
||||||
|
Role: llm.RoleAssistant, StopReason: llm.StopToolCalls,
|
||||||
|
ToolCalls: []llm.ToolCall{{ID: "c-cancel", Name: "block_forever", Arguments: `{}`}},
|
||||||
|
}}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
ch <- llm.Event{Kind: llm.KindError, Err: ctx.Err(), Message: llm.Message{StopReason: llm.StopAborted}}
|
||||||
|
case <-time.After(3 * time.Second):
|
||||||
|
ch <- llm.Event{Kind: llm.KindMessageEnd, Message: llm.Message{Role: llm.RoleAssistant, Content: "done", StopReason: llm.StopEnd}}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return ch
|
||||||
|
}
|
||||||
|
var ends []Event
|
||||||
|
var mu sync.Mutex
|
||||||
|
go func() {
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
cancel()
|
||||||
|
}()
|
||||||
|
_, err := Run(ctx, Config{
|
||||||
|
Model: "fake",
|
||||||
|
Tools: reg,
|
||||||
|
Stream: stream,
|
||||||
|
OnEvent: func(ev Event) {
|
||||||
|
if ev.Type == "tool_execution_end" {
|
||||||
|
mu.Lock()
|
||||||
|
ends = append(ends, ev)
|
||||||
|
mu.Unlock()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}, llm.Message{Role: llm.RoleUser, Content: "block"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
if len(ends) == 0 {
|
||||||
|
t.Fatal("expected tool_execution_end")
|
||||||
|
}
|
||||||
|
end := ends[0]
|
||||||
|
if end.CallID != "c-cancel" {
|
||||||
|
t.Fatalf("CallID = %q", end.CallID)
|
||||||
|
}
|
||||||
|
if end.Status != "canceled" {
|
||||||
|
t.Fatalf("Status = %q, want canceled (not ok); end=%+v", end.Status, end)
|
||||||
|
}
|
||||||
|
if end.ErrorReason == "" {
|
||||||
|
t.Fatalf("ErrorReason empty on canceled: %+v", end)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type cancelBatchTool struct {
|
||||||
|
cancel context.CancelFunc
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c cancelBatchTool) Name() string { return "cancel_batch" }
|
||||||
|
func (c cancelBatchTool) Description() string { return "cancels ctx then returns" }
|
||||||
|
func (c cancelBatchTool) Parameters() json.RawMessage { return json.RawMessage(`{"type":"object"}`) }
|
||||||
|
func (c cancelBatchTool) Execute(ctx context.Context, args json.RawMessage) (tools.Result, error) {
|
||||||
|
c.cancel()
|
||||||
|
return tools.Result{Content: "cancelled-siblings"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLoopSkippedCancelEmitsCanceledNotOK:串行批内前一个调用取消 ctx 后,
|
||||||
|
// 后续未执行的调用必须发 Status=canceled,不得报 ok。
|
||||||
|
func TestLoopSkippedCancelEmitsCanceledNotOK(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
reg := tools.NewRegistry(t.TempDir())
|
||||||
|
reg.Register(cancelBatchTool{cancel: cancel})
|
||||||
|
hits := 0
|
||||||
|
reg.Register(hitTool{hits: &hits})
|
||||||
|
var ends []Event
|
||||||
|
_, err := Run(ctx, Config{
|
||||||
|
Model: "fake",
|
||||||
|
Tools: reg,
|
||||||
|
ToolExecution: ExecSequential,
|
||||||
|
Stream: script(
|
||||||
|
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{
|
||||||
|
{ID: "c-first", Name: "cancel_batch", Arguments: `{}`},
|
||||||
|
{ID: "c-skip", Name: "echo", Arguments: `{}`},
|
||||||
|
}},
|
||||||
|
llm.Message{Role: llm.RoleAssistant, Content: "done", StopReason: llm.StopEnd},
|
||||||
|
),
|
||||||
|
OnEvent: func(ev Event) {
|
||||||
|
if ev.Type == "tool_execution_end" {
|
||||||
|
ends = append(ends, ev)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}, llm.Message{Role: llm.RoleUser, Content: "go"})
|
||||||
|
_ = err
|
||||||
|
if hits != 0 {
|
||||||
|
t.Fatalf("echo should not run after cancel, hits=%d", hits)
|
||||||
|
}
|
||||||
|
var skip *Event
|
||||||
|
for i := range ends {
|
||||||
|
if ends[i].CallID == "c-skip" {
|
||||||
|
skip = &ends[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if skip == nil {
|
||||||
|
t.Fatalf("missing end for skipped call: %+v", ends)
|
||||||
|
}
|
||||||
|
if skip.Status != "canceled" {
|
||||||
|
t.Fatalf("skipped end Status=%q want canceled: %+v", skip.Status, skip)
|
||||||
|
}
|
||||||
|
if skip.ErrorReason == "" || strings.Contains(skip.Status, "ok") {
|
||||||
|
t.Fatalf("skipped end malformed: %+v", skip)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user