Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6cc0a86615 | ||
|
|
cdaf1579df | ||
|
|
cff69355f7 |
@@ -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: |
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Vendored
+1
-1
Submodule deps/agent-intent updated: 765a7e7177...70b39a9f70
Vendored
+1
-1
Submodule deps/development-guidelines updated: 2688fd2be6...3ed982138c
Vendored
+1
-1
Submodule deps/development-manager updated: bd3a7de57a...24e8a5c8ba
Vendored
+1
-1
Submodule deps/error-code-system updated: a3ee67bba2...00c03035b5
Vendored
+1
-1
Submodule deps/logging-system updated: 49b5d8be60...7005493077
@@ -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
|
||||
|
||||
@@ -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
|
||||
// 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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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