Compare commits

..
Author SHA1 Message Date
weichen 6cc0a86615 Pin the endpoint-migration compat intent branch
Why: recursive checkout reads pinned dep .gitmodules; the compat
branch carries migrated URLs without API movement.

Evidence: host-verified; see Dev PR #706

Impact: pin update only.

Refs: Agent-System-Dev PR #706
2026-10-22 15:05:00 +00:00
weichen cdaf1579df Update Gitea endpoints on the legacy API line (compat branch)
Why: superprojects pinned to this commit need a migrated .gitmodules
without moving dependency APIs; created from the exact pinned commit.

Evidence: host-verified; see Dev PR #706

Impact: configuration only.

Refs: Agent-System-Dev PR #706
2026-10-22 15:04:40 +00:00
weichen cff69355f7 Carry tool-call identity and real outcome in process events
Why: F14 source half - exec.ProcessEvent lacked CallID and outcome
fields, forcing consumers to pair events by tool name and mark every
end ok; cancellations and failures displayed as success downstream.
Evidence: loop/adapter tests pass (verify-u11a.json); pairing, denial,
cancellation and concurrent-callback cases added.
Impact: new omitempty fields only; legacy events unchanged.
Refs: feature/agent-stack-audit-remediation-b2 U11a
2026-10-22 01:52:12 +00:00
18 changed files with 524 additions and 26 deletions
+1 -1
View File
@@ -24,7 +24,7 @@ jobs:
echo "SUBMODULE_TOKEN is not configured" >&2
exit 1
fi
git config --global url."https://weichen:${SUBMODULE_TOKEN}@git-home.u1680832.nyat.app:30319/".insteadOf "ssh://git@git-ssh.u1680832.nyat.app:31411/"
git config --global url."https://weichen:${SUBMODULE_TOKEN}@gitea.naitangbaobao.cn/".insteadOf "ssh://git@gitea.naitangbaobao.cn:52222/"
git -c fetch.recurseSubmodules=false submodule update --init --recursive
- name: Install Go 1.27.1
run: |
+1 -1
View File
@@ -31,7 +31,7 @@ jobs:
parsed_server = urlsplit(SERVER)
if parsed_server.scheme not in {"http", "https"} or not parsed_server.netloc:
sys.exit("Checkout server must be the platform-injected http(s) URL")
SSH = "ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/"
SSH = "ssh://git@gitea.naitangbaobao.cn:52222/weichen/"
# 审查过的白名单,独立于候选 .gitmodules:仅列出两个治理 gitlink。
REPOSITORIES = {
"deps/development-guidelines": "Development-Guidelines",
+5 -5
View File
@@ -1,15 +1,15 @@
[submodule "deps/development-guidelines"]
path = deps/development-guidelines
url = ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Development-Guidelines.git
url = ssh://git@gitea.naitangbaobao.cn:52222/weichen/Development-Guidelines.git
[submodule "deps/error-code-system"]
path = deps/error-code-system
url = ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Error-Code-System.git
url = ssh://git@gitea.naitangbaobao.cn:52222/weichen/Error-Code-System.git
[submodule "deps/logging-system"]
path = deps/logging-system
url = ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Logging-System.git
url = ssh://git@gitea.naitangbaobao.cn:52222/weichen/Logging-System.git
[submodule "deps/agent-intent"]
path = deps/agent-intent
url = ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-Intent.git
url = ssh://git@gitea.naitangbaobao.cn:52222/weichen/Agent-Intent.git
[submodule "deps/development-manager"]
path = deps/development-manager
url = ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Development-Manager.git
url = ssh://git@gitea.naitangbaobao.cn:52222/weichen/Development-Manager.git
+2 -2
View File
@@ -23,9 +23,9 @@
历史文档与旧链接里的 `git-panel.u1680832.nyat.app:30319` 已失效(TLS 握手失败),
见到一律按过期处理,不要再试。当前权威通道:
- **Web / API(HTTPS)**:`https://git-home.u1680832.nyat.app:30319`(Gitea 1.27.3,
- **Web / API(HTTPS)**:`https://gitea.naitangbaobao.cn`(Gitea 1.27.3,
API 前缀 `/api/v1`;偶发 broken pipe / 超时,重试即可)。
- **Git over SSH**:`ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/<Repo>.git`
- **Git over SSH**:`ssh://git@gitea.naitangbaobao.cn:52222/weichen/<Repo>.git`
(各仓 origin 与 gitlink 均为此格式)。
- **API token**:`~/.secrets/gitea`,两行键值 `URL_PREFIX=…` 与
`ACCESS_TOKEN=<40 位 hex>`;取值 `grep ACCESS_TOKEN ~/.secrets/gitea | cut -d= -f2-`,
+2 -2
View File
@@ -6,7 +6,7 @@
- 目标/产品边界:独立的 Agent 执行基础库。提供执行契约(contract)、执行端口(exec)、自研运行时(runtime)、外部适配器(adapters)与行为验收(conformance)。不实现小队编排,不依赖 Core 或 Squad。
- 源码、资产、文档、机器契约位置:Go 源码在仓库根包目录;模块路径 `agent-native`;人类文档在根 `README.md`、`docs/`、`policy/`;过程记录在 `docs/records/`;机器契约以 `contract` 包类型为准。
- 权威远端、集成基线、受保护分支:`ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-Native.git`,集成基线 `main`。
- 权威远端、集成基线、受保护分支:`ssh://git@gitea.naitangbaobao.cn:52222/weichen/Agent-Native.git`,集成基线 `main`。
- 并行共同基线记录位置、合入通知/主动检查方式:任务主记录在 Gitea Issue/PR;合入前在本仓 `main` 上 `git fetch` 并确认 HEAD。
- 发布线与批准版本/实际制品查询入口:当前无独立制品仓库。可构建性以本仓 `main` 的 `go test` 为准;依赖子模块版本以 gitlink 为准,不把工作区未提交文件当发布组合。
- 提交/审核/合并/发布身份与权限:仓库所有者 weichen。本机提交不代替远端合并。
@@ -28,7 +28,7 @@
克隆:
```bash
git clone --recurse-submodules ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-Native.git
git clone --recurse-submodules ssh://git@gitea.naitangbaobao.cn:52222/weichen/Agent-Native.git
# 已克隆时:git submodule update --init --recursive
```
+2 -2
View File
@@ -54,7 +54,7 @@
版本以本仓 gitlink 为准。`scripts/submodule-consistency.sh` 核对 gitlink 与检出的 HEAD。
```bash
git clone --recurse-submodules ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-Native.git
git clone --recurse-submodules ssh://git@gitea.naitangbaobao.cn:52222/weichen/Agent-Native.git
# 已克隆时:git submodule update --init --recursive
```
@@ -83,7 +83,7 @@ replace agent-native => ./deps/agent-native
本仓自己的 `go.mod` 则把错误码库和日志库替换到 `./deps/`。消费方若同时依赖这三仓,必须在消费方 `go.mod` 里写自己的 `replace`:依赖模块内部的 `replace` 不会在上层构建中生效。
远程:`ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-Native.git`
远程:`ssh://git@gitea.naitangbaobao.cn:52222/weichen/Agent-Native.git`
## CI
+3
View File
@@ -475,6 +475,9 @@ func (b *Backend) run(ctx context.Context, model string, budget *runtime.Budget,
Text: ev.Text,
Tool: ev.Tool,
Detail: ev.Detail,
CallID: ev.CallID,
Status: ev.Status,
ErrorReason: ev.ErrorReason,
PromptTokens: ev.PromptTokens,
CompletionTokens: ev.CompletionTokens,
LastPromptTokens: ev.LastPromptTokens,
+111
View File
@@ -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)
}
}
+8
View File
@@ -54,6 +54,14 @@ type ProcessEvent struct {
Text string // delta 文本(Type 为 *_delta 时)
Tool string // 工具名(tool_* 时)
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 int
CompletionTokens int
+47
View File
@@ -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
View File
@@ -43,7 +43,13 @@ type Event struct {
ToolIndex int
// CallID 在 tool_execution_start / tool_execution_end 时携带工具调用的
// ID;tool_call_delta 也携带同一 ID。消费方按 ID 配对而非按顺序。
// 不得从 ToolIndex 反推;ToolIndex 语义不变。
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
// process-visualization consumers; log writers may ignore it.
Detail string
@@ -598,6 +604,9 @@ func appendSkippedToolResults(cfg Config, calls []llm.ToolCall, messages []llm.M
}
// serializeHostCallbacks 让同一次 Run 的宿主回调不会并发进入。
// 并行工具执行下各 goroutine 各自构造 Event 值(无跨调用共享缓冲);
// 此处再串行化 OnEvent,避免消费方在回调内聚合时字段交错。回调须保持
// 同步、非阻塞(与 Config.OnEvent 文档一致)。
func serializeHostCallbacks(cfg *Config) {
var mu sync.Mutex
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))
for _, call := range assistant.ToolCalls {
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
}
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 {
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
}
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 {
if strings.TrimSpace(call.Name) == "" {
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 {
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 != "" {
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_end", Tool: call.Name, CallID: call.ID, Text: why})
emitToolEnd(cfg, call, why, "error")
if cfg.AfterTool != nil {
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})
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)
if cfg.AfterTool != nil {
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 err := cfg.BeforeTool(call); err != nil {
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)
if cfg.AfterTool != nil {
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})
if err := commitToolIntent(cfg, call); err != nil {
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
}
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,
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)
if cfg.AfterTool != nil {
cfg.AfterTool(call, msg)
+3
View File
@@ -45,5 +45,8 @@ func TestLoopToolEventsCarryCallID(t *testing.T) {
if ends[0].CallID != "call-7" || ends[0].Tool != "echo" {
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
}
+286
View File
@@ -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)
}
}