Files
sundynix-agentix/sundynix-dispatcher/internal/eino/graph.go
T
Blizzard 66caeef35c feat(agent): 多智能体协同 v1 —— coordinator 节点(Eino×Anthropic 融合)
LLM 自主在 agent 间路由/委派:orchestrator=ReAct(Eino 出机器),编排认知按 Anthropic
orchestrator-worker 配方(出脑子)。专家=包成工具的子 agent(agent-as-tool),lead 给
每个专家写定制简报(brief)后并行派发、综合。方案见 MULTI_AGENT.md。

为什么 agent-as-tool 而非 Eino host:host 的 specialist 拿原始输入(preHandler
return state.msgs),传不了 lead 写的定制简报,而定制简报正是 Anthropic 多智能体的
精髓。agent-as-tool 让 orchestrator 自己 emit 工具调用、参数 brief 即简报。
= OpenAI agent.as_tool() / Anthropic 研究系统的 orchestrator-worker。

- coordinator.go: specialistTool(react.Agent/ChatModel 包成 InvokableTool,入参 brief,
  精炼返回) + parseSpecialists/buildSpecialists(带工具→react,不带→ChatModel,MCP 工具
  按 spec.tools 过滤) + runCoordinator(lead 提示词=Anthropic 配方) + leadOrchestratorPrompt。
- 双路接入 execDSLNode(compose)+ runGraph(graph.go)的 case coordinator。
- 护栏:禁套娃(专家是内联叶子)/ MaxStep / 专家 I/O 计入共享 Budget / 降级(无
  ToolCallingModel 或 0 专家 → runAgent)。
- streamAgentReply:抽出 runReactAgent 与 runCoordinator 共用的流式回流尾段。
- 复用即得:evaluator-optimizer=harness 低分纠偏;成本天花板=预算护栏;上下文隔离=
  专家独立 react.Agent;观测=每次派发落 agent 轨迹。

测试:parseSpecialists / agent-as-tool 包装(brief 透传+精炼返回+失败作观察) / 降级。
live 验证(真 deepseek):两专家**并行派发**、lead 给各自写**不同定制简报**、最终
**综合**(非拼接)成稿,评测 1.00。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 15:37:23 +08:00

