Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7bcbbdb376 | ||
|
|
8cd4dd0ab7 | ||
|
|
fd69932c7d | ||
|
|
443e7af1b8 | ||
|
|
029917e843 | ||
|
|
9b40595728 | ||
|
|
7b8a34d1aa | ||
|
|
fc0a0505b3 | ||
|
|
f8e9e9f2d2 |
Vendored
+1
-1
Submodule deps/agent-native updated: ac91b8436e...cff69355f7
Vendored
+1
-1
Submodule deps/agent-squad updated: 0f1daa8b89...9edb6ba865
@@ -217,6 +217,24 @@ func TestResolveHITLSecondRoundKeepsTerminalStatus(t *testing.T) {
|
|||||||
case <-time.After(10 * time.Second):
|
case <-time.After(10 * time.Second):
|
||||||
t.Fatal("runner did not reach the second HITL question")
|
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)
|
second := pendingCard(t, st, conv, 1)
|
||||||
if got, err := st.GetRunByID(run.ID); err != nil || got.Status != core.RunWaitingHITL {
|
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)
|
t.Fatalf("round 2 status=%s err=%v (want waiting_hitl)", got.Status, err)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
"agent-native/adapters/native"
|
"agent-native/adapters/native"
|
||||||
"agent-native/runtime/tools"
|
"agent-native/runtime/tools"
|
||||||
@@ -25,14 +26,30 @@ type producedSink interface {
|
|||||||
add(ref *core.ArtifactRef)
|
add(ref *core.ArtifactRef)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// artifactList 是本次运行已登记的工件表。宿主工具可能被内核并行调用,
|
||||||
|
// 因此写入与读取都在同一把锁下:并发调用既不丢条目也不读到半成品。
|
||||||
type artifactList struct {
|
type artifactList struct {
|
||||||
|
mu sync.Mutex
|
||||||
items []*core.ArtifactRef
|
items []*core.ArtifactRef
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *artifactList) add(ref *core.ArtifactRef) {
|
func (a *artifactList) add(ref *core.ArtifactRef) {
|
||||||
if ref != nil {
|
if ref == nil {
|
||||||
a.items = append(a.items, ref)
|
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 把画像允许的产品工具包成内核宿主工具。
|
// bridgeHostTools 把画像允许的产品工具包成内核宿主工具。
|
||||||
|
|||||||
+104
-34
@@ -110,6 +110,13 @@ type toolEvidence struct {
|
|||||||
VerifyText string // 给验证器的尾部摘要,比界面上的摘要长
|
VerifyText string // 给验证器的尾部摘要,比界面上的摘要长
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// toolSlot 是一次在途工具调用的过程状态。
|
||||||
|
//
|
||||||
|
// 并行工具执行下槽位有两个并发访问方:工具 goroutine 回写结果字段,
|
||||||
|
// 内核事件回调(onEvent 的 tool_execution_end)在 toolMu 下读它们装部件。
|
||||||
|
// 字段一律经 directRun.writeSlot 在 toolMu 下访问,直接写即数据竞争。
|
||||||
|
// claimed 只在 toolMu 下访问:并行且无 ctx 注入的调用(宿主 UnknownTool
|
||||||
|
// 直调)按认领取槽位,两个 goroutine 不会写同一个槽位、结果不会互相覆盖。
|
||||||
type toolSlot struct {
|
type toolSlot struct {
|
||||||
call agentllm.ToolCall
|
call agentllm.ToolCall
|
||||||
start time.Time
|
start time.Time
|
||||||
@@ -117,6 +124,7 @@ type toolSlot struct {
|
|||||||
verifyText string
|
verifyText string
|
||||||
status string
|
status string
|
||||||
truncated bool
|
truncated bool
|
||||||
|
claimed bool // 已被某次工具执行认领(无注入路径据此避免撞槽)
|
||||||
artifact *core.ArtifactRef
|
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) {
|
func (st *directRun) noteKernelResult(ctx context.Context, res agenttools.Result) {
|
||||||
call, ok := nruntime.ToolCallFromContext(ctx)
|
call, ok := nruntime.ToolCallFromContext(ctx)
|
||||||
slot := st.slotFor("")
|
var slot *toolSlot
|
||||||
if ok {
|
if ok && call.ID != "" {
|
||||||
slot = st.slotFor(call.ID)
|
slot = st.slotFor(call.ID)
|
||||||
|
} else {
|
||||||
|
slot = st.slotFor("")
|
||||||
}
|
}
|
||||||
if slot == nil {
|
if slot == nil {
|
||||||
return
|
return
|
||||||
@@ -576,28 +586,74 @@ func (st *directRun) noteKernelResult(ctx context.Context, res agenttools.Result
|
|||||||
if res.IsError {
|
if res.IsError {
|
||||||
summary = "error: " + summary
|
summary = "error: " + summary
|
||||||
}
|
}
|
||||||
slot.summary = summary
|
status := statusOf(nil)
|
||||||
slot.status = statusOf(nil)
|
|
||||||
if res.IsError {
|
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 {
|
func (st *directRun) slotFor(callID string) *toolSlot {
|
||||||
st.toolMu.Lock()
|
st.toolMu.Lock()
|
||||||
defer st.toolMu.Unlock()
|
defer st.toolMu.Unlock()
|
||||||
if callID != "" {
|
if callID != "" {
|
||||||
return st.inflight[callID]
|
slot := st.inflight[callID]
|
||||||
|
if slot != nil {
|
||||||
|
slot.claimed = true
|
||||||
|
}
|
||||||
|
return slot
|
||||||
}
|
}
|
||||||
if len(st.inflight) == 1 {
|
var free *toolSlot
|
||||||
for _, slot := range st.inflight {
|
for _, slot := range st.inflight {
|
||||||
return slot
|
if !slot.claimed {
|
||||||
|
free = slot
|
||||||
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
if free != nil {
|
||||||
|
free.claimed = true
|
||||||
|
}
|
||||||
|
return free
|
||||||
}
|
}
|
||||||
|
|
||||||
// noteToolDelta 把模型正在编写的工具参数立刻转成会话事件。
|
// noteToolDelta 把模型正在编写的工具参数立刻转成会话事件。
|
||||||
@@ -725,18 +781,22 @@ func (st *directRun) onEvent(ev nruntime.Event) {
|
|||||||
st.toolMu.Unlock()
|
st.toolMu.Unlock()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// 锁内快照结果字段:工具回写(并行工具 goroutine)与本回调分属两个
|
||||||
|
// goroutine,解锁后再读槽位字段即数据竞争,事件只带快照走。
|
||||||
|
call, start := slot.call, slot.start
|
||||||
|
status, summary, truncated := slot.status, slot.summary, slot.truncated
|
||||||
end := st.l.now()
|
end := st.l.now()
|
||||||
st.finalParts = append(st.finalParts,
|
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.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: slot.call.ID, Status: slot.status, Summary: slot.summary}},
|
core.Part{Type: core.PartToolResult, Process: true, ToolResult: &core.ToolResultPart{CallID: call.ID, Status: status, Summary: summary}},
|
||||||
)
|
)
|
||||||
if slot.artifact != nil {
|
if slot.artifact != nil {
|
||||||
st.deliverables = append(st.deliverables, slot.artifact)
|
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.toolMu.Unlock()
|
||||||
st.noteFailureOutcome(slot.status)
|
st.noteFailureOutcome(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.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 {
|
if res.Artifact != nil && slot != nil {
|
||||||
slot.artifact = &core.ArtifactRef{ArtifactID: res.Artifact.ID, Name: res.Artifact.Name, Kind: res.Artifact.Kind}
|
st.writeSlot(slot, func(s *toolSlot) {
|
||||||
if st.cardflow && res.Truncated && !st.explicitCard {
|
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)))
|
fc, ferr := st.rc.SubmitCard(artifactFacadeCard(call.Name, *res.Artifact, summarize(out, nil)))
|
||||||
if ferr == 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 {
|
st.writeSlot(slot, func(s *toolSlot) { s.truncated = res.Truncated })
|
||||||
slot.truncated = res.Truncated
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
summary := summarize(out, nil)
|
summary := summarize(out, nil)
|
||||||
@@ -1148,11 +1208,12 @@ func (st *directRun) runToolCtx(ctx context.Context, name, args string) agenttoo
|
|||||||
summary = "error: " + err.Error()
|
summary = "error: " + err.Error()
|
||||||
out = summary
|
out = summary
|
||||||
}
|
}
|
||||||
if slot != nil {
|
verifyText := tailRunes(out, 600)
|
||||||
slot.summary = summary
|
st.writeSlot(slot, func(s *toolSlot) {
|
||||||
slot.status = status
|
s.summary = summary
|
||||||
slot.verifyText = tailRunes(out, 600)
|
s.status = status
|
||||||
}
|
s.verifyText = verifyText
|
||||||
|
})
|
||||||
if r := []rune(out); len(r) > 16000 {
|
if r := []rune(out); len(r) > 16000 {
|
||||||
out = string(r[:16000]) + "…(超长截断)"
|
out = string(r[:16000]) + "…(超长截断)"
|
||||||
}
|
}
|
||||||
@@ -1257,8 +1318,8 @@ func (st *directRun) submitCard(args string) string {
|
|||||||
if cerr != nil {
|
if cerr != nil {
|
||||||
return "submit_card 失败: " + cerr.Error()
|
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.appendFinalPart(core.Part{Type: core.PartCard, Card: &core.CardPart{Kind: string(core.CardKindResult), CardID: card.ID}})
|
||||||
st.explicitCard = true
|
st.markExplicitCard()
|
||||||
where := "对话"
|
where := "对话"
|
||||||
if a.Scope == "project" {
|
if a.Scope == "project" {
|
||||||
where = "项目"
|
where = "项目"
|
||||||
@@ -1311,18 +1372,21 @@ func (st *directRun) commitCard() string {
|
|||||||
if cerr != nil {
|
if cerr != nil {
|
||||||
return "card_commit 失败: " + cerr.Error()
|
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.appendFinalPart(core.Part{Type: core.PartCard, Card: &core.CardPart{Kind: string(core.CardKindResult), CardID: card.ID}})
|
||||||
st.explicitCard = true
|
st.markExplicitCard()
|
||||||
return fmt.Sprintf("已作为卡片发出(%s,card:%s)。对话中保留简短结论即可,不要重复卡片全文。", card.Title, card.ID)
|
return fmt.Sprintf("已作为卡片发出(%s,card:%s)。对话中保留简短结论即可,不要重复卡片全文。", card.Title, card.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (st *directRun) pinSubagentArtifacts(text string) {
|
func (st *directRun) pinSubagentArtifacts(text string) {
|
||||||
|
st.toolMu.Lock()
|
||||||
seen := map[string]bool{}
|
seen := map[string]bool{}
|
||||||
for _, ref := range st.deliverables {
|
for _, ref := range st.deliverables {
|
||||||
if ref != nil {
|
if ref != nil {
|
||||||
seen[ref.ArtifactID] = true
|
seen[ref.ArtifactID] = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
st.toolMu.Unlock()
|
||||||
|
pinned := make([]*core.ArtifactRef, 0, 4)
|
||||||
for _, id := range gates.ArtifactIDPattern.FindAllString(text, -1) {
|
for _, id := range gates.ArtifactIDPattern.FindAllString(text, -1) {
|
||||||
if seen[id] {
|
if seen[id] {
|
||||||
continue
|
continue
|
||||||
@@ -1332,8 +1396,14 @@ func (st *directRun) pinSubagentArtifacts(text string) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
seen[id] = true
|
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 {
|
func errString(err error) string {
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ type fakeRunCtx struct {
|
|||||||
cardflow bool
|
cardflow bool
|
||||||
events []emittedEvent
|
events []emittedEvent
|
||||||
eventsMu sync.Mutex
|
eventsMu sync.Mutex
|
||||||
|
stateMu sync.Mutex // 并行工具执行下会写回的假状态(计数/卡片/用量/后台)
|
||||||
assistant []core.Part
|
assistant []core.Part
|
||||||
cards []core.ActivityCard
|
cards []core.ActivityCard
|
||||||
streamCardID core.ID // 活跃流式卡(Begin/Append/Commit fake 语义)
|
streamCardID core.ID // 活跃流式卡(Begin/Append/Commit fake 语义)
|
||||||
@@ -80,8 +81,15 @@ func (f *fakeRunCtx) emittedEvents() []emittedEvent {
|
|||||||
defer f.eventsMu.Unlock()
|
defer f.eventsMu.Unlock()
|
||||||
return append([]emittedEvent(nil), f.events...)
|
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) {
|
func (f *fakeRunCtx) AddUsage(u core.Usage) {
|
||||||
|
f.stateMu.Lock()
|
||||||
|
defer f.stateMu.Unlock()
|
||||||
f.usageTotal.PromptTokens += u.PromptTokens
|
f.usageTotal.PromptTokens += u.PromptTokens
|
||||||
f.usageTotal.CompletionTokens += u.CompletionTokens
|
f.usageTotal.CompletionTokens += u.CompletionTokens
|
||||||
f.usageTotal.TotalTokens += u.TotalTokens
|
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) Retrieve(q core.RetrievalQuery) ([]core.RetrievalHit, error) { return nil, nil }
|
||||||
func (f *fakeRunCtx) ExecuteTool(call core.ToolCallRequest) core.ToolCallResult {
|
func (f *fakeRunCtx) ExecuteTool(call core.ToolCallRequest) core.ToolCallResult {
|
||||||
|
f.stateMu.Lock()
|
||||||
|
defer f.stateMu.Unlock()
|
||||||
f.toolRuns++
|
f.toolRuns++
|
||||||
res := core.ToolCallResult{Status: "ok", Output: "done", Truncated: f.execTruncated}
|
res := core.ToolCallResult{Status: "ok", Output: "done", Truncated: f.execTruncated}
|
||||||
if f.execStatus != "" {
|
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"}}}}
|
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) {
|
func (f *fakeRunCtx) SpawnBackground(agentID, name, task, hint string) (core.ID, error) {
|
||||||
|
f.stateMu.Lock()
|
||||||
|
defer f.stateMu.Unlock()
|
||||||
f.bgCalls++
|
f.bgCalls++
|
||||||
if f.bgErr != nil {
|
if f.bgErr != nil {
|
||||||
return "", f.bgErr
|
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) Tools() []core.ToolDesc { return nil }
|
||||||
func (f *fakeRunCtx) Subtask() string { return "" }
|
func (f *fakeRunCtx) Subtask() string { return "" }
|
||||||
func (f *fakeRunCtx) CreateCard(card core.ActivityCard) (core.ActivityCard, error) {
|
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))
|
card.ID = core.ID(fmt.Sprintf("card_%d", len(f.cards)+1))
|
||||||
f.cards = append(f.cards, card)
|
f.cards = append(f.cards, card)
|
||||||
return card, nil
|
return card, nil
|
||||||
@@ -199,7 +219,9 @@ func (f *fakeRunCtx) SubmitCard(card core.ActivityCard) (core.ActivityCard, erro
|
|||||||
// runmgr/cardsvc 语义,供工具编排层断言。
|
// runmgr/cardsvc 语义,供工具编排层断言。
|
||||||
func (f *fakeRunCtx) BeginStreamCard(card core.ActivityCard) (core.ActivityCard, error) {
|
func (f *fakeRunCtx) BeginStreamCard(card core.ActivityCard) (core.ActivityCard, error) {
|
||||||
card.Status = core.CardActive
|
card.Status = core.CardActive
|
||||||
created, err := f.CreateCard(card)
|
f.stateMu.Lock()
|
||||||
|
defer f.stateMu.Unlock()
|
||||||
|
created, err := f.createCardLocked(card)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return created, err
|
return created, err
|
||||||
}
|
}
|
||||||
@@ -208,6 +230,8 @@ func (f *fakeRunCtx) BeginStreamCard(card core.ActivityCard) (core.ActivityCard,
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeRunCtx) AppendStreamCard(text string) error {
|
func (f *fakeRunCtx) AppendStreamCard(text string) error {
|
||||||
|
f.stateMu.Lock()
|
||||||
|
defer f.stateMu.Unlock()
|
||||||
if f.streamCardID == "" {
|
if f.streamCardID == "" {
|
||||||
return errors.New("card: no active stream card (card_begin first)")
|
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) {
|
func (f *fakeRunCtx) CommitStreamCard() (core.ActivityCard, error) {
|
||||||
|
f.stateMu.Lock()
|
||||||
|
defer f.stateMu.Unlock()
|
||||||
if f.streamCardID == "" {
|
if f.streamCardID == "" {
|
||||||
return core.ActivityCard{}, errors.New("card: no active stream card (card_begin first)")
|
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")
|
return core.ActivityCard{}, errors.New("card: not found")
|
||||||
}
|
}
|
||||||
func (f *fakeRunCtx) Card(id string) *core.ActivityCard {
|
func (f *fakeRunCtx) Card(id string) *core.ActivityCard {
|
||||||
|
f.stateMu.Lock()
|
||||||
|
defer f.stateMu.Unlock()
|
||||||
for i := range f.cards {
|
for i := range f.cards {
|
||||||
if string(f.cards[i].ID) == id {
|
if string(f.cards[i].ID) == id {
|
||||||
return &f.cards[i]
|
return &f.cards[i]
|
||||||
|
|||||||
@@ -0,0 +1,188 @@
|
|||||||
|
package runtime
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"agent-squad/squad"
|
||||||
|
core "agent-system-core"
|
||||||
|
)
|
||||||
|
|
||||||
|
// toolResultSummaryLen 是工具结果摘要的截断长度(与既有展示一致)。
|
||||||
|
const toolResultSummaryLen = 160
|
||||||
|
|
||||||
|
// toolEventID 是宿主侧的工具调用身份:优先用上游 CallID(并行同名工具据此配对,
|
||||||
|
// 而不是按到达顺序);空串 = legacy/未知,退回工具名,保持既有展示不变。
|
||||||
|
func toolEventID(ev squad.ProcessEvent) string {
|
||||||
|
if id := strings.TrimSpace(ev.CallID); id != "" {
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
return ev.Tool
|
||||||
|
}
|
||||||
|
|
||||||
|
// toolEventOutcome 把上游 tool_execution_end 翻译成宿主展示的真实结局。
|
||||||
|
//
|
||||||
|
// 取值域与上游一致(ok | error | denied | canceled),不新增、不改写上游状态:
|
||||||
|
// - Status 非空:原样透传;
|
||||||
|
// - Status 为空且没有失败信号(ErrorReason 也空):legacy/未知,沿用旧的 "ok"
|
||||||
|
// 与 Detail 摘要(字节一致,不臆造结局);
|
||||||
|
// - Status 为空但有 ErrorReason:有失败信号而状态未知,报 error 而不是 ok;
|
||||||
|
// - 非 ok 时摘要取 ErrorReason 原文(截断),上游没给原因才回退 Detail。
|
||||||
|
func toolEventOutcome(ev squad.ProcessEvent) (status, summary string) {
|
||||||
|
status = strings.TrimSpace(ev.Status)
|
||||||
|
if status == "" {
|
||||||
|
if strings.TrimSpace(ev.ErrorReason) == "" {
|
||||||
|
return squad.ToolStatusOK, truncateRun(ev.Detail, toolResultSummaryLen)
|
||||||
|
}
|
||||||
|
status = squad.ToolStatusError
|
||||||
|
}
|
||||||
|
if status == squad.ToolStatusOK {
|
||||||
|
return status, truncateRun(ev.Detail, toolResultSummaryLen)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(ev.ErrorReason) != "" {
|
||||||
|
return status, truncateRun(ev.ErrorReason, toolResultSummaryLen)
|
||||||
|
}
|
||||||
|
return status, truncateRun(ev.Detail, 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 == squad.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 决定终局文本。模型文本原样保留(不代写、不改写);只有宿主兜底的
|
||||||
|
// 「已完成。」在仍有未恢复的工具失败时会被换成如实说明,避免收尾文本与失败部件
|
||||||
|
// 自相矛盾。legacy 事件(无 CallID/Status)走不到这里,展示与旧行为一致。
|
||||||
|
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,443 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSquadEventsPairByCallID:两个同名工具的 start/end 必须按 CallID 配对,
|
||||||
|
// 不得因名字相同而互相对调(这是并行同名工具显示错乱的根因)。
|
||||||
|
func TestSquadEventsPairByCallID(t *testing.T) {
|
||||||
|
p := newEventProbe()
|
||||||
|
p.onEvent(squad.ProcessEvent{Type: "tool_execution_start", Tool: "grep", CallID: "call_a", Detail: `{"q":"a"}`})
|
||||||
|
p.onEvent(squad.ProcessEvent{Type: "tool_execution_start", Tool: "grep", CallID: "call_b", Detail: `{"q":"b"}`})
|
||||||
|
// 交错到达:B 先结束。
|
||||||
|
p.onEvent(squad.ProcessEvent{Type: "tool_execution_end", Tool: "grep", CallID: "call_b", Status: squad.ToolStatusOK, Detail: "命中 B"})
|
||||||
|
p.onEvent(squad.ProcessEvent{Type: "tool_execution_end", Tool: "grep", CallID: "call_a", Status: squad.ToolStatusError, ErrorReason: "路径不存在"})
|
||||||
|
|
||||||
|
parts := p.parts()
|
||||||
|
calls := toolCallsOf(parts)
|
||||||
|
if len(calls) != 2 || calls[0].ID != "call_a" || calls[1].ID != "call_b" {
|
||||||
|
t.Fatalf("tool call parts = %+v", calls)
|
||||||
|
}
|
||||||
|
emittedCalls := p.calls()
|
||||||
|
if len(emittedCalls) != 2 || emittedCalls[0].CallID != "call_a" || emittedCalls[1].CallID != "call_b" {
|
||||||
|
t.Fatalf("emitted tool calls = %+v", emittedCalls)
|
||||||
|
}
|
||||||
|
results := toolResultsOf(parts)
|
||||||
|
if len(results) != 2 {
|
||||||
|
t.Fatalf("tool result parts = %+v", results)
|
||||||
|
}
|
||||||
|
if results[0].CallID != "call_b" || results[0].Status != squad.ToolStatusOK || results[0].Summary != "命中 B" {
|
||||||
|
t.Fatalf("result B = %+v", results[0])
|
||||||
|
}
|
||||||
|
if results[1].CallID != "call_a" || results[1].Status != squad.ToolStatusError || results[1].Summary != "路径不存在" {
|
||||||
|
t.Fatalf("result A = %+v", results[1])
|
||||||
|
}
|
||||||
|
if p.rec.unresolvedFailure() == nil || p.rec.unresolvedFailure().CallID != "call_a" {
|
||||||
|
t.Fatalf("unresolved failure = %+v", p.rec.unresolvedFailure())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSquadEventsStatusIsTruthful:失败/取消/拒绝必须原样透传,不得一律报 ok,
|
||||||
|
// 摘要取真实原因(截断),部件与发射载荷完全一致。
|
||||||
|
func TestSquadEventsStatusIsTruthful(t *testing.T) {
|
||||||
|
long := strings.Repeat("失败原因", 80)
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
ev squad.ProcessEvent
|
||||||
|
status string
|
||||||
|
summary string
|
||||||
|
}{
|
||||||
|
{"error", squad.ProcessEvent{Type: "tool_execution_end", Tool: "grep", CallID: "c1",
|
||||||
|
Status: squad.ToolStatusError, ErrorReason: "权限不足", Detail: "旧摘要"}, squad.ToolStatusError, "权限不足"},
|
||||||
|
{"denied", squad.ProcessEvent{Type: "tool_execution_end", Tool: "run_command", CallID: "c2",
|
||||||
|
Status: squad.ToolStatusDenied, ErrorReason: "策略拒绝执行"}, squad.ToolStatusDenied, "策略拒绝执行"},
|
||||||
|
{"canceled", squad.ProcessEvent{Type: "tool_execution_end", Tool: "web_search", CallID: "c3",
|
||||||
|
Status: squad.ToolStatusCanceled, ErrorReason: "用户取消"}, squad.ToolStatusCanceled, "用户取消"},
|
||||||
|
{"ok", squad.ProcessEvent{Type: "tool_execution_end", Tool: "grep", CallID: "c4",
|
||||||
|
Status: squad.ToolStatusOK, Detail: "命中 3 处"}, squad.ToolStatusOK, "命中 3 处"},
|
||||||
|
// 有失败信号但状态未知:不得报 ok。
|
||||||
|
{"status-less error signal", squad.ProcessEvent{Type: "tool_execution_end", Tool: "grep", CallID: "c5",
|
||||||
|
ErrorReason: "连接被拒绝", Detail: "旧摘要"}, squad.ToolStatusError, "连接被拒绝"},
|
||||||
|
}
|
||||||
|
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, CallID: tc.ev.CallID})
|
||||||
|
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.CallID {
|
||||||
|
t.Fatalf("result part = %+v", got[0])
|
||||||
|
}
|
||||||
|
wantPayload := core.ToolResultPayload{CallID: tc.ev.CallID, 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.CallID || 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{Status: squad.ToolStatusError, ErrorReason: long})
|
||||||
|
if status != squad.ToolStatusError {
|
||||||
|
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()
|
||||||
|
wantStatus := map[string]string{}
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for i := 0; i < calls; i++ {
|
||||||
|
id := fmt.Sprintf("call_%02d", i)
|
||||||
|
wantStatus[id] = squad.ToolStatusOK
|
||||||
|
failing := i%8 == 0
|
||||||
|
if failing {
|
||||||
|
wantStatus[id] = squad.ToolStatusError
|
||||||
|
}
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
p.onEvent(squad.ProcessEvent{Type: "tool_execution_start", Tool: "grep", CallID: id, Detail: id})
|
||||||
|
if failing {
|
||||||
|
p.onEvent(squad.ProcessEvent{Type: "tool_execution_end", Tool: "grep", CallID: id,
|
||||||
|
Status: squad.ToolStatusError, ErrorReason: "boom " + id})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p.onEvent(squad.ProcessEvent{Type: "tool_execution_end", Tool: "grep", CallID: id,
|
||||||
|
Status: squad.ToolStatusOK, Detail: "ok " + id})
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
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 != wantStatus[id] {
|
||||||
|
t.Fatalf("end %s status = %q, want %q", id, status, wantStatus[id])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSquadEventsFinalTextMatchesToolOutcome:未恢复的失败不得被收尾文本改写成成功;
|
||||||
|
// 同名工具重试成功后收尾文本恢复旧行为。
|
||||||
|
func TestSquadEventsFinalTextMatchesToolOutcome(t *testing.T) {
|
||||||
|
p := newEventProbe()
|
||||||
|
p.onEvent(squad.ProcessEvent{Type: "tool_execution_start", Tool: "run_command", CallID: "c1"})
|
||||||
|
p.onEvent(squad.ProcessEvent{Type: "tool_execution_end", Tool: "run_command", CallID: "c1",
|
||||||
|
Status: squad.ToolStatusCanceled, ErrorReason: "用户取消"})
|
||||||
|
if got := p.rec.finalReply("", "", "已完成。"); got == "已完成。" || !strings.Contains(got, "run_command") || !strings.Contains(got, "用户取消") {
|
||||||
|
t.Fatalf("fallback text = %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 != squad.ToolStatusCanceled {
|
||||||
|
t.Fatalf("failure part dropped: %+v", results)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 同名工具重试成功 → 失败记为已恢复,兜底文本回到旧行为。
|
||||||
|
q := newEventProbe()
|
||||||
|
q.onEvent(squad.ProcessEvent{Type: "tool_execution_start", Tool: "run_command", CallID: "c1"})
|
||||||
|
q.onEvent(squad.ProcessEvent{Type: "tool_execution_end", Tool: "run_command", CallID: "c1",
|
||||||
|
Status: squad.ToolStatusError, ErrorReason: "超时"})
|
||||||
|
q.onEvent(squad.ProcessEvent{Type: "tool_execution_start", Tool: "run_command", CallID: "c2"})
|
||||||
|
q.onEvent(squad.ProcessEvent{Type: "tool_execution_end", Tool: "run_command", CallID: "c2",
|
||||||
|
Status: squad.ToolStatusOK, Detail: "done"})
|
||||||
|
if f := q.rec.unresolvedFailure(); f != nil {
|
||||||
|
t.Fatalf("retry should clear the failure: %+v", f)
|
||||||
|
}
|
||||||
|
if got := q.rec.finalReply("", "", "已完成。"); got != "已完成。" {
|
||||||
|
t.Fatalf("recovered run fallback = %q", 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...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSquadEntryFailedToolReachesFinalParts:真实跑一次小队入口,同名并行工具
|
||||||
|
// 必须按各自 CallID 落部件,失败必须显示为失败,且不被收尾文本改写成成功。
|
||||||
|
func TestSquadEntryFailedToolReachesFinalParts(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)
|
||||||
|
ids := map[string]bool{}
|
||||||
|
for _, c := range calls {
|
||||||
|
if c.Name != "knowledge_search" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if c.ID == "knowledge_search" || ids[c.ID] {
|
||||||
|
t.Fatalf("tool call part lost its call id: %+v (all %+v)", c, calls)
|
||||||
|
}
|
||||||
|
ids[c.ID] = true
|
||||||
|
}
|
||||||
|
if len(ids) != 2 {
|
||||||
|
t.Fatalf("distinct call ids = %v", ids)
|
||||||
|
}
|
||||||
|
results := toolResultsOf(parts)
|
||||||
|
search := map[string]core.ToolResultPart{}
|
||||||
|
for _, r := range results {
|
||||||
|
if ids[r.CallID] {
|
||||||
|
search[r.CallID] = r
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(search) != 2 {
|
||||||
|
t.Fatalf("tool results for knowledge_search = %+v", results)
|
||||||
|
}
|
||||||
|
for id, r := range search {
|
||||||
|
if r.Status != squad.ToolStatusError {
|
||||||
|
t.Fatalf("%s status = %q, want error", id, r.Status)
|
||||||
|
}
|
||||||
|
if r.Summary != "索引不可用" {
|
||||||
|
t.Fatalf("%s summary = %q", id, r.Summary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 发射载荷与落库部件一致:前端过程行不得说 ok。
|
||||||
|
emitted := map[string]string{}
|
||||||
|
for _, pl := range rc.resultPayloads() {
|
||||||
|
if ids[pl.CallID] {
|
||||||
|
emitted[pl.CallID] = pl.Status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for id, r := range search {
|
||||||
|
if emitted[id] != r.Status {
|
||||||
|
t.Fatalf("emitted %s status = %q, part status = %q", id, emitted[id], r.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 收尾文本保留模型原话,失败部件不被覆盖。
|
||||||
|
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()
|
prof := rc.Profile()
|
||||||
maxTurns, maxTools := loopCaps(prof.Budget)
|
maxTurns, maxTools := loopCaps(prof.Budget)
|
||||||
produced := &artifactList{}
|
produced := &artifactList{}
|
||||||
var streamed strings.Builder
|
// 过程帧可能来自并行的工具回调:聚合全部走 squadEventLog(内部互斥)。
|
||||||
var toolParts []core.Part
|
evlog := newSquadEventLog(rc)
|
||||||
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,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
router := s.Router
|
router := s.Router
|
||||||
if router == nil {
|
if router == nil {
|
||||||
router = newModelRouter(rc)
|
router = newModelRouter(rc)
|
||||||
@@ -73,7 +44,7 @@ func (s *SquadRunner) Execute(rc RunCtx) error {
|
|||||||
DataDir: rc.Settings().GetString("storage.dataDir", "./data"),
|
DataDir: rc.Settings().GetString("storage.dataDir", "./data"),
|
||||||
Goal: entryGoal(rc),
|
Goal: entryGoal(rc),
|
||||||
HostTools: bridgeHostTools(rc, produced),
|
HostTools: bridgeHostTools(rc, produced),
|
||||||
OnEvent: onEvent,
|
OnEvent: evlog.onEvent,
|
||||||
Stream: stream,
|
Stream: stream,
|
||||||
Router: router,
|
Router: router,
|
||||||
Checker: s.Checker,
|
Checker: s.Checker,
|
||||||
@@ -90,14 +61,9 @@ func (s *SquadRunner) Execute(rc RunCtx) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
reply := strings.TrimSpace(streamed.String())
|
// 收尾文本:模型文本原样保留;仍有未恢复的工具失败时不用「已完成。」兜底。
|
||||||
if reply == "" {
|
reply := evlog.finalReply(evlog.reply(), res.Summary, "已完成。")
|
||||||
reply = strings.TrimSpace(res.Summary)
|
if claimsFalseDeliverable(reply, produced.snapshot(), rc) {
|
||||||
}
|
|
||||||
if reply == "" {
|
|
||||||
reply = "已完成。"
|
|
||||||
}
|
|
||||||
if claimsFalseDeliverable(reply, produced.items, rc) {
|
|
||||||
return fmt.Errorf("声称已生成文件,但没有登记对应工件")
|
return fmt.Errorf("声称已生成文件,但没有登记对应工件")
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(res.Body) != "" {
|
if strings.TrimSpace(res.Body) != "" {
|
||||||
@@ -105,9 +71,9 @@ func (s *SquadRunner) Execute(rc RunCtx) error {
|
|||||||
return werr
|
return werr
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
parts := append([]core.Part{}, toolParts...)
|
parts := evlog.toolParts()
|
||||||
parts = append(parts, core.Part{Type: core.PartText, Text: reply})
|
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})
|
parts = append(parts, core.Part{Type: core.PartArtifactRef, Artifact: ref})
|
||||||
}
|
}
|
||||||
return rc.SetAssistantParts(parts)
|
return rc.SetAssistantParts(parts)
|
||||||
|
|||||||
@@ -2252,7 +2252,7 @@ func (s *Store) upsertRolePermissionsTx(tx *sql.Tx, actorID, roleID string, curr
|
|||||||
for _, row := range writes {
|
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)
|
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,
|
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 {
|
roleID, row.ActionID, row.Allowed, row.CapAllowed, 1, actorID, now); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user