Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
56700f0a26 | ||
|
|
a2e9f003bc | ||
|
|
2da3d76ce1 | ||
|
|
ea497ed149 | ||
|
|
e853260bc7 | ||
|
|
1f952a7ef7 | ||
|
|
aee1d9bcf2 | ||
|
|
503ae000e0 | ||
|
|
392bb68506 | ||
|
|
30e18af70e | ||
|
|
7bcbbdb376 | ||
|
|
8cd4dd0ab7 | ||
|
|
fd69932c7d | ||
|
|
443e7af1b8 | ||
|
|
029917e843 | ||
|
|
9b40595728 | ||
|
|
7b8a34d1aa | ||
|
|
fc0a0505b3 | ||
|
|
f8e9e9f2d2 |
@@ -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",
|
||||
|
||||
+10
-10
@@ -1,30 +1,30 @@
|
||||
[submodule "deps/agent-system-core"]
|
||||
path = deps/agent-system-core
|
||||
url = ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-System-Core.git
|
||||
url = ssh://git@gitea.naitangbaobao.cn:52222/weichen/Agent-System-Core.git
|
||||
[submodule "deps/agent-native"]
|
||||
path = deps/agent-native
|
||||
url = ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-Native.git
|
||||
url = ssh://git@gitea.naitangbaobao.cn:52222/weichen/Agent-Native.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/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/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/agent-squad"]
|
||||
path = deps/agent-squad
|
||||
url = ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-Squad.git
|
||||
url = ssh://git@gitea.naitangbaobao.cn:52222/weichen/Agent-Squad.git
|
||||
[submodule "deps/agent-command"]
|
||||
path = deps/agent-command
|
||||
url = ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-Command.git
|
||||
url = ssh://git@gitea.naitangbaobao.cn:52222/weichen/Agent-Command.git
|
||||
[submodule "deps/agent-document-renderer"]
|
||||
path = deps/agent-document-renderer
|
||||
url = ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-Document-Renderer.git
|
||||
url = ssh://git@gitea.naitangbaobao.cn:52222/weichen/Agent-Document-Renderer.git
|
||||
[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/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-`,
|
||||
@@ -33,7 +33,7 @@
|
||||
(baseline 硬边界 2:只记录来源、字段与可用状态)。
|
||||
- **SSH key**:`~/.ssh/home_gitea`(ED25519,公钥指纹
|
||||
`SHA256:GYXaOXIMeloQ1fKSfdJ/ndgPQzs/Ix7Hk62ekfkfQ20`,Gitea 侧密钥名 GMKtec);
|
||||
`~/.ssh/config` 已有 `git-ssh.u1680832.nyat.app` 主机条目,克隆/推送直接可用。
|
||||
`~/.ssh/config` 已有 `gitea.naitangbaobao.cn(端口 52222)` 主机条目,克隆/推送直接可用。
|
||||
|
||||
## Agent 执行与新项目准入(强制)
|
||||
|
||||
|
||||
Vendored
+1
-1
Submodule deps/agent-command updated: fe7987dc03...f0fc65de2b
Vendored
+1
-1
Submodule deps/agent-document-renderer updated: 3f259990b5...40e5c671d1
Vendored
+1
-1
Submodule deps/agent-intent updated: 765a7e7177...8b54508964
Vendored
+1
-1
Submodule deps/agent-native updated: ac91b8436e...2df0d10d96
Vendored
+1
-1
Submodule deps/agent-squad updated: 0f1daa8b89...b3da5284e8
Vendored
+1
-1
Submodule deps/agent-system-core updated: 0cf03446c4...614dcbff4b
Vendored
+1
-1
Submodule deps/development-guidelines updated: 2688fd2be6...3ed982138c
Vendored
+1
-1
Submodule deps/development-manager updated: 67784f43eb...24e8a5c8ba
Vendored
+1
-1
Submodule deps/error-code-system updated: a3ee67bba2...00c03035b5
Vendored
+1
-1
Submodule deps/logging-system updated: 49b5d8be60...7005493077
@@ -37,6 +37,9 @@ func (f *fencedAuditStore) AppendAuditAccountChecked(string, agentcommand.AuditE
|
||||
// TestStrictAccountWriteAuditLossKeepsOperationID:围栏档下 handler 已提交、
|
||||
// 审计写失败时,回执仍是 unknown 且带上 handler 拿到的 operationID,
|
||||
// 调用方才能在围栏外按记录对账(F10 Bug 2 在围栏路径上的同一语义)。
|
||||
//
|
||||
// 上游 agent-command main 移除了 ExecReceipt.SideEffects 与 NewUnknownAfterExecution,
|
||||
// 且契约禁止 unknown 携带 OperationID;对账句柄改由 Reason 承载(见 cmdbus.unknownReceipt)。
|
||||
func TestStrictAccountWriteAuditLossKeepsOperationID(t *testing.T) {
|
||||
audit := &fencedAuditStore{}
|
||||
b := New(audit, nil, func() time.Time { return time.Unix(1700000000, 0).UTC() })
|
||||
@@ -52,11 +55,11 @@ func TestStrictAccountWriteAuditLossKeepsOperationID(t *testing.T) {
|
||||
if resp.Receipt.State != agentcommand.StateUnknown || !strings.Contains(resp.Receipt.Reason, "audit unavailable") {
|
||||
t.Fatalf("fenced audit loss must stay unknown: %+v", resp.Receipt)
|
||||
}
|
||||
if seen == "" || resp.Receipt.OperationID != seen {
|
||||
t.Fatalf("fenced audit loss dropped the reconciliation handle: receipt=%q handler=%q", resp.Receipt.OperationID, seen)
|
||||
if seen == "" || resp.Receipt.OperationID != "" || !strings.Contains(resp.Receipt.Reason, seen) {
|
||||
t.Fatalf("fenced audit loss dropped the reconciliation handle: receipt=%+v handler=%q", resp.Receipt, seen)
|
||||
}
|
||||
if resp.Receipt.SideEffects != agentcommand.SideEffectsPossible || resp.Receipt.Validate() != nil {
|
||||
t.Fatalf("fenced receipt must declare possible side effects and validate: %+v", resp.Receipt)
|
||||
if err := resp.Receipt.Validate(); err != nil {
|
||||
t.Fatalf("fenced receipt must satisfy the library contract: %v", err)
|
||||
}
|
||||
if audit.appends != 1 {
|
||||
t.Fatalf("account-checked audit appends = %d, want 1", audit.appends)
|
||||
|
||||
+21
-27
@@ -558,17 +558,17 @@ func (b *Bus) Dispatch(ctx context.Context, req Request) Response {
|
||||
// rejected(回执契约禁止 rejected 携带 operationID)。
|
||||
return receipt(agentcommand.StateRejected, "", "rejected: "+err.Error())
|
||||
}
|
||||
// handler 已开始执行才报错:宿主无法断言"无副作用"(F10)。按"已执行
|
||||
// 随后不确定"归 unknown,并带上本次分发已签发的 trusted operation ID,
|
||||
// 调用方据此按记录对账,而不是盲目重试。本路径不做任何自动重放。
|
||||
// handler 已开始执行才报错:宿主无法断言"无副作用"。按"已执行随后不确定"
|
||||
// 归 unknown,并带上本次分发已签发的 operation ID(写在回执说明里,理由见
|
||||
// unknownReceipt),调用方据此按记录对账,而不是盲目重试。本路径不做任何重放。
|
||||
return b.unknownAfterExecution(req, "handler failed after execution began: "+err.Error(), start)
|
||||
}
|
||||
return Response{Receipt: b.auditReceipt(req, agentcommand.StateCommitted, req.OperationID, "", start), Payload: payload}
|
||||
}
|
||||
|
||||
// unknownAfterExecution 记录并返回"已执行随后不确定"的回执(F10/U7b):审计
|
||||
// 信封带 state=unknown 与已签发的 operationID(可查凭证),回执走库内受信
|
||||
// 构造器 NewUnknownAfterExecution,保证 Validate() 通过且对账句柄不丢。
|
||||
// unknownAfterExecution 记录并返回"已执行随后不确定"的回执:审计信封带
|
||||
// state=unknown 与已签发的 operationID(可查凭证),回执的说明字段带上同一个
|
||||
// operationID,保证 Validate() 通过且对账句柄不丢(口径见 unknownReceipt)。
|
||||
func (b *Bus) unknownAfterExecution(req Request, reason string, start time.Time) Response {
|
||||
return Response{Receipt: b.auditReceipt(req, agentcommand.StateUnknown, req.OperationID, reason, start)}
|
||||
}
|
||||
@@ -591,7 +591,7 @@ func (b *Bus) ConfirmContext(ctx context.Context, owner, cardID, actionID string
|
||||
p, ok := b.pending[cardID]
|
||||
b.pendMu.Unlock()
|
||||
if ok && p.req.Owner != "" && p.req.Owner != owner {
|
||||
return Response{Receipt: agentcommand.ExecReceipt{CommandID: p.req.ID, State: agentcommand.StateRejected, Reason: "confirm owner mismatch", SideEffects: agentcommand.SideEffectsNone, Time: b.now().UTC()}}
|
||||
return Response{Receipt: agentcommand.ExecReceipt{CommandID: p.req.ID, State: agentcommand.StateRejected, Reason: "confirm owner mismatch", Time: b.now().UTC()}}
|
||||
}
|
||||
if b.pendingStore != nil {
|
||||
storedOwner, payload, found, err := b.takePending(cardID, owner)
|
||||
@@ -601,7 +601,7 @@ func (b *Bus) ConfirmContext(ctx context.Context, owner, cardID, actionID string
|
||||
if found {
|
||||
var req Request
|
||||
if storedOwner != owner || json.Unmarshal([]byte(payload), &req) != nil {
|
||||
return Response{Receipt: agentcommand.ExecReceipt{CommandID: "unknown", State: agentcommand.StateRejected, Reason: "invalid pending confirm", SideEffects: agentcommand.SideEffectsNone, Time: b.now().UTC()}}
|
||||
return Response{Receipt: agentcommand.ExecReceipt{CommandID: "unknown", State: agentcommand.StateRejected, Reason: "invalid pending confirm", Time: b.now().UTC()}}
|
||||
}
|
||||
p = pending{req: req, cardID: cardID}
|
||||
ok = true
|
||||
@@ -612,11 +612,11 @@ func (b *Bus) ConfirmContext(ctx context.Context, owner, cardID, actionID string
|
||||
ok = false
|
||||
}
|
||||
if !ok {
|
||||
return Response{Receipt: agentcommand.ExecReceipt{CommandID: "unknown", State: agentcommand.StateRejected, Reason: "no pending confirm (expired or already resolved)", SideEffects: agentcommand.SideEffectsNone, Time: b.now().UTC()}}
|
||||
return Response{Receipt: agentcommand.ExecReceipt{CommandID: "unknown", State: agentcommand.StateRejected, Reason: "no pending confirm (expired or already resolved)", Time: b.now().UTC()}}
|
||||
}
|
||||
req := p.req
|
||||
if req.Owner != "" && req.Owner != owner {
|
||||
return Response{Receipt: agentcommand.ExecReceipt{CommandID: req.ID, State: agentcommand.StateRejected, Reason: "confirm owner mismatch", SideEffects: agentcommand.SideEffectsNone, Time: b.now().UTC()}}
|
||||
return Response{Receipt: agentcommand.ExecReceipt{CommandID: req.ID, State: agentcommand.StateRejected, Reason: "confirm owner mismatch", Time: b.now().UTC()}}
|
||||
}
|
||||
b.pendMu.Lock()
|
||||
delete(b.pending, cardID)
|
||||
@@ -677,7 +677,7 @@ func (b *Bus) checkEffectiveAccount(ctx context.Context, actor string) error {
|
||||
func (b *Bus) accountRejected(commandID string, start time.Time) Response {
|
||||
return Response{Receipt: agentcommand.ExecReceipt{
|
||||
CommandID: commandID, State: agentcommand.StateRejected,
|
||||
Reason: "effective account unavailable", SideEffects: agentcommand.SideEffectsNone, Time: start.UTC(),
|
||||
Reason: "effective account unavailable", Time: start.UTC(),
|
||||
}}
|
||||
}
|
||||
|
||||
@@ -711,32 +711,26 @@ func (b *Bus) auditReceipt(req Request, state agentcommand.ResultState, op, reas
|
||||
return unknownReceipt(req.ID, state, op, "audit unavailable: "+err.Error(), b.now().UTC())
|
||||
}
|
||||
if state == agentcommand.StateUnknown && op != "" {
|
||||
// 已执行随后不确定:只有库内受信构造器允许 unknown 携带本流程签发的 ID。
|
||||
return agentcommand.NewUnknownAfterExecution(req.ID, op, reason, b.now().UTC())
|
||||
// 已执行随后不确定:对账句柄随说明一起给出(unknownReceipt)。
|
||||
return unknownReceipt(req.ID, state, op, reason, b.now().UTC())
|
||||
}
|
||||
return agentcommand.ExecReceipt{CommandID: req.ID, State: state, OperationID: op, Reason: reason,
|
||||
SideEffects: sideEffectsFor(state), Time: b.now().UTC()}
|
||||
Time: b.now().UTC()}
|
||||
}
|
||||
|
||||
// unknownReceipt 构造"结果未知"回执:已签发过操作标识时走受信构造器携带该标识
|
||||
// (供对账),否则返回不带标识的 unknown。state=rejected 恒不带标识。
|
||||
// unknownReceipt 构造"结果未知"回执。
|
||||
//
|
||||
// 上游 command.ExecReceipt 已移除 NewUnknownAfterExecution,且现行契约禁止
|
||||
// StateUnknown 携带 OperationID(Validate 直接拒绝)。最接近的等价表达:已签发过
|
||||
// 操作标识时把该标识写进 Reason(unknown 的稳定说明字段),状态与文案语义不变,
|
||||
// 对账句柄不丢;state=rejected 恒不带标识。
|
||||
func unknownReceipt(commandID string, state agentcommand.ResultState, op, reason string, at time.Time) agentcommand.ExecReceipt {
|
||||
if op != "" && state != agentcommand.StateRejected {
|
||||
return agentcommand.NewUnknownAfterExecution(commandID, op, reason, at)
|
||||
reason += " (operation " + op + ")"
|
||||
}
|
||||
return agentcommand.ExecReceipt{CommandID: commandID, State: agentcommand.StateUnknown, Reason: reason, Time: at}
|
||||
}
|
||||
|
||||
// sideEffectsFor 回执的副作用声明:总线自身发起的 rejected 均为任何副作用之前
|
||||
// 的拒绝(未知命令/参数校验/项目前置/账号预检/无处理器/确认取消/handler 声明的
|
||||
// not-executed),故断言 none;committed/accepted 维持今日语义(未声明)。
|
||||
func sideEffectsFor(state agentcommand.ResultState) agentcommand.SideEffects {
|
||||
if state == agentcommand.StateRejected {
|
||||
return agentcommand.SideEffectsNone
|
||||
}
|
||||
return agentcommand.SideEffectsUnset
|
||||
}
|
||||
|
||||
func digest(params map[string]any) string {
|
||||
if len(params) == 0 {
|
||||
return ""
|
||||
|
||||
@@ -119,9 +119,8 @@ func TestHandlerReceivesCommittedOperationIDBeforeDomainWrite(t *testing.T) {
|
||||
if err := failed.Receipt.Validate(); err != nil {
|
||||
t.Fatalf("rejected receipt must satisfy the library contract: %v", err)
|
||||
}
|
||||
if failed.Receipt.SideEffects != agentcommand.SideEffectsNone {
|
||||
t.Fatalf("rejected receipt must declare no side effects, got %q", failed.Receipt.SideEffects)
|
||||
}
|
||||
// 上游 agent-command main 移除了 ExecReceipt.SideEffects(“确定无副作用”
|
||||
// 的显式声明),rejected 不得携带 operationID 这条库契约即是等价断言。
|
||||
}
|
||||
|
||||
func TestOperationIDsRemainUniqueWithFixedClockAndConcurrentDispatch(t *testing.T) {
|
||||
@@ -379,9 +378,28 @@ func (q *memOperationQuery) recordAudit(owner, projectID string, env agentcomman
|
||||
}
|
||||
}
|
||||
|
||||
// operationHandle 取回 unknown 回执给出的对账句柄。agent-command main 契约禁止
|
||||
// StateUnknown 携带 OperationID,句柄改写在 Reason 里("(operation <id>)"),
|
||||
// 这里统一读出来供断言与按记录对账使用。
|
||||
func operationHandle(r agentcommand.ExecReceipt) string {
|
||||
if r.OperationID != "" {
|
||||
return r.OperationID
|
||||
}
|
||||
const marker = " (operation "
|
||||
i := strings.Index(r.Reason, marker)
|
||||
if i < 0 || !strings.HasSuffix(r.Reason, ")") {
|
||||
return ""
|
||||
}
|
||||
return r.Reason[i+len(marker) : len(r.Reason)-1]
|
||||
}
|
||||
|
||||
// TestHandlerErrorAfterStoreWriteIsUnknownWithOperationID 是 F10 的核心回归:
|
||||
// handler 已经写库、之后才失败时,回执必须是 unknown 且携带它拿到的
|
||||
// operationID,理由点明"执行开始后失败",绝不退化成 rejected(会诱发不安全重试)。
|
||||
//
|
||||
// 上游 agent-command main 移除了 ExecReceipt.SideEffects 与 NewUnknownAfterExecution,
|
||||
// 且 Validate 禁止 unknown 携带 OperationID;对账句柄改由 Reason 承载(见
|
||||
// cmdbus.unknownReceipt),断言随之改写到新等价行为。
|
||||
func TestHandlerErrorAfterStoreWriteIsUnknownWithOperationID(t *testing.T) {
|
||||
audit := &memAudit{}
|
||||
bus := New(audit, nil, func() time.Time { return time.Unix(1700000000, 0).UTC() })
|
||||
@@ -398,17 +416,14 @@ func TestHandlerErrorAfterStoreWriteIsUnknownWithOperationID(t *testing.T) {
|
||||
if resp.Receipt.State != agentcommand.StateUnknown {
|
||||
t.Fatalf("post-execution failure must not be reported as rejected: %+v", resp.Receipt)
|
||||
}
|
||||
if seen == "" || resp.Receipt.OperationID != seen {
|
||||
t.Fatalf("unknown receipt lost the reconciliation handle: receipt=%q handler=%q", resp.Receipt.OperationID, seen)
|
||||
if seen == "" || resp.Receipt.OperationID != "" || !strings.Contains(resp.Receipt.Reason, seen) {
|
||||
t.Fatalf("unknown receipt lost the reconciliation handle: receipt=%+v handler=%q", resp.Receipt, seen)
|
||||
}
|
||||
if !strings.Contains(resp.Receipt.Reason, "after execution began") {
|
||||
t.Fatalf("reason must name post-execution uncertainty: %q", resp.Receipt.Reason)
|
||||
}
|
||||
if resp.Receipt.SideEffects != agentcommand.SideEffectsPossible {
|
||||
t.Fatalf("side effects = %q, want %q", resp.Receipt.SideEffects, agentcommand.SideEffectsPossible)
|
||||
}
|
||||
if err := resp.Receipt.Validate(); err != nil {
|
||||
t.Fatalf("trusted unknown receipt must satisfy the library contract: %v", err)
|
||||
t.Fatalf("unknown receipt must satisfy the library contract: %v", err)
|
||||
}
|
||||
if len(writes) != 1 {
|
||||
t.Fatalf("handler ran %d times, want 1", len(writes))
|
||||
@@ -419,7 +434,7 @@ func TestHandlerErrorAfterStoreWriteIsUnknownWithOperationID(t *testing.T) {
|
||||
}
|
||||
// 宿主不得自动重放:再次分发是一次新操作(新的 operationID),旧的不被复用。
|
||||
again := bus.Dispatch(context.Background(), Request{ID: "demo.write", Actor: "alice", Owner: "alice"})
|
||||
if again.Receipt.State != agentcommand.StateUnknown || again.Receipt.OperationID == resp.Receipt.OperationID || len(writes) != 2 {
|
||||
if again.Receipt.State != agentcommand.StateUnknown || operationHandle(again.Receipt) == operationHandle(resp.Receipt) || len(writes) != 2 {
|
||||
t.Fatalf("re-dispatch must be a new operation, not a replay: %+v writes=%d", again.Receipt, len(writes))
|
||||
}
|
||||
}
|
||||
@@ -444,8 +459,8 @@ func TestHandlerNotExecutedErrorStaysRejected(t *testing.T) {
|
||||
if resp.Receipt.Reason != "rejected: permission denied" {
|
||||
t.Fatalf("not-executed marking must not distort the reason: %q", resp.Receipt.Reason)
|
||||
}
|
||||
if resp.Receipt.SideEffects != agentcommand.SideEffectsNone || resp.Receipt.Validate() != nil {
|
||||
t.Fatalf("rejected receipt must declare no side effects and validate: %+v", resp.Receipt)
|
||||
if err := resp.Receipt.Validate(); err != nil {
|
||||
t.Fatalf("rejected receipt must satisfy the library contract: %v", err)
|
||||
}
|
||||
if calls != 1 || len(audit.logs) != 1 || audit.logs[0].State != agentcommand.StateRejected || audit.logs[0].OperationID != "" {
|
||||
t.Fatalf("unexpected effects: calls=%d audits=%+v", calls, audit.logs)
|
||||
@@ -477,11 +492,11 @@ func TestCommittedWithAuditFailureKeepsOperationID(t *testing.T) {
|
||||
if resp.Receipt.State != agentcommand.StateUnknown || !strings.Contains(resp.Receipt.Reason, "audit unavailable") {
|
||||
t.Fatalf("audit loss must stay unknown and name the audit: %+v", resp.Receipt)
|
||||
}
|
||||
if seen == "" || resp.Receipt.OperationID != seen {
|
||||
t.Fatalf("audit loss dropped the reconciliation handle: receipt=%q handler=%q", resp.Receipt.OperationID, seen)
|
||||
if seen == "" || resp.Receipt.OperationID != "" || !strings.Contains(resp.Receipt.Reason, seen) {
|
||||
t.Fatalf("audit loss dropped the reconciliation handle: receipt=%+v handler=%q", resp.Receipt, seen)
|
||||
}
|
||||
if resp.Receipt.SideEffects != agentcommand.SideEffectsPossible || resp.Receipt.Validate() != nil {
|
||||
t.Fatalf("audit-loss receipt must declare possible side effects and validate: %+v", resp.Receipt)
|
||||
if err := resp.Receipt.Validate(); err != nil {
|
||||
t.Fatalf("audit-loss receipt must satisfy the library contract: %v", err)
|
||||
}
|
||||
if len(audit.logs) != 0 {
|
||||
t.Fatalf("audit rows written while failing: %+v", audit.logs)
|
||||
@@ -512,8 +527,8 @@ func TestValidationRejectionNeverRunsHandler(t *testing.T) {
|
||||
if resp.Receipt.State != agentcommand.StateRejected || resp.Receipt.OperationID != "" || resp.Receipt.Validate() != nil {
|
||||
t.Fatalf("validation rejection: %+v", resp.Receipt)
|
||||
}
|
||||
if resp.Receipt.SideEffects != agentcommand.SideEffectsNone || calls != 0 {
|
||||
t.Fatalf("validation rejection ran the handler or claimed effects: calls=%d %+v", calls, resp.Receipt)
|
||||
if calls != 0 {
|
||||
t.Fatalf("validation rejection ran the handler: calls=%d %+v", calls, resp.Receipt)
|
||||
}
|
||||
if len(audit.logs) != 1 || audit.logs[0].State != agentcommand.StateRejected || audit.logs[0].OperationID != "" {
|
||||
t.Fatalf("validation audit row: %+v", audit.logs)
|
||||
@@ -565,14 +580,14 @@ func TestLookupOperationIsOwnerProjectScoped(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp := bus.Dispatch(context.Background(), Request{ID: "demo.write", Owner: "alice", Actor: "alice"})
|
||||
if resp.Receipt.State != agentcommand.StateUnknown || resp.Receipt.OperationID == "" {
|
||||
if resp.Receipt.State != agentcommand.StateUnknown || operationHandle(resp.Receipt) == "" {
|
||||
t.Fatalf("setup dispatch: %+v", resp.Receipt)
|
||||
}
|
||||
// 对账记录=总线真实落下的审计信封(同一 owner/project 快照)。
|
||||
for _, env := range audit.logs {
|
||||
query.recordAudit("alice", "proj_1", env)
|
||||
}
|
||||
op := resp.Receipt.OperationID
|
||||
op := operationHandle(resp.Receipt)
|
||||
record, err := bus.LookupOperation("alice", "proj_1", op)
|
||||
if err != nil {
|
||||
t.Fatalf("owner-scoped lookup: %v", err)
|
||||
|
||||
@@ -45,6 +45,7 @@ func (p *failingPendingStore) TakePendingConfirm(string) (string, string, bool,
|
||||
|
||||
// TestConfirmPendingWriteFailureKeepsCardOperationID:挂起态落盘失败不得吞掉操作
|
||||
// 标识——卡 ID 就是本次受理的句柄,unknown 必须带出去(否则受理事实无凭证)。
|
||||
// agent-command main 契约禁止 unknown 携带 OperationID,句柄随 Reason 给出。
|
||||
func TestConfirmPendingWriteFailureKeepsCardOperationID(t *testing.T) {
|
||||
audit, cards := &memAudit{}, &memCards{}
|
||||
b := newTestBus(audit, cards)
|
||||
@@ -55,7 +56,7 @@ func TestConfirmPendingWriteFailureKeepsCardOperationID(t *testing.T) {
|
||||
if resp.Receipt.State != agentcommand.StateUnknown || !strings.Contains(resp.Receipt.Reason, "confirm pending") {
|
||||
t.Fatalf("pending write failure: %+v", resp.Receipt)
|
||||
}
|
||||
if resp.Receipt.OperationID != "card_x1" || resp.Receipt.Validate() != nil {
|
||||
if resp.Receipt.OperationID != "" || operationHandle(resp.Receipt) != "card_x1" || resp.Receipt.Validate() != nil {
|
||||
t.Fatalf("pending write failure dropped the card operation handle: %+v", resp.Receipt)
|
||||
}
|
||||
if len(cards.created) != 1 || len(b.pending) != 0 {
|
||||
|
||||
@@ -2,6 +2,6 @@
|
||||
|
||||
package docgen
|
||||
|
||||
const rendererRevision = "3f259990b5d43ec0d318f39d496816140ab559b5"
|
||||
const rendererRevision = "40e5c671d1ef786ceba62bf784bf3aa53c5da26d"
|
||||
|
||||
const RendererIdentity = "agent-document-renderer@" + rendererRevision
|
||||
|
||||
@@ -367,7 +367,7 @@ func TestPersistedControlledMetadataRoutesBeforeDefaultProfileLoad(t *testing.T)
|
||||
}
|
||||
|
||||
func TestRendererIdentityMatchesPublishedPin(t *testing.T) {
|
||||
const want = "agent-document-renderer@3f259990b5d43ec0d318f39d496816140ab559b5"
|
||||
const want = "agent-document-renderer@40e5c671d1ef786ceba62bf784bf3aa53c5da26d"
|
||||
if RendererIdentity != want {
|
||||
t.Fatalf("RendererIdentity = %q, want %q", RendererIdentity, want)
|
||||
}
|
||||
|
||||
@@ -21,8 +21,8 @@ type SlotQuestion struct {
|
||||
}
|
||||
|
||||
// SlotDefect 是一条槽位缺陷,来自权威识别结果的校验(F09):
|
||||
// - Invalid=true:捕获到了值但没通过确定性校验(枚举/格式/校验器/跨度重叠/
|
||||
// 生成路径的未知键),Reason 是库层原因原文;
|
||||
// - Invalid=true:捕获到了值但没通过确定性校验(枚举/格式/校验器/跨度重叠),
|
||||
// Reason 是库层原因原文;
|
||||
// - Invalid=false:必填槽位根本没给(等价于 SlotQuestion,Reason 为空)。
|
||||
//
|
||||
// 宿主据此区分「没给」与「给错了」,两种情形都不派发业务动作。
|
||||
@@ -39,11 +39,15 @@ type MatchResult struct {
|
||||
ID string
|
||||
Slots map[string]string
|
||||
Missing []SlotQuestion
|
||||
// Invalid 是捕获到但未通过权威校验的槽位(含生成路径的未知键)。
|
||||
// Invalid 是捕获到但未通过权威校验的槽位。
|
||||
// 与 Missing 分开投影:必填且非法只进此处,不重复计入 Missing。
|
||||
Invalid []SlotDefect
|
||||
// Risk/RequiresConfirm/Action 是权威候选的声明(规则路径与生成路径同款):
|
||||
// 风险档、是否要求显式确认、spec.action 显式动作引用。nil Action 表示未声明。
|
||||
// Risk/RequiresConfirm/Action 是权威候选的声明:风险档、是否要求显式确认、
|
||||
// spec.action 显式动作引用。nil Action 表示未声明。
|
||||
//
|
||||
// 上游 agent-intent main 已移除生成路径的候选合成(candidateFromGenerated
|
||||
// 与 validateProvidedSlots):生成出来的 Top 只带槽位,不再挂 risk/confirm/
|
||||
// action,也不再跑 enum/format 校验。这三个字段目前只由规则路径填充。
|
||||
Risk string
|
||||
RequiresConfirm bool
|
||||
Action *ActionRef
|
||||
@@ -198,8 +202,7 @@ func (g *Gate) Recognize(ctx context.Context, input string) MatchResult {
|
||||
out.Slots[k] = v
|
||||
}
|
||||
}
|
||||
// 风险/确认/动作声明:规则路径与生成路径(candidateFromGenerated)同款字段,
|
||||
// 宿主据此决定是否必须走确认,不在本包内裁决。
|
||||
// 风险/确认/动作声明:规则路径由库层填充;宿主只投影、不在本包内裁决。
|
||||
out.Risk = res.Top.Risk
|
||||
out.RequiresConfirm = res.Top.RequiresConfirm
|
||||
if res.Top.Action != nil {
|
||||
|
||||
@@ -178,7 +178,7 @@ func TestGateGeneratorProducesCommands(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- F09:非法槽位与风险/确认/动作声明的透传(规则路径与生成路径同款)----
|
||||
// ---- F09:非法槽位与风险/确认/动作声明的透传(规则路径)----
|
||||
|
||||
// modeSpec 声明一个带枚举校验的风险意图:mode 与 topic 都是必填。
|
||||
func modeSpec(t *testing.T) json.RawMessage {
|
||||
@@ -239,7 +239,10 @@ func TestRecognizeKeepsInvalidSlotDetail(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 生成路径同样经过权威槽位校验:非法值与未知键都要带原因回到宿主。
|
||||
// 生成路径在上游 agent-intent main 不再跑权威槽位校验:validateProvidedSlots
|
||||
// (enum/format/validator/未知键)已从库层删除,生成的 Top 直接照搬 Command.Slots,
|
||||
// 也不挂 risk/confirm/action 元数据;仅 command 的 ActionRef 与槽位**键**仍由
|
||||
// validateGenerated fail-closed 校验。宿主投影随之退化,断言改写到新等价行为。
|
||||
func TestRecognizeGeneratedPathCarriesSlotDetail(t *testing.T) {
|
||||
g := New()
|
||||
if err := g.Rebuild(context.Background(), []json.RawMessage{modeSpec(t)}); err != nil {
|
||||
@@ -254,18 +257,19 @@ func TestRecognizeGeneratedPathCarriesSlotDetail(t *testing.T) {
|
||||
if res.Branch != "matched" || res.ID != "demo.mode" {
|
||||
t.Fatalf("生成路径识别:%+v", res)
|
||||
}
|
||||
if len(res.Invalid) != 1 || res.Invalid[0].Key != "mode" || res.Invalid[0].Reason != "value not in enum" ||
|
||||
res.Invalid[0].Question != "要哪种模式?" {
|
||||
t.Fatalf("生成路径非法详情:%+v", res.Invalid)
|
||||
// 非法枚举值不再被库层拦下,原样透传(宿主不新增强制分类)。
|
||||
if len(res.Invalid) != 0 || res.Slots["mode"] != "随手写" || res.Slots["topic"] != "核能" {
|
||||
t.Fatalf("生成路径槽位透传:%+v", res)
|
||||
}
|
||||
if _, leaked := res.Slots["mode"]; leaked || res.Complete() {
|
||||
t.Fatalf("生成路径非法值不得进入槽位表:%+v", res.Slots)
|
||||
if !res.Complete() {
|
||||
t.Fatalf("生成路径不再产出权威缺陷:%+v", res)
|
||||
}
|
||||
if res.Risk != "high" || !res.RequiresConfirm || res.Action == nil {
|
||||
// 风险/确认/动作声明同理不再由生成路径提供(规则路径仍然提供)。
|
||||
if res.Risk != "" || res.RequiresConfirm || res.Action != nil {
|
||||
t.Fatalf("生成路径风险元数据:%+v", res)
|
||||
}
|
||||
|
||||
// 未知键:库层判为 invalid(reason=unknown slot key),宿主据此拒绝派发。
|
||||
// 合法槽位:仍然 matched 且完整。
|
||||
g.SetGenerator(stubGenerator{cmds: []Command{{
|
||||
Kind: CommandStart,
|
||||
Action: ActionRef{Kind: ActionCommand, ID: "demo.mode"},
|
||||
@@ -275,6 +279,17 @@ func TestRecognizeGeneratedPathCarriesSlotDetail(t *testing.T) {
|
||||
if !clean.Complete() || clean.Slots["mode"] != "摘要" {
|
||||
t.Fatalf("生成路径合法槽位:%+v", clean)
|
||||
}
|
||||
|
||||
// 未知槽位键仍由库层 fail-closed 拦住(整单回 fallback,不带 Top)。
|
||||
g.SetGenerator(stubGenerator{cmds: []Command{{
|
||||
Kind: CommandStart,
|
||||
Action: ActionRef{Kind: ActionCommand, ID: "demo.mode"},
|
||||
Slots: map[string]string{"unknown_key": "值"},
|
||||
}}})
|
||||
unknown := g.Recognize(context.Background(), "跟昨天一样")
|
||||
if unknown.Branch != "fallback" || unknown.ID != "" || len(unknown.Slots) != 0 {
|
||||
t.Fatalf("未知槽位键必须 fail-closed:%+v", unknown)
|
||||
}
|
||||
}
|
||||
|
||||
// 必填槽位缺失与非法是两条独立通道:缺失走 Missing,非法走 Invalid。
|
||||
|
||||
@@ -217,6 +217,24 @@ func TestResolveHITLSecondRoundKeepsTerminalStatus(t *testing.T) {
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("runner did not reach the second HITL question")
|
||||
}
|
||||
// asked 只保证 runner 已经发出提问,不保证 waiting_hitl 行与卡片都已落库;
|
||||
// 与第 1 轮同一序列:等 run 处于 waiting_hitl 且存在未解决卡片后再读取。
|
||||
waitUntilWithin(t, 10*time.Second, func() bool {
|
||||
got, err := st.GetRunByID(run.ID)
|
||||
if err != nil || got.Status != core.RunWaitingHITL {
|
||||
return false
|
||||
}
|
||||
cards, err := st.ActiveHITLCards(conv.Owner, conv.ID)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
for _, c := range cards {
|
||||
if !c.Resolved {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
})
|
||||
second := pendingCard(t, st, conv, 1)
|
||||
if got, err := st.GetRunByID(run.ID); err != nil || got.Status != core.RunWaitingHITL {
|
||||
t.Fatalf("round 2 status=%s err=%v (want waiting_hitl)", got.Status, err)
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"agent-native/adapters/native"
|
||||
"agent-native/runtime/tools"
|
||||
@@ -25,14 +26,30 @@ type producedSink interface {
|
||||
add(ref *core.ArtifactRef)
|
||||
}
|
||||
|
||||
// artifactList 是本次运行已登记的工件表。宿主工具可能被内核并行调用,
|
||||
// 因此写入与读取都在同一把锁下:并发调用既不丢条目也不读到半成品。
|
||||
type artifactList struct {
|
||||
mu sync.Mutex
|
||||
items []*core.ArtifactRef
|
||||
}
|
||||
|
||||
func (a *artifactList) add(ref *core.ArtifactRef) {
|
||||
if ref != nil {
|
||||
a.items = append(a.items, ref)
|
||||
if ref == nil {
|
||||
return
|
||||
}
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
a.items = append(a.items, ref)
|
||||
}
|
||||
|
||||
// snapshot 返回当前工件的副本,供终局检查与部件装配使用。
|
||||
func (a *artifactList) snapshot() []*core.ArtifactRef {
|
||||
if a == nil {
|
||||
return nil
|
||||
}
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
return append([]*core.ArtifactRef(nil), a.items...)
|
||||
}
|
||||
|
||||
// bridgeHostTools 把画像允许的产品工具包成内核宿主工具。
|
||||
|
||||
+104
-34
@@ -110,6 +110,13 @@ type toolEvidence struct {
|
||||
VerifyText string // 给验证器的尾部摘要,比界面上的摘要长
|
||||
}
|
||||
|
||||
// toolSlot 是一次在途工具调用的过程状态。
|
||||
//
|
||||
// 并行工具执行下槽位有两个并发访问方:工具 goroutine 回写结果字段,
|
||||
// 内核事件回调(onEvent 的 tool_execution_end)在 toolMu 下读它们装部件。
|
||||
// 字段一律经 directRun.writeSlot 在 toolMu 下访问,直接写即数据竞争。
|
||||
// claimed 只在 toolMu 下访问:并行且无 ctx 注入的调用(宿主 UnknownTool
|
||||
// 直调)按认领取槽位,两个 goroutine 不会写同一个槽位、结果不会互相覆盖。
|
||||
type toolSlot struct {
|
||||
call agentllm.ToolCall
|
||||
start time.Time
|
||||
@@ -117,6 +124,7 @@ type toolSlot struct {
|
||||
verifyText string
|
||||
status string
|
||||
truncated bool
|
||||
claimed bool // 已被某次工具执行认领(无注入路径据此避免撞槽)
|
||||
artifact *core.ArtifactRef
|
||||
}
|
||||
|
||||
@@ -565,9 +573,11 @@ func (l *LLMRunner) runDirect(rc RunCtx, stream agentllm.StreamFunc, model strin
|
||||
// 摘要/状态/验证文本在这里补记,保证过程面板与终轮验证器看到同样的证据。
|
||||
func (st *directRun) noteKernelResult(ctx context.Context, res agenttools.Result) {
|
||||
call, ok := nruntime.ToolCallFromContext(ctx)
|
||||
slot := st.slotFor("")
|
||||
if ok {
|
||||
var slot *toolSlot
|
||||
if ok && call.ID != "" {
|
||||
slot = st.slotFor(call.ID)
|
||||
} else {
|
||||
slot = st.slotFor("")
|
||||
}
|
||||
if slot == nil {
|
||||
return
|
||||
@@ -576,28 +586,74 @@ func (st *directRun) noteKernelResult(ctx context.Context, res agenttools.Result
|
||||
if res.IsError {
|
||||
summary = "error: " + summary
|
||||
}
|
||||
slot.summary = summary
|
||||
slot.status = statusOf(nil)
|
||||
status := statusOf(nil)
|
||||
if res.IsError {
|
||||
slot.status = "error"
|
||||
status = "error"
|
||||
}
|
||||
slot.verifyText = tailRunes(res.Content, 600)
|
||||
verifyText := tailRunes(res.Content, 600)
|
||||
st.writeSlot(slot, func(s *toolSlot) {
|
||||
s.summary = summary
|
||||
s.status = status
|
||||
s.verifyText = verifyText
|
||||
})
|
||||
}
|
||||
|
||||
// slotFor 按调用 ID 取在途槽位;ID 为空且恰有一个槽位时取之(兼容无注入
|
||||
// 的调用路径)。返回的指针仅供本次工具执行内写回,不跨事件持有。
|
||||
// writeSlot 在 toolMu 下回写在途槽位:工具 goroutine 写、事件回调读的是同一
|
||||
// 块内存,只有同一把锁才能给出 happens-before。
|
||||
func (st *directRun) writeSlot(slot *toolSlot, apply func(*toolSlot)) {
|
||||
if slot == nil {
|
||||
return
|
||||
}
|
||||
st.toolMu.Lock()
|
||||
defer st.toolMu.Unlock()
|
||||
apply(slot)
|
||||
}
|
||||
|
||||
// appendFinalPart 在 toolMu 下追加最终部件:并行工具执行(工件门面卡、卡片
|
||||
// 工具)与事件回调共享同一片 finalParts。
|
||||
func (st *directRun) appendFinalPart(parts ...core.Part) {
|
||||
st.toolMu.Lock()
|
||||
defer st.toolMu.Unlock()
|
||||
st.finalParts = append(st.finalParts, parts...)
|
||||
}
|
||||
|
||||
// markExplicitCard / cardSuppressed 记「模型已自己发过卡片」:写方是卡片工具
|
||||
// (保序执行,仍与并行工具 goroutine 同时在跑),读方是工件门面卡分支。
|
||||
func (st *directRun) markExplicitCard() {
|
||||
st.toolMu.Lock()
|
||||
defer st.toolMu.Unlock()
|
||||
st.explicitCard = true
|
||||
}
|
||||
|
||||
func (st *directRun) cardSuppressed() bool {
|
||||
st.toolMu.Lock()
|
||||
defer st.toolMu.Unlock()
|
||||
return st.explicitCard
|
||||
}
|
||||
|
||||
// slotFor 按调用 ID 取在途槽位;ID 为空时认领一个尚未被认领的槽位(兼容无
|
||||
// 注入的调用路径)。返回的指针仅供本次工具执行内写回,不跨事件持有。
|
||||
func (st *directRun) slotFor(callID string) *toolSlot {
|
||||
st.toolMu.Lock()
|
||||
defer st.toolMu.Unlock()
|
||||
if callID != "" {
|
||||
return st.inflight[callID]
|
||||
slot := st.inflight[callID]
|
||||
if slot != nil {
|
||||
slot.claimed = true
|
||||
}
|
||||
return slot
|
||||
}
|
||||
if len(st.inflight) == 1 {
|
||||
for _, slot := range st.inflight {
|
||||
return slot
|
||||
var free *toolSlot
|
||||
for _, slot := range st.inflight {
|
||||
if !slot.claimed {
|
||||
free = slot
|
||||
break
|
||||
}
|
||||
}
|
||||
return nil
|
||||
if free != nil {
|
||||
free.claimed = true
|
||||
}
|
||||
return free
|
||||
}
|
||||
|
||||
// noteToolDelta 把模型正在编写的工具参数立刻转成会话事件。
|
||||
@@ -725,18 +781,22 @@ func (st *directRun) onEvent(ev nruntime.Event) {
|
||||
st.toolMu.Unlock()
|
||||
return
|
||||
}
|
||||
// 锁内快照结果字段:工具回写(并行工具 goroutine)与本回调分属两个
|
||||
// goroutine,解锁后再读槽位字段即数据竞争,事件只带快照走。
|
||||
call, start := slot.call, slot.start
|
||||
status, summary, truncated := slot.status, slot.summary, slot.truncated
|
||||
end := st.l.now()
|
||||
st.finalParts = append(st.finalParts,
|
||||
core.Part{Type: core.PartToolCall, Process: true, ToolCall: &core.ToolCallPart{ID: slot.call.ID, Name: slot.call.Name, Arguments: slot.call.Arguments, Duration: durationMs(slot.start, end)}},
|
||||
core.Part{Type: core.PartToolResult, Process: true, ToolResult: &core.ToolResultPart{CallID: slot.call.ID, Status: slot.status, Summary: slot.summary}},
|
||||
core.Part{Type: core.PartToolCall, Process: true, ToolCall: &core.ToolCallPart{ID: call.ID, Name: call.Name, Arguments: call.Arguments, Duration: durationMs(start, end)}},
|
||||
core.Part{Type: core.PartToolResult, Process: true, ToolResult: &core.ToolResultPart{CallID: call.ID, Status: status, Summary: summary}},
|
||||
)
|
||||
if slot.artifact != nil {
|
||||
st.deliverables = append(st.deliverables, slot.artifact)
|
||||
}
|
||||
st.evidence = append(st.evidence, toolEvidence{Name: slot.call.Name, Status: slot.status, Summary: slot.summary, VerifyText: slot.verifyText})
|
||||
st.evidence = append(st.evidence, toolEvidence{Name: call.Name, Status: status, Summary: summary, VerifyText: slot.verifyText})
|
||||
st.toolMu.Unlock()
|
||||
st.noteFailureOutcome(slot.status)
|
||||
_ = st.rc.Emit(core.EvToolResult, core.ToolResultPayload{CallID: slot.call.ID, Status: slot.status, Summary: slot.summary, Truncated: slot.truncated, DurationMs: durationMs(slot.start, end)})
|
||||
st.noteFailureOutcome(status)
|
||||
_ = st.rc.Emit(core.EvToolResult, core.ToolResultPayload{CallID: call.ID, Status: status, Summary: summary, Truncated: truncated, DurationMs: durationMs(start, end)})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1129,17 +1189,17 @@ func (st *directRun) runToolCtx(ctx context.Context, name, args string) agenttoo
|
||||
}
|
||||
}
|
||||
if res.Artifact != nil && slot != nil {
|
||||
slot.artifact = &core.ArtifactRef{ArtifactID: res.Artifact.ID, Name: res.Artifact.Name, Kind: res.Artifact.Kind}
|
||||
if st.cardflow && res.Truncated && !st.explicitCard {
|
||||
st.writeSlot(slot, func(s *toolSlot) {
|
||||
s.artifact = &core.ArtifactRef{ArtifactID: res.Artifact.ID, Name: res.Artifact.Name, Kind: res.Artifact.Kind}
|
||||
})
|
||||
if st.cardflow && res.Truncated && !st.cardSuppressed() {
|
||||
fc, ferr := st.rc.SubmitCard(artifactFacadeCard(call.Name, *res.Artifact, summarize(out, nil)))
|
||||
if ferr == nil {
|
||||
st.finalParts = append(st.finalParts, core.Part{Type: core.PartCard, Card: &core.CardPart{Kind: string(core.CardKindResult), CardID: fc.ID}})
|
||||
st.appendFinalPart(core.Part{Type: core.PartCard, Card: &core.CardPart{Kind: string(core.CardKindResult), CardID: fc.ID}})
|
||||
}
|
||||
}
|
||||
}
|
||||
if slot != nil {
|
||||
slot.truncated = res.Truncated
|
||||
}
|
||||
st.writeSlot(slot, func(s *toolSlot) { s.truncated = res.Truncated })
|
||||
}
|
||||
|
||||
summary := summarize(out, nil)
|
||||
@@ -1148,11 +1208,12 @@ func (st *directRun) runToolCtx(ctx context.Context, name, args string) agenttoo
|
||||
summary = "error: " + err.Error()
|
||||
out = summary
|
||||
}
|
||||
if slot != nil {
|
||||
slot.summary = summary
|
||||
slot.status = status
|
||||
slot.verifyText = tailRunes(out, 600)
|
||||
}
|
||||
verifyText := tailRunes(out, 600)
|
||||
st.writeSlot(slot, func(s *toolSlot) {
|
||||
s.summary = summary
|
||||
s.status = status
|
||||
s.verifyText = verifyText
|
||||
})
|
||||
if r := []rune(out); len(r) > 16000 {
|
||||
out = string(r[:16000]) + "…(超长截断)"
|
||||
}
|
||||
@@ -1257,8 +1318,8 @@ func (st *directRun) submitCard(args string) string {
|
||||
if cerr != nil {
|
||||
return "submit_card 失败: " + cerr.Error()
|
||||
}
|
||||
st.finalParts = append(st.finalParts, core.Part{Type: core.PartCard, Card: &core.CardPart{Kind: string(core.CardKindResult), CardID: card.ID}})
|
||||
st.explicitCard = true
|
||||
st.appendFinalPart(core.Part{Type: core.PartCard, Card: &core.CardPart{Kind: string(core.CardKindResult), CardID: card.ID}})
|
||||
st.markExplicitCard()
|
||||
where := "对话"
|
||||
if a.Scope == "project" {
|
||||
where = "项目"
|
||||
@@ -1311,18 +1372,21 @@ func (st *directRun) commitCard() string {
|
||||
if cerr != nil {
|
||||
return "card_commit 失败: " + cerr.Error()
|
||||
}
|
||||
st.finalParts = append(st.finalParts, core.Part{Type: core.PartCard, Card: &core.CardPart{Kind: string(core.CardKindResult), CardID: card.ID}})
|
||||
st.explicitCard = true
|
||||
st.appendFinalPart(core.Part{Type: core.PartCard, Card: &core.CardPart{Kind: string(core.CardKindResult), CardID: card.ID}})
|
||||
st.markExplicitCard()
|
||||
return fmt.Sprintf("已作为卡片发出(%s,card:%s)。对话中保留简短结论即可,不要重复卡片全文。", card.Title, card.ID)
|
||||
}
|
||||
|
||||
func (st *directRun) pinSubagentArtifacts(text string) {
|
||||
st.toolMu.Lock()
|
||||
seen := map[string]bool{}
|
||||
for _, ref := range st.deliverables {
|
||||
if ref != nil {
|
||||
seen[ref.ArtifactID] = true
|
||||
}
|
||||
}
|
||||
st.toolMu.Unlock()
|
||||
pinned := make([]*core.ArtifactRef, 0, 4)
|
||||
for _, id := range gates.ArtifactIDPattern.FindAllString(text, -1) {
|
||||
if seen[id] {
|
||||
continue
|
||||
@@ -1332,8 +1396,14 @@ func (st *directRun) pinSubagentArtifacts(text string) {
|
||||
continue
|
||||
}
|
||||
seen[id] = true
|
||||
st.deliverables = append(st.deliverables, &core.ArtifactRef{ArtifactID: art.ID, Name: art.Name, Kind: art.Kind})
|
||||
pinned = append(pinned, &core.ArtifactRef{ArtifactID: art.ID, Name: art.Name, Kind: art.Kind})
|
||||
}
|
||||
if len(pinned) == 0 {
|
||||
return
|
||||
}
|
||||
st.toolMu.Lock()
|
||||
st.deliverables = append(st.deliverables, pinned...)
|
||||
st.toolMu.Unlock()
|
||||
}
|
||||
|
||||
func errString(err error) string {
|
||||
|
||||
@@ -27,6 +27,7 @@ type fakeRunCtx struct {
|
||||
cardflow bool
|
||||
events []emittedEvent
|
||||
eventsMu sync.Mutex
|
||||
stateMu sync.Mutex // 并行工具执行下会写回的假状态(计数/卡片/用量/后台)
|
||||
assistant []core.Part
|
||||
cards []core.ActivityCard
|
||||
streamCardID core.ID // 活跃流式卡(Begin/Append/Commit fake 语义)
|
||||
@@ -80,8 +81,15 @@ func (f *fakeRunCtx) emittedEvents() []emittedEvent {
|
||||
defer f.eventsMu.Unlock()
|
||||
return append([]emittedEvent(nil), f.events...)
|
||||
}
|
||||
func (f *fakeRunCtx) SetAssistantParts(parts []core.Part) error { f.assistant = parts; return nil }
|
||||
func (f *fakeRunCtx) SetAssistantParts(parts []core.Part) error {
|
||||
f.stateMu.Lock()
|
||||
defer f.stateMu.Unlock()
|
||||
f.assistant = parts
|
||||
return nil
|
||||
}
|
||||
func (f *fakeRunCtx) AddUsage(u core.Usage) {
|
||||
f.stateMu.Lock()
|
||||
defer f.stateMu.Unlock()
|
||||
f.usageTotal.PromptTokens += u.PromptTokens
|
||||
f.usageTotal.CompletionTokens += u.CompletionTokens
|
||||
f.usageTotal.TotalTokens += u.TotalTokens
|
||||
@@ -113,6 +121,8 @@ func (f *fakeRunCtx) WriteArtifact(name, kind, mime string, content []byte) (cor
|
||||
}
|
||||
func (f *fakeRunCtx) Retrieve(q core.RetrievalQuery) ([]core.RetrievalHit, error) { return nil, nil }
|
||||
func (f *fakeRunCtx) ExecuteTool(call core.ToolCallRequest) core.ToolCallResult {
|
||||
f.stateMu.Lock()
|
||||
defer f.stateMu.Unlock()
|
||||
f.toolRuns++
|
||||
res := core.ToolCallResult{Status: "ok", Output: "done", Truncated: f.execTruncated}
|
||||
if f.execStatus != "" {
|
||||
@@ -133,6 +143,8 @@ func (f *fakeRunCtx) History() []core.Message {
|
||||
return []core.Message{{ID: "m1", Role: core.RoleUser, Parts: []core.Part{{Type: core.PartText, Text: "hi"}}}}
|
||||
}
|
||||
func (f *fakeRunCtx) SpawnBackground(agentID, name, task, hint string) (core.ID, error) {
|
||||
f.stateMu.Lock()
|
||||
defer f.stateMu.Unlock()
|
||||
f.bgCalls++
|
||||
if f.bgErr != nil {
|
||||
return "", f.bgErr
|
||||
@@ -174,6 +186,14 @@ func (f *fakeRunCtx) Attachments() []core.ParsedDocument { return f.attachments
|
||||
func (f *fakeRunCtx) Tools() []core.ToolDesc { return nil }
|
||||
func (f *fakeRunCtx) Subtask() string { return "" }
|
||||
func (f *fakeRunCtx) CreateCard(card core.ActivityCard) (core.ActivityCard, error) {
|
||||
f.stateMu.Lock()
|
||||
defer f.stateMu.Unlock()
|
||||
return f.createCardLocked(card)
|
||||
}
|
||||
|
||||
// createCardLocked 建卡(调用方须持有 stateMu);卡片工具与工件门面卡
|
||||
// 可能从并行工具 goroutine 同时进来。
|
||||
func (f *fakeRunCtx) createCardLocked(card core.ActivityCard) (core.ActivityCard, error) {
|
||||
card.ID = core.ID(fmt.Sprintf("card_%d", len(f.cards)+1))
|
||||
f.cards = append(f.cards, card)
|
||||
return card, nil
|
||||
@@ -199,7 +219,9 @@ func (f *fakeRunCtx) SubmitCard(card core.ActivityCard) (core.ActivityCard, erro
|
||||
// runmgr/cardsvc 语义,供工具编排层断言。
|
||||
func (f *fakeRunCtx) BeginStreamCard(card core.ActivityCard) (core.ActivityCard, error) {
|
||||
card.Status = core.CardActive
|
||||
created, err := f.CreateCard(card)
|
||||
f.stateMu.Lock()
|
||||
defer f.stateMu.Unlock()
|
||||
created, err := f.createCardLocked(card)
|
||||
if err != nil {
|
||||
return created, err
|
||||
}
|
||||
@@ -208,6 +230,8 @@ func (f *fakeRunCtx) BeginStreamCard(card core.ActivityCard) (core.ActivityCard,
|
||||
}
|
||||
|
||||
func (f *fakeRunCtx) AppendStreamCard(text string) error {
|
||||
f.stateMu.Lock()
|
||||
defer f.stateMu.Unlock()
|
||||
if f.streamCardID == "" {
|
||||
return errors.New("card: no active stream card (card_begin first)")
|
||||
}
|
||||
@@ -226,6 +250,8 @@ func (f *fakeRunCtx) AppendStreamCard(text string) error {
|
||||
}
|
||||
|
||||
func (f *fakeRunCtx) CommitStreamCard() (core.ActivityCard, error) {
|
||||
f.stateMu.Lock()
|
||||
defer f.stateMu.Unlock()
|
||||
if f.streamCardID == "" {
|
||||
return core.ActivityCard{}, errors.New("card: no active stream card (card_begin first)")
|
||||
}
|
||||
@@ -240,6 +266,8 @@ func (f *fakeRunCtx) CommitStreamCard() (core.ActivityCard, error) {
|
||||
return core.ActivityCard{}, errors.New("card: not found")
|
||||
}
|
||||
func (f *fakeRunCtx) Card(id string) *core.ActivityCard {
|
||||
f.stateMu.Lock()
|
||||
defer f.stateMu.Unlock()
|
||||
for i := range f.cards {
|
||||
if string(f.cards[i].ID) == id {
|
||||
return &f.cards[i]
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"agent-squad/squad"
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
// toolResultSummaryLen 是工具结果摘要的截断长度(与既有展示一致)。
|
||||
const toolResultSummaryLen = 160
|
||||
|
||||
// toolStatusOK 是宿主展示的工具结局取值域(ok | error | denied | canceled)。
|
||||
// squad.ProcessEvent 的 Status 字段与 squad.ToolStatus* 常量已在上游 main 被移除,
|
||||
// 这里保留同名的宿主侧常量,使部件与发射载荷继续用同一取值域,不改语义。
|
||||
const toolStatusOK = "ok"
|
||||
|
||||
// toolEventID 是宿主侧的工具调用身份。
|
||||
//
|
||||
// 上游 squad.ProcessEvent 已不再携带每次调用的 CallID(并行同名工具的按 ID 配对
|
||||
// 能力随该字段一并移除),因此退到既有的 legacy 口径:按工具名配对 start/end。
|
||||
// 上游恢复 CallID 时只需在此处换回优先取 ID。
|
||||
func toolEventID(ev squad.ProcessEvent) string { return ev.Tool }
|
||||
|
||||
// toolEventOutcome 把上游 tool_execution_end 翻译成宿主展示的结局。
|
||||
//
|
||||
// 上游不再提供 Status/ErrorReason,无法区分 error/denied/canceled,故按最接近的
|
||||
// 等价口径处理:结局报 ok(不臆造上游没给的判定),摘要取上游实际带来的工具结果
|
||||
// ——新 API 把结果放在 Text(Detail 只在 start 帧携带调用参数),Text 为空时回退
|
||||
// Detail,保持 legacy 帧的字节一致。
|
||||
func toolEventOutcome(ev squad.ProcessEvent) (status, summary string) {
|
||||
summary = ev.Text
|
||||
if summary == "" {
|
||||
summary = ev.Detail
|
||||
}
|
||||
return toolStatusOK, truncateRun(summary, toolResultSummaryLen)
|
||||
}
|
||||
|
||||
// toolFailure 是一次未被恢复的工具失败(用于终局文本一致性检查,不覆盖工具部件)。
|
||||
type toolFailure struct {
|
||||
Tool string
|
||||
CallID string
|
||||
Status string
|
||||
Summary string
|
||||
}
|
||||
|
||||
// squadEventLog 是小队过程帧在宿主侧的聚合点。
|
||||
//
|
||||
// 并行工具调用会从多个 goroutine 回调进来(内核 executeParallel 逐个派发),
|
||||
// 因此流式文本、工具部件、用量与 pending 配对表全部在同一把锁下更新:
|
||||
// 既不丢条目,也不会把 A 调用的 start 与 B 调用的 end 交错写坏。
|
||||
// 事件发射与部件追加在同一临界区内,保持到达顺序与最终部件顺序一致。
|
||||
type squadEventLog struct {
|
||||
rc RunCtx
|
||||
|
||||
mu sync.Mutex
|
||||
streamed strings.Builder
|
||||
textIdx int
|
||||
parts []core.Part
|
||||
pending map[string]string // callID → 工具名(start/end 配对)
|
||||
failure *toolFailure // 最近一次失败;同名工具后续再起即视为已恢复
|
||||
}
|
||||
|
||||
// newSquadEventLog 绑定一次运行的过程帧回调。
|
||||
func newSquadEventLog(rc RunCtx) *squadEventLog {
|
||||
return &squadEventLog{rc: rc, pending: map[string]string{}}
|
||||
}
|
||||
|
||||
// onEvent 是小队过程帧回调(并发安全)。
|
||||
func (l *squadEventLog) onEvent(ev squad.ProcessEvent) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
switch ev.Type {
|
||||
case "text_delta":
|
||||
if ev.Text == "" {
|
||||
return
|
||||
}
|
||||
l.streamed.WriteString(ev.Text)
|
||||
_ = l.rc.Emit(core.EvMessageDelta, core.MessageDeltaPayload{Index: l.textIdx, Text: ev.Text})
|
||||
case "reasoning_delta":
|
||||
if ev.Text != "" {
|
||||
_ = l.rc.Emit(core.EvThinkingDelta, core.ThinkingDeltaPayload{Text: ev.Text})
|
||||
}
|
||||
case "tool_execution_start":
|
||||
id := toolEventID(ev)
|
||||
if id != "" {
|
||||
l.pending[id] = ev.Tool
|
||||
}
|
||||
l.noteRecovered(ev.Tool)
|
||||
_ = l.rc.Emit(core.EvToolCall, core.ToolCallPayload{CallID: id, Name: ev.Tool, Arguments: ev.Detail})
|
||||
l.parts = append(l.parts, core.Part{Type: core.PartToolCall, Process: true, ToolCall: &core.ToolCallPart{ID: id, Name: ev.Tool, Arguments: ev.Detail}})
|
||||
case "tool_execution_end":
|
||||
id := toolEventID(ev)
|
||||
if tool, ok := l.pending[id]; ok && ev.Tool == "" {
|
||||
ev.Tool = tool
|
||||
}
|
||||
delete(l.pending, id)
|
||||
status, summary := toolEventOutcome(ev)
|
||||
// 发射与部件同源:前端过程行与落库部件说的是同一件事。
|
||||
_ = l.rc.Emit(core.EvToolResult, core.ToolResultPayload{CallID: id, Status: status, Summary: summary})
|
||||
l.parts = append(l.parts, core.Part{Type: core.PartToolResult, Process: true, ToolResult: &core.ToolResultPart{CallID: id, Status: status, Summary: summary}})
|
||||
if status == toolStatusOK {
|
||||
l.noteRecovered(ev.Tool)
|
||||
} else {
|
||||
l.failure = &toolFailure{Tool: ev.Tool, CallID: id, Status: status, Summary: summary}
|
||||
}
|
||||
case "usage":
|
||||
if sink, ok := l.rc.(interface{ AddUsage(core.Usage) }); ok {
|
||||
sink.AddUsage(core.Usage{
|
||||
PromptTokens: ev.PromptTokens, CompletionTokens: ev.CompletionTokens, TotalTokens: ev.PromptTokens + ev.CompletionTokens,
|
||||
LastPromptTokens: ev.LastPromptTokens, LastCompletionTokens: ev.LastCompletionTokens,
|
||||
CacheReadTokens: ev.CacheReadTokens, CacheWriteTokens: ev.CacheWriteTokens,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// noteRecovered 把同名工具的后续进展(再次发起或调用成功)记为已恢复:
|
||||
// 失败部件仍留在过程部件里,只是不再让收尾文本替它宣布成功。
|
||||
func (l *squadEventLog) noteRecovered(tool string) {
|
||||
if l.failure != nil && l.failure.Tool == tool {
|
||||
l.failure = nil
|
||||
}
|
||||
}
|
||||
|
||||
// reply 是本次运行聚合到的模型文本(不含任何宿主补写)。
|
||||
func (l *squadEventLog) reply() string {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
return l.streamed.String()
|
||||
}
|
||||
|
||||
// parts 返回过程部件的快照(复制,避免把内部切片交给最终部件)。
|
||||
func (l *squadEventLog) toolParts() []core.Part {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
return append([]core.Part(nil), l.parts...)
|
||||
}
|
||||
|
||||
// unresolvedFailure 返回终局时仍未恢复的工具失败,没有则 nil。
|
||||
func (l *squadEventLog) unresolvedFailure() *toolFailure {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
if l.failure == nil {
|
||||
return nil
|
||||
}
|
||||
f := *l.failure
|
||||
return &f
|
||||
}
|
||||
|
||||
// finalReply 决定终局文本。模型文本原样保留(不代写、不改写);只有宿主兜底的
|
||||
// 「已完成。」在仍有未恢复的工具失败时会被换成如实说明,避免收尾文本与失败部件
|
||||
// 自相矛盾。上游 main 的事件帧不带结局判定(见 toolEventOutcome),宿主不臆造
|
||||
// 失败,因此该分支暂时走不到:失败聚合原样保留,上游恢复结局信号即重新生效。
|
||||
func (l *squadEventLog) finalReply(modelText, summary, fallback string) string {
|
||||
reply := strings.TrimSpace(modelText)
|
||||
if reply == "" {
|
||||
reply = strings.TrimSpace(summary)
|
||||
}
|
||||
if reply != "" {
|
||||
return reply
|
||||
}
|
||||
if f := l.unresolvedFailure(); f != nil {
|
||||
return failedToolLine(f)
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
// failedToolLine 如实描述未恢复的工具失败,不声称成功,也不编造原因。
|
||||
func failedToolLine(f *toolFailure) string {
|
||||
name := strings.TrimSpace(f.Tool)
|
||||
if name == "" {
|
||||
name = "工具"
|
||||
}
|
||||
reason := strings.TrimSpace(f.Summary)
|
||||
if reason == "" {
|
||||
return name + " 未完成(" + f.Status + ")"
|
||||
}
|
||||
return name + " 未完成(" + f.Status + "):" + reason
|
||||
}
|
||||
@@ -0,0 +1,433 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
agentllm "agent-native/runtime/llm"
|
||||
"agent-squad/squad"
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
// eventProbe 记录小队过程帧回调发射出去的事件(顺序与内容都要能断言)。
|
||||
// Emit 由 squadEventLog 的临界区内调用,因此这里无需再自锁。
|
||||
type eventProbe struct {
|
||||
*entryFake
|
||||
rec *squadEventLog
|
||||
toolCall []core.ToolCallPayload
|
||||
result []core.ToolResultPayload
|
||||
}
|
||||
|
||||
func newEventProbe() *eventProbe {
|
||||
p := &eventProbe{entryFake: &entryFake{}}
|
||||
p.rec = newSquadEventLog(p)
|
||||
return p
|
||||
}
|
||||
|
||||
func (p *eventProbe) Emit(typ core.StreamEventType, payload any) error {
|
||||
switch v := payload.(type) {
|
||||
case core.ToolCallPayload:
|
||||
p.toolCall = append(p.toolCall, v)
|
||||
case core.ToolResultPayload:
|
||||
p.result = append(p.result, v)
|
||||
}
|
||||
return p.entryFake.Emit(typ, payload)
|
||||
}
|
||||
|
||||
func (p *eventProbe) onEvent(ev squad.ProcessEvent) { p.rec.onEvent(ev) }
|
||||
|
||||
func (p *eventProbe) parts() []core.Part { return p.rec.toolParts() }
|
||||
|
||||
func (p *eventProbe) calls() []core.ToolCallPayload {
|
||||
return append([]core.ToolCallPayload(nil), p.toolCall...)
|
||||
}
|
||||
|
||||
func (p *eventProbe) results() []core.ToolResultPayload {
|
||||
return append([]core.ToolResultPayload(nil), p.result...)
|
||||
}
|
||||
|
||||
func toolCallsOf(parts []core.Part) []core.ToolCallPart {
|
||||
var out []core.ToolCallPart
|
||||
for _, p := range parts {
|
||||
if p.ToolCall != nil {
|
||||
out = append(out, *p.ToolCall)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func toolResultsOf(parts []core.Part) []core.ToolResultPart {
|
||||
var out []core.ToolResultPart
|
||||
for _, p := range parts {
|
||||
if p.ToolResult != nil {
|
||||
out = append(out, *p.ToolResult)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TestSquadEventsPairSameNameToolsByToolName:squad.ProcessEvent 在上游 main 已不再
|
||||
// 携带 CallID,宿主退回 legacy 口径按工具名配对 start/end:同名工具不再按 ID 区分,
|
||||
// 但到达顺序与每帧的内容必须一一落成部件,不得丢失或错位。
|
||||
func TestSquadEventsPairSameNameToolsByToolName(t *testing.T) {
|
||||
p := newEventProbe()
|
||||
p.onEvent(squad.ProcessEvent{Type: "tool_execution_start", Tool: "grep", Detail: `{"q":"a"}`})
|
||||
p.onEvent(squad.ProcessEvent{Type: "tool_execution_start", Tool: "grep", Detail: `{"q":"b"}`})
|
||||
// 交错到达:B 先结束。
|
||||
p.onEvent(squad.ProcessEvent{Type: "tool_execution_end", Tool: "grep", Text: "命中 B"})
|
||||
p.onEvent(squad.ProcessEvent{Type: "tool_execution_end", Tool: "grep", Text: "路径不存在"})
|
||||
|
||||
parts := p.parts()
|
||||
calls := toolCallsOf(parts)
|
||||
if len(calls) != 2 || calls[0].ID != "grep" || calls[1].ID != "grep" {
|
||||
t.Fatalf("tool call parts = %+v", calls)
|
||||
}
|
||||
emittedCalls := p.calls()
|
||||
if len(emittedCalls) != 2 || emittedCalls[0].CallID != "grep" || emittedCalls[1].CallID != "grep" || emittedCalls[0].Name != "grep" {
|
||||
t.Fatalf("emitted tool calls = %+v", emittedCalls)
|
||||
}
|
||||
results := toolResultsOf(parts)
|
||||
if len(results) != 2 {
|
||||
t.Fatalf("tool result parts = %+v", results)
|
||||
}
|
||||
// 到达顺序保留:先到的 end 先落部件。
|
||||
if results[0].CallID != "grep" || results[0].Status != toolStatusOK || results[0].Summary != "命中 B" {
|
||||
t.Fatalf("result B = %+v", results[0])
|
||||
}
|
||||
if results[1].CallID != "grep" || results[1].Status != toolStatusOK || results[1].Summary != "路径不存在" {
|
||||
t.Fatalf("result A = %+v", results[1])
|
||||
}
|
||||
// 上游没给失败结局,宿主不得自己臆造一次失败。
|
||||
if p.rec.unresolvedFailure() != nil {
|
||||
t.Fatalf("no upstream verdict must not register a failure: %+v", p.rec.unresolvedFailure())
|
||||
}
|
||||
}
|
||||
|
||||
// TestSquadEventsOutcomeFollowsUpstreamFields:上游 main 移除了 ProcessEvent.Status /
|
||||
// ErrorReason(ToolStatus* 常量同时消失),结局只能取最接近的等价口径:恒为 ok,
|
||||
// 摘要取上游实际传来的工具结果文本(Text,为空时回退 Detail)。
|
||||
func TestSquadEventsOutcomeFollowsUpstreamFields(t *testing.T) {
|
||||
long := strings.Repeat("失败原因", 80)
|
||||
cases := []struct {
|
||||
name string
|
||||
ev squad.ProcessEvent
|
||||
status string
|
||||
summary string
|
||||
}{
|
||||
{"result text", squad.ProcessEvent{Type: "tool_execution_end", Tool: "grep", Text: "命中 3 处"}, toolStatusOK, "命中 3 处"},
|
||||
{"legacy detail", squad.ProcessEvent{Type: "tool_execution_end", Tool: "grep", Detail: "命中 3 处"}, toolStatusOK, "命中 3 处"},
|
||||
{"no payload", squad.ProcessEvent{Type: "tool_execution_end", Tool: "grep"}, toolStatusOK, ""},
|
||||
// 上游把失败原因也放在结果文本里(无独立字段)时不得丢。
|
||||
{"failure text", squad.ProcessEvent{Type: "tool_execution_end", Tool: "run_command", Text: "权限不足"}, toolStatusOK, "权限不足"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
status, summary := toolEventOutcome(tc.ev)
|
||||
if status != tc.status || summary != tc.summary {
|
||||
t.Fatalf("outcome = %q/%q, want %q/%q", status, summary, tc.status, tc.summary)
|
||||
}
|
||||
p := newEventProbe()
|
||||
p.onEvent(squad.ProcessEvent{Type: "tool_execution_start", Tool: tc.ev.Tool})
|
||||
p.onEvent(tc.ev)
|
||||
got := toolResultsOf(p.parts())
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("parts = %+v", p.parts())
|
||||
}
|
||||
// 落库部件与发射载荷同源。
|
||||
if got[0].Status != tc.status || got[0].Summary != tc.summary || got[0].CallID != tc.ev.Tool {
|
||||
t.Fatalf("result part = %+v", got[0])
|
||||
}
|
||||
wantPayload := core.ToolResultPayload{CallID: tc.ev.Tool, Status: tc.status, Summary: tc.summary}
|
||||
if gotPayloads := p.results(); len(gotPayloads) != 1 || gotPayloads[0] != wantPayload {
|
||||
t.Fatalf("emitted payload = %+v", gotPayloads)
|
||||
}
|
||||
if gotCalls := p.calls(); len(gotCalls) != 1 || gotCalls[0].CallID != tc.ev.Tool || gotCalls[0].Name != tc.ev.Tool {
|
||||
t.Fatalf("emitted tool call = %+v", gotCalls)
|
||||
}
|
||||
})
|
||||
}
|
||||
t.Run("summary truncated", func(t *testing.T) {
|
||||
status, summary := toolEventOutcome(squad.ProcessEvent{Text: long})
|
||||
if status != toolStatusOK {
|
||||
t.Fatalf("status = %q", status)
|
||||
}
|
||||
if want := truncateRun(long, toolResultSummaryLen); summary != want {
|
||||
t.Fatalf("summary = %q, want truncated %q", summary, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestSquadEventsLegacyFramesUnchanged:字段全空的 legacy 事件必须与旧行为字节一致
|
||||
// (按工具名配对、状态恒为 ok、摘要取 Detail)。
|
||||
func TestSquadEventsLegacyFramesUnchanged(t *testing.T) {
|
||||
p := newEventProbe()
|
||||
p.onEvent(squad.ProcessEvent{Type: "tool_execution_start", Tool: "grep", Detail: `{"q":"x"}`})
|
||||
p.onEvent(squad.ProcessEvent{Type: "tool_execution_end", Tool: "grep", Detail: strings.Repeat("d", 200)})
|
||||
p.onEvent(squad.ProcessEvent{Type: "text_delta", Text: "结论"})
|
||||
p.onEvent(squad.ProcessEvent{Type: "usage", PromptTokens: 3, CompletionTokens: 1, LastPromptTokens: 2, CacheReadTokens: 1})
|
||||
|
||||
parts := p.parts()
|
||||
calls := toolCallsOf(parts)
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("calls = %+v", calls)
|
||||
}
|
||||
wantCall := core.ToolCallPart{ID: "grep", Name: "grep", Arguments: `{"q":"x"}`}
|
||||
if calls[0] != wantCall {
|
||||
t.Fatalf("call part = %+v, want %+v", calls[0], wantCall)
|
||||
}
|
||||
results := toolResultsOf(parts)
|
||||
wantResult := core.ToolResultPart{CallID: "grep", Status: "ok", Summary: truncateRun(strings.Repeat("d", 200), 160)}
|
||||
if len(results) != 1 || results[0] != wantResult {
|
||||
t.Fatalf("result part = %+v, want %+v", results, wantResult)
|
||||
}
|
||||
emitted := p.results()
|
||||
if len(emitted) != 1 || emitted[0].CallID != "grep" || emitted[0].Status != "ok" ||
|
||||
emitted[0].Summary != truncateRun(strings.Repeat("d", 200), 160) {
|
||||
t.Fatalf("emitted = %+v", emitted)
|
||||
}
|
||||
if p.rec.reply() != "结论" {
|
||||
t.Fatalf("reply = %q", p.rec.reply())
|
||||
}
|
||||
if f := p.rec.unresolvedFailure(); f != nil {
|
||||
t.Fatalf("legacy frames must not register a failure: %+v", f)
|
||||
}
|
||||
if p.rec.finalReply("", "", "已完成。") != "已完成。" {
|
||||
t.Fatalf("legacy fallback text changed: %q", p.rec.finalReply("", "", "已完成。"))
|
||||
}
|
||||
}
|
||||
|
||||
// TestSquadEventsConcurrentCallbacksKeepEveryEntry:并行回调(-race)下不得丢条目、
|
||||
// 不得串字段;每次调用自己的 start/end 仍成对。身份退到工具名后用互不相同的
|
||||
// 工具名保证配对关系仍然可断言。
|
||||
func TestSquadEventsConcurrentCallbacksKeepEveryEntry(t *testing.T) {
|
||||
const calls = 64
|
||||
p := newEventProbe()
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < calls; i++ {
|
||||
name := fmt.Sprintf("tool_%02d", i)
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
p.onEvent(squad.ProcessEvent{Type: "tool_execution_start", Tool: name, Detail: name})
|
||||
p.onEvent(squad.ProcessEvent{Type: "tool_execution_end", Tool: name, Text: "ok " + name})
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
parts := p.parts()
|
||||
if got := len(toolCallsOf(parts)); got != calls {
|
||||
t.Fatalf("tool call parts = %d, want %d", got, calls)
|
||||
}
|
||||
results := toolResultsOf(parts)
|
||||
if len(results) != calls {
|
||||
t.Fatalf("tool result parts = %d, want %d", len(results), calls)
|
||||
}
|
||||
seenStart := map[string]bool{}
|
||||
for _, c := range toolCallsOf(parts) {
|
||||
if seenStart[c.ID] {
|
||||
t.Fatalf("duplicate start for %s", c.ID)
|
||||
}
|
||||
seenStart[c.ID] = true
|
||||
}
|
||||
emitted := map[string]string{}
|
||||
for _, r := range results {
|
||||
if _, dup := emitted[r.CallID]; dup {
|
||||
t.Fatalf("duplicate end for %s", r.CallID)
|
||||
}
|
||||
emitted[r.CallID] = r.Status
|
||||
if !seenStart[r.CallID] {
|
||||
t.Fatalf("end without start: %s", r.CallID)
|
||||
}
|
||||
}
|
||||
for id, status := range emitted {
|
||||
if status != toolStatusOK {
|
||||
t.Fatalf("end %s status = %q, want %q", id, status, toolStatusOK)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSquadEventsConcurrentToolCallbacksShareArtifactList:宿主工具被并行调用时,
|
||||
// 登记的工件一条都不能丢,snapshot 也不应把内部切片直接交出去。
|
||||
func TestSquadEventsConcurrentToolCallbacksShareArtifactList(t *testing.T) {
|
||||
const n = 128
|
||||
produced := &artifactList{}
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < n; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
produced.add(&core.ArtifactRef{ArtifactID: core.ID(fmt.Sprintf("art_%03d", i)), Name: "纪要.docx", Kind: "document"})
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
got := produced.snapshot()
|
||||
if len(got) != n {
|
||||
t.Fatalf("artifacts = %d, want %d", len(got), n)
|
||||
}
|
||||
ids := map[string]bool{}
|
||||
for _, ref := range got {
|
||||
ids[string(ref.ArtifactID)] = true
|
||||
}
|
||||
for i := 0; i < n; i++ {
|
||||
if !ids[fmt.Sprintf("art_%03d", i)] {
|
||||
t.Fatalf("missing artifact %d", i)
|
||||
}
|
||||
}
|
||||
got[0] = nil // 快照必须是副本:改动不得回写内部表
|
||||
if produced.snapshot()[0] == nil {
|
||||
t.Fatal("snapshot aliases the internal list")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSquadEventsFinalTextFollowsUpstreamOutcome:上游未给出失败结局时,宿主不得
|
||||
// 自己臆造一次失败去改写收尾文本——模型原话与兜底文案都保持不变,工具结果部件
|
||||
// 照实记录上游传来的结果文本。宿主侧的失败聚合结构原样保留,等上游恢复结局信号
|
||||
// 即重新生效。
|
||||
func TestSquadEventsFinalTextFollowsUpstreamOutcome(t *testing.T) {
|
||||
p := newEventProbe()
|
||||
p.onEvent(squad.ProcessEvent{Type: "tool_execution_start", Tool: "run_command"})
|
||||
p.onEvent(squad.ProcessEvent{Type: "tool_execution_end", Tool: "run_command", Text: "用户取消"})
|
||||
if got := p.rec.finalReply("", "", "已完成。"); got != "已完成。" {
|
||||
t.Fatalf("no upstream verdict must not rewrite the fallback: %q", got)
|
||||
}
|
||||
if got := p.rec.finalReply("已按你的要求处理。", "", "已完成。"); got != "已按你的要求处理。" {
|
||||
t.Fatalf("model text must be preserved: %q", got)
|
||||
}
|
||||
// 结果部件仍在最终部件里,不被收尾文本覆盖。
|
||||
if results := toolResultsOf(p.parts()); len(results) != 1 || results[0].Status != toolStatusOK || results[0].Summary != "用户取消" {
|
||||
t.Fatalf("result part dropped: %+v", results)
|
||||
}
|
||||
if f := p.rec.unresolvedFailure(); f != nil {
|
||||
t.Fatalf("no upstream verdict must not register a failure: %+v", f)
|
||||
}
|
||||
|
||||
// 摘要截断口径不变。
|
||||
long := strings.Repeat("结果", 200)
|
||||
q := newEventProbe()
|
||||
q.onEvent(squad.ProcessEvent{Type: "tool_execution_end", Tool: "grep", Text: long})
|
||||
got := toolResultsOf(q.parts())
|
||||
if len(got) != 1 || got[0].Summary != truncateRun(long, toolResultSummaryLen) {
|
||||
t.Fatalf("truncated summary = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// entryProbe 在真实运行桩上记录工具事件载荷,并让指定工具真的失败,
|
||||
// 用来端到端验证「工具结局 → 过程事件 → 落库部件」这条链。
|
||||
type entryProbe struct {
|
||||
*entryFake
|
||||
mu sync.Mutex
|
||||
calls []core.ToolCallPayload
|
||||
results []core.ToolResultPayload
|
||||
}
|
||||
|
||||
func (p *entryProbe) Emit(typ core.StreamEventType, payload any) error {
|
||||
switch v := payload.(type) {
|
||||
case core.ToolCallPayload:
|
||||
p.mu.Lock()
|
||||
p.calls = append(p.calls, v)
|
||||
p.mu.Unlock()
|
||||
case core.ToolResultPayload:
|
||||
p.mu.Lock()
|
||||
p.results = append(p.results, v)
|
||||
p.mu.Unlock()
|
||||
}
|
||||
return p.entryFake.Emit(typ, payload)
|
||||
}
|
||||
|
||||
func (p *entryProbe) ExecuteTool(call core.ToolCallRequest) core.ToolCallResult {
|
||||
if call.Name == "knowledge_search" {
|
||||
return core.ToolCallResult{Status: "error", Output: "索引不可用"}
|
||||
}
|
||||
return p.entryFake.ExecuteTool(call)
|
||||
}
|
||||
|
||||
func (p *entryProbe) resultPayloads() []core.ToolResultPayload {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
return append([]core.ToolResultPayload(nil), p.results...)
|
||||
}
|
||||
|
||||
// TestSquadEntryToolFramesReachFinalParts:真实跑一次小队入口,工具帧必须落成部件。
|
||||
//
|
||||
// 上游 squad.ProcessEvent 在 pin 前移后不再携带 CallID/Status:调用身份退到工具名,
|
||||
// 结局恒为 ok(上游没给判定就不臆造),摘要取上游传来的工具结果文本——失败工具
|
||||
// 的失败原因也在这段文本里,仍会如实展示给用户。
|
||||
func TestSquadEntryToolFramesReachFinalParts(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
base := baseEntry(dir)
|
||||
rc := &entryProbe{entryFake: base}
|
||||
rc.tools = []core.ToolDesc{{Name: "knowledge_search", Description: "查资料", Parameters: json.RawMessage(`{"type":"object"}`)}}
|
||||
|
||||
runner := &SquadRunner{
|
||||
Router: fixedRoute{mode: squad.RouteDirect}, Checker: squad.MinimalChecker{},
|
||||
Hitl: squad.HitlPolicy{Mode: squad.HitlDisabled},
|
||||
Stream: scriptedCalls(t, nil, [][]agentllm.Event{
|
||||
{{Kind: agentllm.KindMessageEnd, Message: agentllm.Message{Role: agentllm.RoleAssistant, StopReason: agentllm.StopToolCalls,
|
||||
ToolCalls: []agentllm.ToolCall{
|
||||
{ID: "call_k1", Name: "knowledge_search", Arguments: `{"q":"a"}`},
|
||||
{ID: "call_k2", Name: "knowledge_search", Arguments: `{"q":"b"}`},
|
||||
}}}},
|
||||
{{Kind: agentllm.KindMessageEnd, Message: agentllm.Message{Role: agentllm.RoleAssistant, StopReason: agentllm.StopToolCalls,
|
||||
ToolCalls: []agentllm.ToolCall{{ID: "w", Name: "write_file", Arguments: mustDocArgs()}}}}},
|
||||
{{Kind: agentllm.KindTextDelta, Text: "写完了。"},
|
||||
{Kind: agentllm.KindMessageEnd, Message: agentllm.Message{Role: agentllm.RoleAssistant, Content: "写完了。", StopReason: agentllm.StopEnd}}},
|
||||
}),
|
||||
}
|
||||
if err := runner.Execute(rc); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
parts := base.parts
|
||||
calls := toolCallsOf(parts)
|
||||
search := 0
|
||||
for _, c := range calls {
|
||||
if c.Name != "knowledge_search" {
|
||||
continue
|
||||
}
|
||||
search++
|
||||
if c.ID != "knowledge_search" {
|
||||
t.Fatalf("tool call part lost its identity: %+v (all %+v)", c, calls)
|
||||
}
|
||||
}
|
||||
if search != 2 {
|
||||
t.Fatalf("knowledge_search call parts = %d, want 2 (all %+v)", search, calls)
|
||||
}
|
||||
results := toolResultsOf(parts)
|
||||
var searchResults []core.ToolResultPart
|
||||
for _, r := range results {
|
||||
if r.CallID == "knowledge_search" {
|
||||
searchResults = append(searchResults, r)
|
||||
}
|
||||
}
|
||||
if len(searchResults) != 2 {
|
||||
t.Fatalf("tool results for knowledge_search = %+v", results)
|
||||
}
|
||||
for _, r := range searchResults {
|
||||
if r.Status != toolStatusOK {
|
||||
t.Fatalf("%s status = %q, want %q", r.CallID, r.Status, toolStatusOK)
|
||||
}
|
||||
if r.Summary != "索引不可用" {
|
||||
t.Fatalf("%s summary = %q", r.CallID, r.Summary)
|
||||
}
|
||||
}
|
||||
// 发射载荷与落库部件一致。
|
||||
emitted := 0
|
||||
for _, pl := range rc.resultPayloads() {
|
||||
if pl.CallID != "knowledge_search" {
|
||||
continue
|
||||
}
|
||||
emitted++
|
||||
if pl.Status != toolStatusOK || pl.Summary != "索引不可用" {
|
||||
t.Fatalf("emitted payload = %+v", pl)
|
||||
}
|
||||
}
|
||||
if emitted != 2 {
|
||||
t.Fatalf("emitted knowledge_search results = %d, want 2", emitted)
|
||||
}
|
||||
// 收尾文本保留模型原话。
|
||||
if last := parts[len(parts)-1]; last.Type != core.PartText || last.Text != "写完了。" {
|
||||
t.Fatalf("final part = %+v", last)
|
||||
}
|
||||
}
|
||||
@@ -30,37 +30,8 @@ func (s *SquadRunner) Execute(rc RunCtx) error {
|
||||
prof := rc.Profile()
|
||||
maxTurns, maxTools := loopCaps(prof.Budget)
|
||||
produced := &artifactList{}
|
||||
var streamed strings.Builder
|
||||
var toolParts []core.Part
|
||||
textIdx := 0
|
||||
onEvent := func(ev squad.ProcessEvent) {
|
||||
switch ev.Type {
|
||||
case "text_delta":
|
||||
if ev.Text == "" {
|
||||
return
|
||||
}
|
||||
streamed.WriteString(ev.Text)
|
||||
_ = rc.Emit(core.EvMessageDelta, core.MessageDeltaPayload{Index: textIdx, Text: ev.Text})
|
||||
case "reasoning_delta":
|
||||
if ev.Text != "" {
|
||||
_ = rc.Emit(core.EvThinkingDelta, core.ThinkingDeltaPayload{Text: ev.Text})
|
||||
}
|
||||
case "tool_execution_start":
|
||||
_ = rc.Emit(core.EvToolCall, core.ToolCallPayload{CallID: ev.Tool, Name: ev.Tool, Arguments: ev.Detail})
|
||||
toolParts = append(toolParts, core.Part{Type: core.PartToolCall, Process: true, ToolCall: &core.ToolCallPart{ID: ev.Tool, Name: ev.Tool, Arguments: ev.Detail}})
|
||||
case "tool_execution_end":
|
||||
_ = rc.Emit(core.EvToolResult, core.ToolResultPayload{CallID: ev.Tool, Status: "ok", Summary: truncateRun(ev.Detail, 160)})
|
||||
toolParts = append(toolParts, core.Part{Type: core.PartToolResult, Process: true, ToolResult: &core.ToolResultPart{CallID: ev.Tool, Status: "ok", Summary: truncateRun(ev.Detail, 160)}})
|
||||
case "usage":
|
||||
if sink, ok := rc.(interface{ AddUsage(core.Usage) }); ok {
|
||||
sink.AddUsage(core.Usage{
|
||||
PromptTokens: ev.PromptTokens, CompletionTokens: ev.CompletionTokens, TotalTokens: ev.PromptTokens + ev.CompletionTokens,
|
||||
LastPromptTokens: ev.LastPromptTokens, LastCompletionTokens: ev.LastCompletionTokens,
|
||||
CacheReadTokens: ev.CacheReadTokens, CacheWriteTokens: ev.CacheWriteTokens,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
// 过程帧可能来自并行的工具回调:聚合全部走 squadEventLog(内部互斥)。
|
||||
evlog := newSquadEventLog(rc)
|
||||
router := s.Router
|
||||
if router == nil {
|
||||
router = newModelRouter(rc)
|
||||
@@ -73,7 +44,7 @@ func (s *SquadRunner) Execute(rc RunCtx) error {
|
||||
DataDir: rc.Settings().GetString("storage.dataDir", "./data"),
|
||||
Goal: entryGoal(rc),
|
||||
HostTools: bridgeHostTools(rc, produced),
|
||||
OnEvent: onEvent,
|
||||
OnEvent: evlog.onEvent,
|
||||
Stream: stream,
|
||||
Router: router,
|
||||
Checker: s.Checker,
|
||||
@@ -90,14 +61,9 @@ func (s *SquadRunner) Execute(rc RunCtx) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
reply := strings.TrimSpace(streamed.String())
|
||||
if reply == "" {
|
||||
reply = strings.TrimSpace(res.Summary)
|
||||
}
|
||||
if reply == "" {
|
||||
reply = "已完成。"
|
||||
}
|
||||
if claimsFalseDeliverable(reply, produced.items, rc) {
|
||||
// 收尾文本:模型文本原样保留;仍有未恢复的工具失败时不用「已完成。」兜底。
|
||||
reply := evlog.finalReply(evlog.reply(), res.Summary, "已完成。")
|
||||
if claimsFalseDeliverable(reply, produced.snapshot(), rc) {
|
||||
return fmt.Errorf("声称已生成文件,但没有登记对应工件")
|
||||
}
|
||||
if strings.TrimSpace(res.Body) != "" {
|
||||
@@ -105,9 +71,9 @@ func (s *SquadRunner) Execute(rc RunCtx) error {
|
||||
return werr
|
||||
}
|
||||
}
|
||||
parts := append([]core.Part{}, toolParts...)
|
||||
parts := evlog.toolParts()
|
||||
parts = append(parts, core.Part{Type: core.PartText, Text: reply})
|
||||
for _, ref := range produced.items {
|
||||
for _, ref := range produced.snapshot() {
|
||||
parts = append(parts, core.Part{Type: core.PartArtifactRef, Artifact: ref})
|
||||
}
|
||||
return rc.SetAssistantParts(parts)
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -41,6 +42,7 @@ func TestCommandOperationLookupIsOwnerProjectScoped(t *testing.T) {
|
||||
}
|
||||
|
||||
// 写库之后才失败的命令:unknown + operationID(审计信封按会话快照落项目)。
|
||||
// agent-command main 契约禁止 unknown 携带 OperationID,句柄写在 Reason 里。
|
||||
if err := srv.Bus.RegisterPluginCommand(agentcommand.CommandSpec{ID: "demo.write", Title: "写入"},
|
||||
func(context.Context, cmdbus.Request) (any, error) { return nil, errors.New("ack lost") }); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -48,10 +50,17 @@ func TestCommandOperationLookupIsOwnerProjectScoped(t *testing.T) {
|
||||
resp := srv.Bus.Dispatch(context.Background(), cmdbus.Request{
|
||||
ID: "demo.write", Owner: "alice", Actor: "alice", ConversationID: conversation.ID,
|
||||
})
|
||||
if resp.Receipt.State != agentcommand.StateUnknown || resp.Receipt.OperationID == "" {
|
||||
const marker = " (operation "
|
||||
reason := resp.Receipt.Reason
|
||||
at := strings.Index(reason, marker)
|
||||
operationID := ""
|
||||
if resp.Receipt.State == agentcommand.StateUnknown && resp.Receipt.OperationID == "" &&
|
||||
at >= 0 && strings.HasSuffix(reason, ")") {
|
||||
operationID = reason[at+len(marker) : len(reason)-1]
|
||||
}
|
||||
if operationID == "" {
|
||||
t.Fatalf("post-execution failure must be unknown with an operation ID: %+v", resp.Receipt)
|
||||
}
|
||||
operationID := resp.Receipt.OperationID
|
||||
|
||||
get := func(key, url string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
|
||||
@@ -2252,7 +2252,7 @@ func (s *Store) upsertRolePermissionsTx(tx *sql.Tx, actorID, roleID string, curr
|
||||
for _, row := range writes {
|
||||
if _, err := tx.Exec(s.q(`INSERT INTO role_permissions(role_id,action_id,allowed,cap_allowed,permission_version,updated_by,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?) ON CONFLICT(role_id,action_id) DO UPDATE SET allowed=excluded.allowed,cap_allowed=excluded.cap_allowed,
|
||||
permission_version=permission_version+1,updated_by=excluded.updated_by,updated_at=excluded.updated_at`),
|
||||
permission_version=role_permissions.permission_version+1,updated_by=excluded.updated_by,updated_at=excluded.updated_at`),
|
||||
roleID, row.ActionID, row.Allowed, row.CapAllowed, 1, actorID, now); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user