Why: F14 backend half - tool events used the tool name as the call id and hardcoded status ok, so same-named tools could not be paired and failures displayed as success; parallel callbacks shared artifact state. Evidence: squad runner event tests pass (verify-u11c.json); pairing, error mapping and legacy passthrough cases added; artifact list locked. Impact: legacy empty-field events keep byte-identical output. Refs: feature/agent-stack-audit-remediation-b2 U11c
170 lines
5.3 KiB
Go
170 lines
5.3 KiB
Go
package runtime
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"strings"
|
||
|
||
agentllm "agent-native/runtime/llm"
|
||
"agent-squad/squad"
|
||
"agent-system-backend/internal/squadadapter"
|
||
core "agent-system-core"
|
||
)
|
||
|
||
// SquadRunner 是 runtime.entry=squad 时的对话入口。
|
||
// 小队先分诊:简单任务由 assistant 单步完成,复杂任务交给 leader。步骤都跑在内核上。
|
||
type SquadRunner struct {
|
||
Stream agentllm.StreamFunc
|
||
Router squad.Router
|
||
Checker squad.Checker
|
||
Hitl squad.HitlPolicy
|
||
}
|
||
|
||
func (s *SquadRunner) Name() string { return "squad" }
|
||
|
||
func (s *SquadRunner) Execute(rc RunCtx) error {
|
||
if rc.Context().Err() != nil {
|
||
return rc.Context().Err()
|
||
}
|
||
prof := rc.Profile()
|
||
maxTurns, maxTools := loopCaps(prof.Budget)
|
||
produced := &artifactList{}
|
||
// 过程帧可能来自并行的工具回调:聚合全部走 squadEventLog(内部互斥)。
|
||
evlog := newSquadEventLog(rc)
|
||
router := s.Router
|
||
if router == nil {
|
||
router = newModelRouter(rc)
|
||
}
|
||
stream := s.Stream
|
||
if stream != nil {
|
||
stream = GuardProjectStream(rc, entryGoal(rc), stream)
|
||
}
|
||
res, err := squadadapter.RunEntry(rc, squadadapter.EntryOptions{
|
||
DataDir: rc.Settings().GetString("storage.dataDir", "./data"),
|
||
Goal: entryGoal(rc),
|
||
HostTools: bridgeHostTools(rc, produced),
|
||
OnEvent: evlog.onEvent,
|
||
Stream: stream,
|
||
Router: router,
|
||
Checker: s.Checker,
|
||
Hitl: s.Hitl,
|
||
MaxTurns: maxTurns,
|
||
MaxToolCalls: maxTools,
|
||
// 模型走本次运行的生效配置(当前用户/项目 + 画像覆盖),与直连同一
|
||
// 解析规则:内核工厂用同一快照配端点/密钥,绝不与默认账号混搭。
|
||
Model: RunChatRoute(rc).Model,
|
||
})
|
||
if rc.Context().Err() != nil {
|
||
return rc.Context().Err()
|
||
}
|
||
if err != nil {
|
||
return err
|
||
}
|
||
// 收尾文本:模型文本原样保留;仍有未恢复的工具失败时不用「已完成。」兜底。
|
||
reply := evlog.finalReply(evlog.reply(), res.Summary, "已完成。")
|
||
if claimsFalseDeliverable(reply, produced.snapshot(), rc) {
|
||
return fmt.Errorf("声称已生成文件,但没有登记对应工件")
|
||
}
|
||
if strings.TrimSpace(res.Body) != "" {
|
||
if _, werr := rc.WriteArtifact(res.Name, "document", "text/markdown", []byte(res.Body)); werr != nil {
|
||
return werr
|
||
}
|
||
}
|
||
parts := evlog.toolParts()
|
||
parts = append(parts, core.Part{Type: core.PartText, Text: reply})
|
||
for _, ref := range produced.snapshot() {
|
||
parts = append(parts, core.Part{Type: core.PartArtifactRef, Artifact: ref})
|
||
}
|
||
return rc.SetAssistantParts(parts)
|
||
}
|
||
|
||
func entryGoal(rc RunCtx) string {
|
||
var b strings.Builder
|
||
b.WriteString(currentUserText(rc))
|
||
if listed, ok := rc.(interface{ WorkspaceInputs() []string }); ok {
|
||
if paths := listed.WorkspaceInputs(); len(paths) > 0 {
|
||
b.WriteString("\n工作区 inputs:")
|
||
b.WriteString(strings.Join(paths, "、"))
|
||
}
|
||
}
|
||
names := make([]string, 0, len(rc.Attachments()))
|
||
for _, a := range rc.Attachments() {
|
||
if a.Meta.Filename != "" {
|
||
names = append(names, a.Meta.Filename)
|
||
}
|
||
}
|
||
if len(names) > 0 {
|
||
b.WriteString("\n附件:")
|
||
b.WriteString(strings.Join(names, "、"))
|
||
}
|
||
return b.String()
|
||
}
|
||
|
||
type modelRouter struct {
|
||
stream agentllm.StreamFunc
|
||
model string
|
||
}
|
||
|
||
func newModelRouter(rc RunCtx) modelRouter {
|
||
route := ResolveLightweightModel(rc.Profile(), rc.Settings())
|
||
if route.Model == "" || route.Base == "" {
|
||
return modelRouter{}
|
||
}
|
||
client := OpenRouteClient(route, rc.Settings())
|
||
return modelRouter{stream: GuardProjectStream(rc, "", client.Stream), model: route.Model}
|
||
}
|
||
|
||
func (r modelRouter) Route(ctx context.Context, goal squad.Goal) (*squad.RouteDecision, error) {
|
||
if r.stream == nil || r.model == "" {
|
||
return &squad.RouteDecision{Mode: squad.RoutePlan, Family: "none", Reason: "未配置分诊模型,按复杂任务处理"}, nil
|
||
}
|
||
prompt := `只输出一个 JSON 对象,不要解释。字段 mode 取 direct 或 plan,family 取 minutes、report、proposal 或 none,reason 用一句话。简单问答和单步改写用 direct。需要成文、多步调研或会议材料用 plan。目标:` + goal.Text
|
||
var text string
|
||
for ev := range r.stream(ctx, r.model, []agentllm.Message{{Role: agentllm.RoleUser, Content: prompt}}, nil, agentllm.StreamOptions{}) {
|
||
switch ev.Kind {
|
||
case agentllm.KindTextDelta:
|
||
text += ev.Text
|
||
case agentllm.KindMessageEnd:
|
||
if ev.Message.Content != "" {
|
||
text = ev.Message.Content
|
||
}
|
||
case agentllm.KindError:
|
||
return &squad.RouteDecision{Mode: squad.RoutePlan, Family: "none", Reason: "分诊调用失败,按复杂任务处理"}, nil
|
||
}
|
||
}
|
||
return parseRoute(text), nil
|
||
}
|
||
|
||
func parseRoute(text string) *squad.RouteDecision {
|
||
dec := &squad.RouteDecision{Mode: squad.RoutePlan, Family: "none", Reason: "分诊结果无法解析,按复杂任务处理"}
|
||
start := strings.Index(text, "{")
|
||
end := strings.LastIndex(text, "}")
|
||
if start < 0 || end <= start {
|
||
return dec
|
||
}
|
||
var raw struct {
|
||
Mode string `json:"mode"`
|
||
Family string `json:"family"`
|
||
Reason string `json:"reason"`
|
||
}
|
||
if json.Unmarshal([]byte(text[start:end+1]), &raw) != nil {
|
||
return dec
|
||
}
|
||
switch raw.Mode {
|
||
case string(squad.RouteDirect):
|
||
dec.Mode = squad.RouteDirect
|
||
case string(squad.RoutePlan):
|
||
dec.Mode = squad.RoutePlan
|
||
default:
|
||
return dec
|
||
}
|
||
if raw.Family != "" {
|
||
dec.Family = raw.Family
|
||
}
|
||
if raw.Reason != "" {
|
||
dec.Reason = raw.Reason
|
||
}
|
||
return dec
|
||
}
|