// Package eino 封装基于 CloudWeGo Eino 的 Agent 图编排引擎。 package eino import ( "context" "encoding/json" "errors" "fmt" "log" "log/slog" "os" "strconv" "strings" "sync" "time" "github.com/cloudwego/eino/components/model" "github.com/cloudwego/eino/schema" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/codes" "go.opentelemetry.io/otel/trace" "github.com/sundynix/sundynix-dispatcher/internal/dsl" "github.com/sundynix/sundynix-dispatcher/internal/harness" "github.com/sundynix/sundynix-dispatcher/internal/llm" "github.com/sundynix/sundynix-shared/contract" "github.com/sundynix/sundynix-shared/otelx" "github.com/sundynix/sundynix-shared/prompts" ) // TokenSink 是 Token 流回流出口(由 NATS bus 实现)。 type TokenSink interface { PublishToken(taskID string, token []byte) error CompleteStream(taskID string) error } // ToolCaller 经 NATS 调起第 5 层 MCP 工具(由 NATS bus 实现)。 type ToolCaller interface { CallTool(ctx context.Context, subject string, call *contract.ToolCall) (*contract.ToolResult, error) } // StatusSink 回写任务生命周期状态(由 NATS bus 实现;可为 nil → 不回写)。 type StatusSink interface { PublishTaskStatus(taskID, status, detail string) error } // ApprovalWaiter 阻塞等待审批节点的人工决定(由 NATS bus 实现;可为 nil → 审批节点自动放行)。 type ApprovalWaiter interface { WaitApproval(ctx context.Context, taskID string, timeout time.Duration) (*contract.ApprovalDecision, error) } // EvalSink 回写自动化评测结果供网关落库(由 NATS bus 实现;可为 nil → 仅打日志不落库)。 type EvalSink interface { PublishEval(ev *contract.EvalEvent) error } // UsageSink 回写任务 token 用量供网关累计/计费(由 NATS bus 实现;可为 nil → 不回写)。 type UsageSink interface { PublishUsage(ev *contract.UsageEvent) error } // errBudget 是单任务 token 预算触顶时的哨兵错误:任务以 failed 收尾并附明确原因(防失控成本)。 var errBudget = errors.New("token 预算超限,已中止") // errRejected 是审批节点拒绝(或超时)时图执行返回的哨兵错误:它是合法终态而非故障, // Handle 据此判 rejected 并优雅收尾(不计熔断失败)。 var errRejected = errors.New("approval rejected") // errInterrupted 是 HITL 审批中断(compose checkpoint)时图执行返回的哨兵错误:任务停在 // waiting、checkpoint 已落、goroutine 即可释放;非故障非终态,等人工决定到达后 resume 续跑。 var errInterrupted = errors.New("approval interrupted (checkpointed)") // LLM 是编排所需的语言模型能力(生产由 *llm.Pool 实现)。抽成接口便于测试注入假模型。 type LLM interface { Ready() bool ChatStream(ctx context.Context, msgs []llm.ChatMessage, onToken func(string), onReasoning func(string)) error StreamText(ctx context.Context, text string, onToken func([]byte)) error Chat(ctx context.Context, msgs []llm.ChatMessage) (string, error) // ToolCallingModel 返回支持函数调用的模型(ReAct agent 用);不支持则返回 nil。 ToolCallingModel() model.ToolCallingChatModel // ChatModel 返回 Eino ChatModel 组件(compose.Graph 编排用);未就绪则 nil。 ChatModel() model.BaseChatModel } // 工具调用超时;超时即降级(不带工具上下文继续推理)。 const toolCallTimeout = 3 * time.Second // taskExecTimeout 是单个任务整体执行上限;超时即判 timeout(状态机),避免无限期"运行中"。 // 含 HITL 审批等待预算(approvalTimeout)+ 常规图执行;须 < bus 消费者 AckWait(15min) 以免重投。 const taskExecTimeout = 10 * time.Minute // approvalTimeout 是单个审批节点等待人工决定的上限;超时安全默认拒绝(fail-safe)。 const approvalTimeout = 5 * time.Minute // specialistTimeout 是多智能体单个专家派发的执行上限;超时即作为"观察"跳过该专家 // (不中断整个协调),避免单专家卡死拖垮 lead 无限等待。专家可能多轮 react+工具调用,故给较宽。 const specialistTimeout = 3 * time.Minute // Orchestrator 把每个 DSL 任务动态编译为 Eino 图并执行(记忆召回 → 工具节点 → 注入 → 流式)。 type Orchestrator struct { pool LLM breaker *harness.CircuitBreaker eval *harness.Evaluator sink TokenSink tools ToolCaller exec ExecSink status StatusSink // 任务生命周期状态回写(可为 nil) approval ApprovalWaiter // HITL 审批等待(可为 nil → 审批节点自动放行) evalSink EvalSink // 评测结果回写落库(可为 nil → 仅打日志) guard *harness.Classifier // 输入护栏 Tier2:对网关标记的灰区任务做 LLM 裁决(可为 nil → 不做) usageSink UsageSink // token 用量回写(可为 nil → 不回写) checkpoints CheckpointKV // HITL 持久化中断的 checkpoint 后端(可为 nil → 审批走阻塞模型) turnMu sync.Mutex // 保护 turns(攒批计数,多任务 goroutine 共享) turns map[string]int // sessionID → 累计轮次,用于每 N 轮触发 consolidate } // NewOrchestrator 持有依赖;图按任务的 DSL 在 Handle 内动态编译。 // exec 为执行可视化事件出口(可为 nil);eval 为自动化评测(可为 nil);status 为状态回写(可为 nil); // approval 为 HITL 审批等待(可为 nil);evalSink 为评测结果落库出口(可为 nil)。 func NewOrchestrator(pool LLM, breaker *harness.CircuitBreaker, eval *harness.Evaluator, sink TokenSink, tools ToolCaller, exec ExecSink, status StatusSink, approval ApprovalWaiter, evalSink EvalSink) (*Orchestrator, error) { return &Orchestrator{pool: pool, breaker: breaker, eval: eval, sink: sink, tools: tools, exec: exec, status: status, approval: approval, evalSink: evalSink}, nil } // SetGuardian 注入输入护栏 Tier2 的 LLM 分类器(可选;不注入则灰区任务直接放行执行)。 func (o *Orchestrator) SetGuardian(c *harness.Classifier) { o.guard = c } // SetUsageSink 注入 token 用量回写出口(可选;不注入则不上报用量)。 func (o *Orchestrator) SetUsageSink(s UsageSink) { o.usageSink = s } // SetCheckpoints 注入 HITL 持久化中断的 checkpoint 后端(可选)。注入后审批节点改走 // compose.Interrupt 中断模型(落 checkpoint、释放 goroutine、抗重启);不注入则维持阻塞模型。 func (o *Orchestrator) SetCheckpoints(kv CheckpointKV) { o.checkpoints = kv } // taskBudget 取本任务的 token 预算上限:优先 Meta(网关按用户/套餐下发),否则 env TASK_TOKEN_BUDGET(默认 20 万)。 func (o *Orchestrator) taskBudget(t *contract.Task) int { switch n := t.Meta[contract.MetaTokenBudget].(type) { case float64: if n > 0 { return int(n) } case int: if n > 0 { return n } } return envInt("TASK_TOKEN_BUDGET", 200000) } // emitUsage 任务收尾回写本轮 token 用量(用量为 0 或无出口则跳过)。 func (o *Orchestrator) emitUsage(t *contract.Task, b *harness.Budget) { if o.usageSink == nil { return } p, c, total := b.Snapshot() if total == 0 { return } uid, _ := t.Meta[contract.MetaUserID].(string) tid, _ := t.Meta[contract.MetaTenantID].(string) if err := o.usageSink.PublishUsage(&contract.UsageEvent{ TaskID: t.ID, UserID: uid, TenantID: tid, PromptTok: p, CompTok: c, TotalTok: total, Exceeded: b.Exceeded(), TS: time.Now().UnixMilli(), }); err != nil { log.Printf("[usage] 回写用量失败 task=%s: %v", t.ID, err) } } // envInt 读正整数环境变量,缺省回退 def。 func envInt(key string, def int) int { if v := os.Getenv(key); v != "" { if n, err := strconv.Atoi(v); err == nil && n > 0 { return n } } return def } // setStatus 回写一次任务状态流转(status 为 nil 时静默跳过)。 func (o *Orchestrator) setStatus(taskID, status, detail string) { if o.status == nil { return } if err := o.status.PublishTaskStatus(taskID, status, detail); err != nil { log.Printf("[eino] 回写任务状态 %s=%s 失败: %v", taskID, status, err) } } // finishStatus 据收尾错误把任务置为 done / timeout / failed。 func (o *Orchestrator) finishStatus(taskID string, err error) { switch { case err == nil: o.setStatus(taskID, contract.TaskDone, "") case errors.Is(err, context.DeadlineExceeded): o.setStatus(taskID, contract.TaskTimeout, "执行超时") default: o.setStatus(taskID, contract.TaskFailed, truncate(err.Error(), 200)) } } // Handle 消费一个任务:按 DSL 编译 Eino 图并执行,把 Token 流回流到 sundynix.streams.。 func (o *Orchestrator) Handle(ctx context.Context, t *contract.Task) error { // 护栏:丢弃空任务(无 id),避免误投/历史脏数据被当真任务处理并触发状态回写放大。 if t.ID == "" { log.Printf("[eino] 跳过空任务(无 id)") return nil } // 链路根(dispatcher 侧):续上 gateway 经 NATS 传来的 trace,覆盖整个图执行。 ctx, span := otelx.Tracer().Start(ctx, "task.execute", trace.WithAttributes(attribute.String("sundynix.task_id", t.ID))) defer span.End() tr := o.tracer(t.ID) defer tr.done() // 熔断开启:快速拒绝,但要让客户端解阻(回流提示 + 收尾流),不静默丢弃。 if !o.breaker.Allow() { log.Printf("[eino] 熔断开启,拒绝任务 %s", t.ID) tr.info("task", "system", "服务熔断", "后端连续失败,暂时拒绝新任务,请稍后重试") _ = o.sink.PublishToken(t.ID, []byte("⚠️ 服务繁忙(已触发熔断保护),请稍后重试。")) _ = o.sink.CompleteStream(t.ID) o.setStatus(t.ID, contract.TaskFailed, "服务熔断") return nil } // 输入护栏 Tier2:仅对网关 Tier1 标记的「灰区」任务做 LLM 越狱裁决(明确干净/恶意的已在网关处理, // 不付 LLM 成本)。命中即拒绝执行(合法终态,非故障,不计熔断)。 if o.guard != nil { if safety, _ := t.Meta[contract.MetaSafetyCheck].(bool); safety { query := dsl.Compile(t.Graph).Query gctx, gcancel := context.WithTimeout(ctx, 10*time.Second) block, sev, reason := o.guard.Classify(gctx, query) gcancel() if block { slog.WarnContext(ctx, "input guardrail blocked", "task_id", t.ID, "severity", sev, "reason", reason) tr.info("guardrail", "system", "输入护栏拦截", reason) _ = o.sink.PublishToken(t.ID, []byte("⚠️ 输入被安全护栏拦截:"+reason)) _ = o.sink.CompleteStream(t.ID) o.breaker.Report(true) // 安全拦截是策略决策,非后端故障 o.setStatus(t.ID, contract.TaskRejected, truncate("输入护栏:"+reason, 200)) return nil } if sev > 0 { slog.InfoContext(ctx, "input guardrail suspect passed", "task_id", t.ID, "severity", sev) } } } // 任务状态机:进入执行 → running;整体加超时上限,超时判 timeout(杜绝无限期"运行中")。 o.setStatus(t.ID, contract.TaskRunning, "") tctx, cancel := context.WithTimeout(ctx, taskExecTimeout) defer cancel() // 成本护栏:单任务 token 预算挂到 ctx,沿图执行各 LLM 节点计量+封顶;收尾回写用量(计费/日预算)。 budget := harness.NewBudget(o.taskBudget(t)) tctx = harness.WithBudget(tctx, budget) defer o.emitUsage(t, budget) // 报告生成走专用多步编排(规划→分章并行检索撰写→汇聚→渲染 Word),而非通用对话图。 if intent, _ := t.Meta[contract.MetaIntent].(string); intent == contract.IntentReport { err := o.handleReport(tctx, t, tr) o.finishStatus(t.ID, err) return err } // ctx 携带 task.execute span → 这些任务生命周期日志自动带 trace_id,可与 Jaeger 链路互跳。 slog.InfoContext(ctx, "task received", "task_id", t.ID, "graph_bytes", len(t.Graph)) tr.info("task", "system", "任务受理", fmt.Sprintf("DSL %d 字节,按图执行", len(t.Graph))) // 按 DSL 图执行:编译为 Eino compose.Graph 并跑(唯一编排引擎);agent 节点流式回流 token。 answer, refs, err := o.executeGraph(tctx, t, tr) if errors.Is(err, errInterrupted) { // HITL 持久化中断:checkpoint 已落、任务停在 waiting(状态在审批节点内已置)。 // 释放 goroutine 但不收尾——不 CompleteStream、不评测、不判 done、不计熔断失败; // token 流与 exec 流都保持打开(suspended),resume 时续录,否则复盘里审批节点永停在等待中。 tr.suspended = true slog.InfoContext(ctx, "task interrupted for approval (checkpointed)", "task_id", t.ID) o.breaker.Report(true) // 中断是正常暂停,非后端故障 return nil } if errors.Is(err, errRejected) { // HITL 拒绝:合法终态,非故障。收尾流 + 置 rejected,不计熔断、不重投。 slog.InfoContext(ctx, "task rejected by approval", "task_id", t.ID) if answer != "" { _ = o.sink.PublishToken(t.ID, []byte(answer)) } _ = o.sink.CompleteStream(t.ID) o.breaker.Report(true) // 拒绝是人为决策,不算后端失败 o.setStatus(t.ID, contract.TaskRejected, truncate(answer, 120)) return nil } if errors.Is(err, errBudget) { // token 预算触顶:策略性中止,非后端故障。收尾流 + 置 failed(附明确原因),不计熔断、不重投。 _, _, total := budget.Snapshot() slog.WarnContext(ctx, "task aborted: token budget exceeded", "task_id", t.ID, "tokens", total) if answer != "" { _ = o.sink.PublishToken(t.ID, []byte(answer)) } _ = o.sink.PublishToken(t.ID, []byte("\n\n⚠️ 已达单任务 token 预算上限,自动中止。")) _ = o.sink.CompleteStream(t.ID) o.breaker.Report(true) o.setStatus(t.ID, contract.TaskFailed, fmt.Sprintf("token 预算超限(已用约 %d)", total)) return nil } if err != nil { span.RecordError(err) span.SetStatus(codes.Error, err.Error()) slog.ErrorContext(ctx, "task graph error", "task_id", t.ID, "err", err.Error()) _ = o.sink.CompleteStream(t.ID) o.breaker.Report(false) o.finishStatus(t.ID, err) return err } if cerr := o.sink.CompleteStream(t.ID); cerr != nil { log.Printf("[eino] complete stream failed: %v", cerr) } slog.InfoContext(ctx, "task done", "task_id", t.ID, "answer_runes", len([]rune(answer))) o.breaker.Report(true) o.finishStatus(t.ID, nil) // 写回阶段(离热路径,单 goroutine 串起评测与落历史): // 先评测(低分则自动纠偏重生成、取更优者),再把最终采纳的答案落会话历史 —— 保证 // 多轮上下文用的是纠偏后的好答案,而非被判低分的初版。 go func() { query := dsl.Compile(t.Graph).Query final := o.evaluate(t, query, answer, refs) o.memorize(t, final) }() return nil } // maxRefineRounds 是低分自动纠偏的最大重生成轮数(防成本失控/死循环)。 // 每轮 = 1 次重写 + 1 次重评;poor 本就稀少,1 轮足以覆盖大多数偶发劣化。 const maxRefineRounds = 1 // evaluate 异步对一次输出做自动化评测并记录评分(off 热路径,不影响响应)。 // 低分(poor)时触发"评语驱动"的自动纠偏:重生成更优答案、重评、取更优者(恒温器闭环)。 // sources 为本轮检索来源:非空时额外评忠实度(幻觉检测)。 // 返回最终采纳的答案(纠偏成功则为修订版,否则为原文)——供调用方落会话历史。 func (o *Orchestrator) evaluate(t *contract.Task, input, output string, sources []string) string { if o.eval == nil { return output } ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) defer cancel() r := o.eval.Score(ctx, input, output, sources) level := evalLevel(r) log.Printf("[eval] task %s 综合 %.2f(规则 %.2f / LLM %.2f / 忠实 %.2f,来源 %d)level=%s flags=%v %s", t.ID, r.Overall, r.Rule, r.LLM, r.Faithful, len(sources), level, r.Flags, r.Reason) // 低分自动纠偏:把"问题 + 初版回答 + 评审短板"交给模型重写,重评后仅采纳更优者(不退步)。 corrected := false for round := 0; level == contract.EvalPoor && round < maxRefineRounds && o.canRefine(); round++ { newOut, newR, ok := o.refine(ctx, t, input, output, sources, r) if !ok { break // 重写失败或未改好:保留当前最优,停止 } prevOverall := r.Overall output, r, level, corrected = newOut, newR, evalLevel(newR), true log.Printf("[eval] task %s 自动纠偏第 %d 轮采纳:综合 %.2f→%.2f level=%s", t.ID, round+1, prevOverall, r.Overall, level) } if level == contract.EvalPoor { // 纠偏后仍低质量:告警(可观测 + 后续可挂报警) slog.Warn("eval poor quality", "task_id", t.ID, "overall", r.Overall, "faithful", r.Faithful, "flags", r.Flags, "corrected", corrected) } // 闭环:评测结果(纠偏后终值)落库(供 UI 查询 / 质量趋势 / 门控)。 if o.evalSink != nil { if err := o.evalSink.PublishEval(&contract.EvalEvent{ TaskID: t.ID, Overall: r.Overall, Rule: r.Rule, LLM: r.LLM, Faithful: r.Faithful, Level: level, Flags: r.Flags, Reason: r.Reason, Sources: len(sources), Corrected: corrected, TS: time.Now().UnixMilli(), }); err != nil { log.Printf("[eval] 回写评测结果失败 task=%s: %v", t.ID, err) } } return output } // canRefine 报告是否具备自动纠偏条件:模型就绪且熔断未开(重写要再打一次模型,避雪上加霜)。 func (o *Orchestrator) canRefine() bool { return o.pool != nil && o.pool.Ready() && (o.breaker == nil || o.breaker.Allow()) } // refine 对低分输出做一次"评语驱动"的重生成:把原问题 + 初版回答 + 评测短板(flags/评语) // 交给模型重写,再重新评分。仅当新分严格更高时采纳(ok=true);否则保留原文(不退步)。 // 有检索来源时把来源一并喂回,要求严格基于来源作答(同时压低幻觉)。 func (o *Orchestrator) refine(ctx context.Context, t *contract.Task, input, output string, sources []string, prev harness.Result) (string, harness.Result, bool) { tr := o.tracer(t.ID) end := tr.span("refine", "system", "低分自动纠偏") critique := strings.Join(prev.Flags, ";") if prev.Reason != "" { if critique != "" { critique += ";" } critique += "评语:" + prev.Reason } if critique == "" { critique = "整体质量不足(相关性/准确性/完整性欠佳)" } sys := prompts.Get(prompts.EvalRefine) srcBlock := "" if len(sources) > 0 { srcBlock = "\n\n【可参考的检索资料(严格基于它作答,不要编造资料外的内容)】\n" + truncate(strings.Join(sources, "\n---\n"), 3000) } user := fmt.Sprintf("【用户问题】%s\n\n【初版回答(质量不足)】%s\n\n【评审指出的问题】%s%s\n\n请直接输出修订后的回答正文,不要解释你做了哪些修改。", truncate(input, 1000), truncate(output, 2000), critique, srcBlock) revised, err := o.pool.Chat(ctx, []llm.ChatMessage{ {Role: "system", Content: sys}, {Role: "user", Content: user}, }) if err != nil || strings.TrimSpace(revised) == "" { end("重写失败", errOrEmpty(err)) return output, prev, false } newR := o.eval.Score(ctx, input, revised, sources) if newR.Overall <= prev.Overall { end(fmt.Sprintf("重写未提升(%.2f≤%.2f),保留原版", newR.Overall, prev.Overall), nil) return output, prev, false } end(fmt.Sprintf("已采纳修订:综合 %.2f→%.2f", prev.Overall, newR.Overall), nil) return revised, newR, true } // errOrEmpty 把空输出也表达成一个错误,供 exec error 事件着色。 func errOrEmpty(err error) error { if err != nil { return err } return errors.New("空输出") } // evalLevel 据综合分 + 忠实度把评测分级(闭环门控/告警用)。 func evalLevel(r harness.Result) string { switch { case r.Overall < 0.5: return contract.EvalPoor case r.Overall < 0.75 || (r.Faithful > 0 && r.Faithful < 0.6): return contract.EvalWarn default: return contract.EvalOK } } // fetchMemory 经 MCP memory_get 工具召回用户常驻画像。 // 工具不可用/超时/无 user_id 时返回空串,降级为无记忆推理(不阻断主流程)。 func (o *Orchestrator) fetchMemory(ctx context.Context, userID, _ string) string { if o.tools == nil || userID == "" { return "" } cctx, cancel := context.WithTimeout(ctx, toolCallTimeout) defer cancel() res, err := o.tools.CallTool(cctx, contract.ToolSubjectGo("memory_get"), &contract.ToolCall{ Tool: "memory_get", Args: map[string]any{"user_id": userID}, }) if err != nil { log.Printf("[eino] memory_get unavailable for %s, degrade: %v", userID, err) return "" } if !res.OK { log.Printf("[eino] memory_get error for %s: %s", userID, res.Error) return "" } log.Printf("[eino] memory_get ok for %s: %s", userID, res.Content) return res.Content } // fetchHistory 经 MCP history_get 工具召回会话短期多轮历史,转为 Eino 消息。 // 工具不可用/无 session 时返回空,降级为无历史(不阻断主流程)。 func (o *Orchestrator) fetchHistory(ctx context.Context, sessionID string) []*schema.Message { if o.tools == nil || sessionID == "" { return nil } cctx, cancel := context.WithTimeout(ctx, toolCallTimeout) defer cancel() res, err := o.tools.CallTool(cctx, contract.ToolSubjectGo("history_get"), &contract.ToolCall{ Tool: "history_get", Args: map[string]any{"session_id": sessionID}, }) if err != nil || res == nil || !res.OK || res.Content == "" { return nil } var turns []struct { Role string `json:"role"` Content string `json:"content"` } if json.Unmarshal([]byte(res.Content), &turns) != nil { return nil } msgs := make([]*schema.Message, 0, len(turns)) for _, tn := range turns { if tn.Role == "assistant" { msgs = append(msgs, schema.AssistantMessage(tn.Content, nil)) } else { msgs = append(msgs, schema.UserMessage(tn.Content)) } } if len(msgs) > 0 { log.Printf("[eino] history_get ok for %s: %d 条历史", sessionID, len(msgs)) } return msgs } // consolidateEveryTurns 控制记忆对账的攒批节奏:每 N 轮 consolidate 一次(不逐轮,省成本)。 const consolidateEveryTurns = 3 // memorize 写回阶段(异步、离热路径):落短期历史;每 N 轮做一次记忆对账(consolidate)。 func (o *Orchestrator) memorize(t *contract.Task, answer string) { // 空答复(LLM 失败/降级/拒绝)不落历史:空 assistant 消息会被 LLM API 拒绝(400), // 一旦写入会毒化该会话后续所有请求。失败的一轮干脆不留痕。 if strings.TrimSpace(answer) == "" { return } uid, _ := t.Meta[contract.MetaUserID].(string) sid, _ := t.Meta[contract.MetaSessionID].(string) if sid != "" && o.tools != nil { o.appendHistory(sid, "user", dsl.Compile(t.Graph).Query) // 落真实用户输入,而非 DSL 原文 o.appendHistory(sid, "assistant", answer) log.Printf("[eino] (writeback) task %s 已落会话历史 session=%s", t.ID, sid) } if uid == "" || sid == "" || o.tools == nil { return } // 攒批:累计轮次,每 N 轮才把近期对话与已有画像交给 LLM 对账一次。 o.turnMu.Lock() if o.turns == nil { o.turns = map[string]int{} } o.turns[sid]++ n := o.turns[sid] o.turnMu.Unlock() if n%consolidateEveryTurns == 0 { ctx := context.Background() o.consolidateMemory(ctx, uid, o.fetchHistory(ctx, sid)) } } func (o *Orchestrator) appendHistory(sessionID, role, content string) { cctx, cancel := context.WithTimeout(context.Background(), toolCallTimeout) defer cancel() if _, err := o.tools.CallTool(cctx, contract.ToolSubjectGo("history_append"), &contract.ToolCall{ Tool: "history_append", Args: map[string]any{"session_id": sessionID, "role": role, "content": content}, }); err != nil { log.Printf("[eino] history_append failed: %v", err) } }