621 lines
22 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 eino
import (
"context"
"fmt"
"strconv"
"strings"
"time"
"github.com/cloudwego/eino/schema"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/trace"
"github.com/sundynix/sundynix-dispatcher/internal/dsl"
"github.com/sundynix/sundynix-dispatcher/internal/harness"
"github.com/sundynix/sundynix-shared/contract"
"github.com/sundynix/sundynix-shared/otelx"
)
// defaultAgentSystem 是 agent 节点未填系统提示词时的兜底。
const defaultAgentSystem = "你是 sundynix-agentix 平台的 AI 助手。"
// board 是图执行的"黑板":节点按拓扑序流转时读写它。
type board struct {
uid, sid string
query string
profile string
history []*schema.Message
kb string // 最近一个检索节点的 owner 作用域库名(供 map 并行各项检索)
refs []string // 检索 / 聚合得到的参考资料
toolOut []string // 工具节点产出
sections []reportSection // map 并行 fan-out 产出的分项成稿(供 render 多章渲染)
answer string // 当前成稿(多 agent 协作时 = 最近一个 agent 的产出 = 成品)
agentOut []string // 各上游 agent 的产出(按序),注入下游 agent 上下文以实现接力协作
rejected bool // HITL 审批节点拒绝/超时 → 置位,runGraph 中止并返回 errRejected
fatalErr error // agent 节点 LLM 调用失败 → 置位,runGraph 中止并上抛 → 任务判 failed(而非 done-空)
}
// runGraph 按 DSL 图的真实拓扑与连线执行(替代旧的线性拍平 compileFlow)。
//
// 入度0 入口 → 沿连线传播 active → 每个节点按 kind 执行真实行为 →
// branch 按条件只激活选中的下游(剪枝)→ agent 节点流式回流 token。
//
// 逐节点点亮"运行·观测"。返回终端 agent 的完整产出(供写回历史)。
func (o *Orchestrator) runGraph(ctx context.Context, t *contract.Task, tr *execTracer) (string, []string, error) {
flow, ferr := dsl.Parse(t.Graph)
plan := dsl.Compile(t.Graph)
b := &board{
uid: meta(t, contract.MetaUserID),
sid: meta(t, contract.MetaSessionID),
query: plan.Query,
}
// 无法解析或空图:退化为"无图单轮对话"(注入默认记忆 + 直接出模型)。
if ferr != nil || flow == nil || len(flow.Nodes) == 0 {
tr.info("task", "system", "无结构化图", "按单轮对话执行")
b.profile = o.fetchMemory(ctx, b.uid, b.query)
b.history = o.fetchHistory(ctx, b.sid)
o.runConversation(ctx, t.ID, b, plan.System, tr, "agent")
return b.answer, refsOf(b), b.fatalErr // 模型失败 → 上抛判 failed
}
// 建邻接与入度(只认两端都存在的边)。保留整条边以便 branch 按 true/false 标签选路。
nodeByID := make(map[string]dsl.Node, len(flow.Nodes))
outE := make(map[string][]dsl.Edge)
indeg := make(map[string]int, len(flow.Nodes))
for _, n := range flow.Nodes {
nodeByID[n.ID] = n
indeg[n.ID] = 0
}
for _, e := range flow.Edges {
if _, ok := nodeByID[e.Source]; !ok {
continue
}
if _, ok := nodeByID[e.Target]; !ok {
continue
}
outE[e.Source] = append(outE[e.Source], e)
indeg[e.Target]++
}
// 入口节点(入度 0)置 active;执行时沿连线把下游激活,branch 只激活选中分支。
active := make(map[string]bool)
for _, n := range flow.Nodes {
if indeg[n.ID] == 0 {
active[n.ID] = true
}
}
// 图里没有 memory 节点 → 沿用旧默认:注入画像+历史(避免回归)。
hasMemory := false
for _, n := range flow.Nodes {
if n.Kind == "memory" {
hasMemory = true
break
}
}
if !hasMemory {
b.profile = o.fetchMemory(ctx, b.uid, b.query)
b.history = o.fetchHistory(ctx, b.sid)
}
for _, n := range flow.Topo() {
if !active[n.ID] {
continue // 被 branch 剪掉的下游,不执行
}
// 每个节点一个 span:下游的工具调用 / LLM span 会自动挂到本节点之下,
// Jaeger 里即呈现「节点 → 工具 → embedding/milvus」的层级。
nctx, nspan := otelx.Tracer().Start(ctx, "node."+n.Kind,
trace.WithAttributes(
attribute.String("sundynix.node.id", n.ID),
attribute.String("sundynix.node.kind", n.Kind),
))
propagate := targetsOf(outE[n.ID]) // 默认激活全部出边;branch 会改写
switch n.Kind {
case "input":
if txt := cstr(n.Config, "text"); txt != "" {
b.query = txt
}
tr.info("input:"+n.ID, "system", labelOf(n, "输入"), truncate(b.query, 80))
case "memory":
if cbool(n.Config, "profile") {
b.profile = o.fetchMemory(nctx, b.uid, b.query)
}
if cbool(n.Config, "history") {
b.history = o.fetchHistory(nctx, b.sid)
}
tr.info("memory:"+n.ID, "memory", labelOf(n, "记忆"),
fmt.Sprintf("画像 %d 字 · 历史 %d 条", len([]rune(b.profile)), len(b.history)))
case "retriever":
o.retrieverNode(nctx, n, b, tr)
case "tool":
o.execToolNode(nctx, t.ID, n, b, tr)
case "agent":
sys := firstNonEmpty(cstr(n.Config, "system"), plan.System)
if cbool(n.Config, "autonomous") { // 开启自主工具 → ReAct(模型自己选工具)
o.runReactAgent(nctx, t.ID, b, sys, n, tr, "agent:"+n.ID)
} else {
o.runConversation(nctx, t.ID, b, sys, tr, "agent:"+n.ID)
}
case "coordinator": // 多智能体协调:orchestrator 自主把子任务派给专家(agent-as-tool)再综合
o.runCoordinator(nctx, t.ID, b, firstNonEmpty(cstr(n.Config, "system"), plan.System), n, tr, "coordinator:"+n.ID)
case "aggregate":
merged := aggregate(cstr(n.Config, "strategy"), append(append([]string{}, b.refs...), b.toolOut...))
b.refs, b.toolOut = merged, nil
tr.info("aggregate:"+n.ID, "system", labelOf(n, "汇聚"), "策略:"+firstNonEmpty(cstr(n.Config, "strategy"), "拼接"))
case "render":
o.renderNode(nctx, t.ID, n, b, tr)
case "branch":
propagate = o.branchNode(n, b, outE[n.ID], nodeByID, tr)
case "approval":
propagate = o.approvalNode(nctx, t.ID, n, b, tr, outE[n.ID])
case "map":
o.mapNode(nctx, t.ID, n, b, tr)
case "output":
tr.info("output:"+n.ID, "system", labelOf(n, "输出"), "目标:"+firstNonEmpty(cstr(n.Config, "target"), "屏幕"))
default:
tr.info(n.Kind+":"+n.ID, "system", labelOf(n, n.Kind), "未识别节点,跳过")
}
nspan.End()
if b.rejected || b.fatalErr != nil {
break // 审批拒绝/超时 或 模型失败:中止后续节点
}
for _, tgt := range propagate {
active[tgt] = true
}
}
if b.rejected {
return b.answer, nil, errRejected // 合法终态,Handle 据此判 rejected 并优雅收尾
}
if b.fatalErr != nil {
return b.answer, nil, b.fatalErr // 上抛 → Handle 判 failed(带原因)
}
// 图里无 agent 节点(纯工具/检索图)也要出一段模型答复,否则没有输出。
if b.answer == "" {
o.runConversation(ctx, t.ID, b, plan.System, tr, "agent")
}
if b.fatalErr != nil {
return b.answer, nil, b.fatalErr
}
return b.answer, refsOf(b), nil // 成功:带回检索来源供忠实度评测
}
// refsOf 汇总本次执行的检索来源(检索资料 + 工具产出),供忠实度评测。
func refsOf(b *board) []string {
out := append([]string{}, b.refs...)
return append(out, b.toolOut...)
}
// retrieverNode 执行检索节点:kb 按 owner 作用域 → kb_search → 累计参考资料。
func (o *Orchestrator) retrieverNode(ctx context.Context, n dsl.Node, b *board, tr *execTracer) {
kb := cstr(n.Config, "kb")
scoped := kb
if b.uid != "" && kb != "" && !strings.Contains(kb, "/") {
scoped = b.uid + "/" + kb
}
if scoped != "" {
b.kb = scoped // 记下作用域库名,供后续 map 并行各项检索复用
}
end := tr.span("retriever:"+n.ID, "tool", labelOf(n, "检索"))
refs := o.retrieve(ctx, scoped, b.query)
if refs != "" {
b.refs = append(b.refs, refs)
}
end(fmt.Sprintf("kb=%s · 命中 %d 段", firstNonEmpty(kb, "(未指定)"), countLines(refs)), nil)
}
// mapNode 执行并行 fan-out:把主题拆成若干项,各项有界并发撰写(复用 report 的 writeSections),
// 成稿存入黑板(结构化 sections + 拼进 answer),并流式呈现进度。
func (o *Orchestrator) mapNode(ctx context.Context, taskID string, n dsl.Node, b *board, tr *execTracer) {
end := tr.span("map:"+n.ID, "plan", labelOf(n, "并行 fan-out"))
items := o.planItems(ctx, b.query, cstr(n.Config, "splitBy"))
end(fmt.Sprintf("拆出 %d 项:%s", len(items), strings.Join(items, " / ")), nil)
o.emit(taskID, fmt.Sprintf("\n> 并行处理 %d 项…\n\n", len(items)))
secs := o.writeSections(ctx, b.query, b.kb, items, tr) // 有界并发,trace 出 section:i 各项
b.sections = secs
for _, s := range secs {
chunk := "## " + s.Heading + "\n\n" + s.Body + "\n\n"
o.emit(taskID, chunk)
b.answer += chunk
b.refs = append(b.refs, s.Heading+""+s.Body)
}
}
// execToolNode 执行工具节点:调 MCP 工具,产出累计进黑板;失败降级不阻断。
func (o *Orchestrator) execToolNode(ctx context.Context, taskID string, n dsl.Node, b *board, tr *execTracer) {
tool, args := dsl.ToolBinding(n)
if tool == "" {
return
}
node := "tool:" + tool
if o.tools == nil {
tr.info(node, "tool", "工具 "+tool, "工具总线未接入,跳过")
return
}
call := map[string]any{}
for k, v := range args {
call[k] = v
}
if call["q"] == nil && call["query"] == nil {
call["q"] = b.query
}
if b.uid != "" {
if kbv, ok := call["kb"].(string); ok && kbv != "" && !strings.Contains(kbv, "/") {
call["kb"] = b.uid + "/" + kbv
}
}
end := tr.span(node, "tool", "调用工具 "+tool)
cctx, cancel := context.WithTimeout(ctx, toolCallTimeout)
defer cancel()
res, err := o.tools.CallTool(cctx, contract.ToolSubjectGo(tool), &contract.ToolCall{Tool: tool, TaskID: taskID, Args: call})
if err != nil {
end("调用失败,降级跳过", err)
return
}
if res == nil || !res.OK || res.Content == "" {
end("无结果,降级跳过", nil)
return
}
end("入参 "+previewArgs(call)+" → 产出 "+truncate(res.Content, 160), nil)
b.toolOut = append(b.toolOut, "["+tool+"] "+res.Content)
}
// runAgent 执行 agent/模型节点:据黑板拼消息 → 流式回流 token → 累计成稿。
func (o *Orchestrator) runAgent(ctx context.Context, taskID string, b *board, system string, tr *execTracer, node string) {
rc := &RunCtx{
System: firstNonEmpty(system, defaultAgentSystem),
Query: b.query,
Profile: b.profile,
History: b.history,
ToolOut: append(append([]string{}, b.toolOut...), b.refs...),
Upstream: append([]string{}, b.agentOut...), // 前序协作 agent 产出 → 接力
}
msgs, _ := buildMessages(ctx, rc)
// 成本护栏:计入本节点输入 token;若已触顶则中止整图(防失控成本)。
if bud := harness.BudgetFrom(ctx); bud != nil {
for _, m := range msgs {
bud.AddPrompt(m.Content)
}
if bud.Exceeded() {
if b.fatalErr == nil {
b.fatalErr = errBudget
}
tr.emit(node, "system", "error", "token 预算", "已达单任务预算上限,中止", 0)
return
}
}
tr.emit(node, "model", "start", "模型流式推理", "", 0)
t0 := time.Now()
n := 0
var produced strings.Builder // 本节点自身产出(用于沿图向下游传递)
// 输出护栏:有状态流式脱敏,跨分片缓冲,杜绝密钥被切断而漏检(逐片正则会漏跨片密钥)。
red := harness.NewStreamRedactor()
emit := func(safe string) {
if safe == "" {
return
}
_ = o.sink.PublishToken(taskID, []byte(safe))
produced.WriteString(safe)
n++
}
send := func(s string) { emit(red.Push(s)) }
// reasoning 模型(本地 Qwen3 思考 / DeepSeek-R1 / QwQ 等)的「思考过程」:不进答案、
// 累计后 surface 到观测轨迹(让用户看到模型在想什么,又不污染最终回答)。
var reasoning strings.Builder
onReasoning := func(s string) { reasoning.WriteString(s) }
var err error
if o.pool.Ready() {
err = o.pool.ChatStream(ctx, toChatMessages(msgs), send, onReasoning)
} else {
err = o.pool.StreamText(ctx, replyFor(msgs), func(tok []byte) { send(string(tok)) })
}
if rc := reasoning.String(); rc != "" {
tr.info(node, "model", "推理过程", fmt.Sprintf("思考 %d 字:%s", len([]rune(rc)), truncate(rc, 200)))
}
if err != nil {
tr.emit(node, "model", "error", "模型流式推理", err.Error(), time.Since(t0).Milliseconds())
// 未产出任何 token 即失败 → 标记致命错,让任务判 failed(暴露原因,便于监控告警),
// 而非静默 done-空。已流出部分 token 的中断也算失败(结果不完整)。
if b.fatalErr == nil {
b.fatalErr = fmt.Errorf("agent 模型推理失败: %w", err)
}
return
}
emit(red.Flush()) // 吐出暂留的尾部(最后一段疑似密钥的判定)
if bud := harness.BudgetFrom(ctx); bud != nil {
bud.AddComplete(produced.String()) // 成本护栏:计入本节点输出 token
}
if red.Hits() > 0 {
tr.info(node, "system", "输出护栏", fmt.Sprintf("已脱敏 %d 处疑似密钥/PII", red.Hits()))
}
o.recordAgentOutput(b, produced.String()) // 产出入黑板:成当前成稿 + 供下游接力
tr.emit(node, "model", "end", "模型流式推理",
fmt.Sprintf("%d tokens / %d 字", n, len([]rune(produced.String()))), time.Since(t0).Milliseconds())
}
// recordAgentOutput 把一个 agent 节点的产出记入黑板:append 到 agentOut(供下游 agent 注入接力),
// 并设为当前成稿 answer(多 agent 协作时,最后一个 agent 的产出即最终成品)。空产出忽略。
func (o *Orchestrator) recordAgentOutput(b *board, out string) {
if strings.TrimSpace(out) == "" {
return
}
b.agentOut = append(b.agentOut, out)
b.answer = out
}
// renderNode 执行渲染节点:把当前成稿渲染成 Word(经 mcp-go report_render)。
func (o *Orchestrator) renderNode(ctx context.Context, taskID string, n dsl.Node, b *board, tr *execTracer) {
if strings.TrimSpace(b.answer) == "" {
tr.info("render:"+n.ID, "render", labelOf(n, "渲染"), "暂无正文可渲染(render 前需有 agent 产出)")
return
}
format := firstNonEmpty(cstr(n.Config, "format"), "docx")
end := tr.span("render:"+n.ID, "render", labelOf(n, "渲染 "+format))
title := truncate(b.query, 40)
secs := b.sections // map 产出的多章优先;否则把整段成稿当单章
if len(secs) == 0 {
secs = []reportSection{{Heading: title, Body: b.answer}}
}
if path := o.renderReport(ctx, taskID, title, secs); path != "" {
end("已落盘:"+path, nil)
o.emit(taskID, "\n\n---\n✅ 已渲染 "+format+" 文档,可在「下载」获取。\n")
} else {
end("渲染服务不可用", fmt.Errorf("render unavailable"))
}
}
// branchNode 执行分支节点:求值条件,按出边的 true/false 标签选出要激活的下游。
// 边带 sourceHandle("true"/"false") 时按标签精确选路(前端 Phase 2 给 branch 的边打标);
// 无标签的旧图退回"出边顺序 [true,false]"约定,保持向后兼容。
func (o *Orchestrator) branchNode(n dsl.Node, b *board, outs []dsl.Edge, byID map[string]dsl.Node, tr *execTracer) []string {
cond := cstr(n.Config, "condition")
res := evalCondition(cond, b)
var truthy, falsy []string
labeled := false
for _, e := range outs {
switch e.SourceHandle {
case "true":
truthy = append(truthy, e.Target)
labeled = true
case "false":
falsy = append(falsy, e.Target)
labeled = true
}
}
var chosen []string
mode := "边标签"
if labeled {
if res {
chosen = truthy
} else {
chosen = falsy
}
} else {
mode = "边序约定[true,false]"
targets := targetsOf(outs)
switch {
case len(targets) >= 2:
if res {
chosen = targets[:1]
} else {
chosen = targets[1:2]
}
case len(targets) == 1 && !res:
chosen = nil
default:
chosen = targets
}
}
names := make([]string, 0, len(chosen))
for _, id := range chosen {
names = append(names, labelOf(byID[id], id))
}
tr.info("branch:"+n.ID, "system", labelOf(n, "分支"),
fmt.Sprintf("条件「%s」→ %v ⇒ 走 [%s]%s",
firstNonEmpty(cond, "(空=真)"), res, strings.Join(names, ", "), mode))
return chosen
}
// approvalNode 是 HITL 人工审批中断:执行到此暂停,把待审摘要推给 UI(exec 事件 kind=approval/phase=await
// + 状态 waiting),阻塞等人工批准/拒绝(带超时,安全默认拒绝)。
// 批准 → 状态回 running 并放行下游;拒绝/超时 → 置 b.rejected 中止全图。返回应激活的下游(拒绝=空)。
func (o *Orchestrator) approvalNode(ctx context.Context, taskID string, n dsl.Node, b *board, tr *execTracer, outs []dsl.Edge) []string {
title, summary := approvalSummary(n, b)
// 未接审批通道(单测/降级)→ 自动放行,避免无人应答卡死。
if o.approval == nil {
tr.info("approval:"+n.ID, "approval", title, "未接审批通道,自动放行")
return targetsOf(outs)
}
// 暂停:发待审事件(UI 据 kind=approval & phase=await 弹批准/拒绝)+ 置任务 waiting。
tr.emit("approval:"+n.ID, "approval", "await", title, summary, 0)
o.setStatus(taskID, contract.TaskWaiting, title)
dec, err := o.approval.WaitApproval(ctx, taskID, approvalTimeout)
if err != nil { // 超时 / ctx 取消 → 安全默认拒绝
b.rejected = true
b.answer = "❌ 审批超时未决,已自动拒绝:" + title
tr.emit("approval:"+n.ID, "approval", "error", title, "审批超时,自动拒绝", 0)
return nil
}
// 批准/拒绝的落盘与终态事件与中断式审批共用,杜绝两路文案/语义漂移。
o.applyApprovalDecision(taskID, n, b, dec, dec.Approved, tr)
if b.rejected {
return nil
}
return targetsOf(outs)
}
// approvalSummary 据审批节点配置 + 当前成稿,算出待审标题与摘要(带产出预览,便于审批人判断)。
func approvalSummary(n dsl.Node, b *board) (title, summary string) {
title = firstNonEmpty(cstr(n.Config, "title"), labelOf(n, "人工审批"))
prompt := firstNonEmpty(cstr(n.Config, "prompt"), "请审批是否继续执行后续步骤")
summary = prompt
if b.answer != "" {
summary = prompt + "\n—— 当前产出预览 ——\n" + truncate(b.answer, 400)
}
return title, summary
}
// applyApprovalDecision 把一次人工决定落到黑板并发终态事件:拒绝 → 置 rejected + 拒绝语;
// 批准 → 状态回 running 放行。阻塞式(approvalNode)与中断式(approvalInterruptLambda)审批共用。
func (o *Orchestrator) applyApprovalDecision(taskID string, n dsl.Node, b *board, dec *contract.ApprovalDecision, approved bool, tr *execTracer) {
title := firstNonEmpty(cstr(n.Config, "title"), labelOf(n, "人工审批"))
if !approved {
note := "审批人拒绝"
if dec != nil {
note = firstNonEmpty(dec.Note, note)
}
b.rejected = true
b.answer = "❌ 已被拒绝:" + note
tr.emit("approval:"+n.ID, "approval", "end", title, "拒绝:"+note, 0)
return
}
note := "放行"
if dec != nil {
note = firstNonEmpty(dec.Note, note)
}
o.setStatus(taskID, contract.TaskRunning, "审批通过,继续执行")
tr.emit("approval:"+n.ID, "approval", "end", title, "批准:"+note, 0)
}
// targetsOf 取一组边的目标节点 ID(保持顺序)。
func targetsOf(edges []dsl.Edge) []string {
out := make([]string, 0, len(edges))
for _, e := range edges {
out = append(out, e.Target)
}
return out
}
// evalCondition 求值 branch 条件。支持:
//
// 空 → true;关键字 refs/tools/answer/profile 作左值(取数量/字数);
// 形如 "a op b"op: >= <= == != > <)数值比较;其余非空 → 默认真。
func evalCondition(cond string, b *board) bool {
cond = strings.TrimSpace(cond)
if cond == "" {
return true
}
for _, op := range []string{">=", "<=", "==", "!=", ">", "<"} {
if i := strings.Index(cond, op); i >= 0 {
l := resolveOperand(strings.TrimSpace(cond[:i]), b)
r := resolveOperand(strings.TrimSpace(cond[i+len(op):]), b)
switch op {
case ">":
return l > r
case "<":
return l < r
case ">=":
return l >= r
case "<=":
return l <= r
case "==":
return l == r
case "!=":
return l != r
}
}
}
return true
}
// resolveOperand 把条件里的左/右值解析为数值(关键字取运行时数量,否则按字面量)。
func resolveOperand(s string, b *board) float64 {
switch strings.ToLower(s) {
case "refs":
return float64(len(b.refs))
case "tools":
return float64(len(b.toolOut))
case "answer":
return float64(len([]rune(b.answer)))
case "profile":
return float64(len([]rune(b.profile)))
}
f, _ := strconv.ParseFloat(s, 64)
return f
}
// aggregate 按策略合并多段参考资料为一段。
func aggregate(strategy string, parts []string) []string {
var nonEmpty []string
for _, p := range parts {
if strings.TrimSpace(p) != "" {
nonEmpty = append(nonEmpty, p)
}
}
if len(nonEmpty) == 0 {
return nil
}
switch strategy {
case "去重合并":
seen := map[string]bool{}
var uniq []string
for _, p := range nonEmpty {
if !seen[p] {
seen[p] = true
uniq = append(uniq, p)
}
}
return []string{strings.Join(uniq, "\n---\n")}
case "摘要":
return []string{truncate(strings.Join(nonEmpty, "\n"), 800)}
default: // 拼接
return []string{strings.Join(nonEmpty, "\n---\n")}
}
}
// ---- 小工具 ----
func meta(t *contract.Task, key string) string {
v, _ := t.Meta[key].(string)
return v
}
func cstr(cfg map[string]any, key string) string {
if cfg == nil {
return ""
}
v, ok := cfg[key]
if !ok || v == nil {
return ""
}
if s, ok := v.(string); ok {
return strings.TrimSpace(s)
}
return strings.TrimSpace(fmt.Sprint(v))
}
func cbool(cfg map[string]any, key string) bool {
if cfg == nil {
return false
}
if v, ok := cfg[key].(bool); ok {
return v
}
return false
}
func labelOf(n dsl.Node, def string) string {
if strings.TrimSpace(n.Label) != "" {
return n.Label
}
return def
}
func countLines(s string) int {
s = strings.TrimSpace(s)
if s == "" {
return 0
}
return strings.Count(s, "\n") + 1
}