Files
Agent-System-Backend/internal/runtime/squad_runner.go
T
weichen 44ccd63d3b Add trusted project context to work model calls
Why: Project work currently reaches model calls without current authorized project facts or a persisted input receipt.

Evidence: SELF go test ./... -count=1, targeted go test -race for W6 paths, go vet ./..., and CGO_ENABLED=0 go build ./cmd/agent-system passed on this change set.

Impact: Add optional X1 interfaces, trusted persisted work-task binding, request-scoped context injection, evidence reauthorization, and attempted/responded/unknown manifests. Existing non-project behavior stays available.

Refs: TT-C01 TT-C02 TT-C03 TT-C04; stage5 W6; Dev stage6 X1 public extension request r1
2026-10-14 04:09:58 +00:00

202 lines
6.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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{}
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,
})
}
}
}
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: onEvent,
Stream: stream,
Router: router,
Checker: s.Checker,
Hitl: s.Hitl,
MaxTurns: maxTurns,
MaxToolCalls: maxTools,
Model: ResolveChatModel(prof, rc.Settings()).Model,
})
if rc.Context().Err() != nil {
return rc.Context().Err()
}
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) {
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 := append([]core.Part{}, toolParts...)
parts = append(parts, core.Part{Type: core.PartText, Text: reply})
for _, ref := range produced.items {
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 := openLLMClient(route.Base, route.Key, route.Model)
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
}