Compare commits

..
49 Commits
Author SHA1 Message Date
RuoJi6 910d07ea0a docs(hitl): remove stale asm_list_resources references (#259) 2026-08-17 17:19:16 +08:00
公明 217f5570c2 Update config.example.yaml 2026-08-17 15:55:03 +08:00
RuoJi6 f30a9de01d feat(hitl): expand safe read-only tool allowlist (#258) 2026-08-17 15:50:32 +08:00
公明 893f1342ae Add files via upload 2026-08-15 11:11:40 +08:00
公明 6db0975223 Add files via upload 2026-08-15 11:09:28 +08:00
公明 b935e1f90f Add files via upload 2026-08-15 11:06:53 +08:00
公明 3b7fd8d582 Add files via upload 2026-08-15 11:06:08 +08:00
公明 053c75b2cc Add files via upload 2026-08-15 11:04:35 +08:00
公明 7823fb6b0c Add files via upload 2026-08-15 11:03:13 +08:00
公明 9c0621819f Add files via upload 2026-08-15 10:13:12 +08:00
公明 aa11b32ce5 Add files via upload 2026-08-15 09:45:37 +08:00
公明 4410cfe826 Update config.example.yaml 2026-08-15 02:25:02 +08:00
公明 409e75f15a Add files via upload 2026-08-15 02:19:19 +08:00
公明 6fc0004ac3 Add files via upload 2026-08-15 02:16:21 +08:00
公明 ee4676a591 Add files via upload 2026-08-15 02:14:36 +08:00
公明 9f38fda15f Add files via upload 2026-08-15 02:12:18 +08:00
公明 b07a645d34 Add files via upload 2026-08-15 02:09:57 +08:00
公明 bbc3395ac5 Add files via upload 2026-08-15 02:08:52 +08:00
公明 799282d0e7 Add files via upload 2026-08-15 02:06:44 +08:00
公明 8635bf49dc Add files via upload 2026-08-15 02:05:09 +08:00
公明 befc8acc3a Add files via upload 2026-08-15 02:03:29 +08:00
公明 964fbd6095 Add files via upload 2026-08-15 01:59:13 +08:00
公明 01c0750d98 Add files via upload 2026-08-15 01:58:38 +08:00
公明 7f8093f8b9 Add files via upload 2026-08-15 01:57:26 +08:00
公明 f564421b4d Add files via upload 2026-08-15 01:56:15 +08:00
公明 2910289f0b Delete internal directory 2026-08-15 01:54:26 +08:00
公明 5ce66ee2f8 Add files via upload 2026-08-15 01:51:13 +08:00
公明 20b6fd241e Add files via upload 2026-08-15 01:49:54 +08:00
公明 3fab05c3b1 Add files via upload 2026-08-15 01:48:43 +08:00
公明 8bde9d749a Add files via upload 2026-08-15 01:47:48 +08:00
公明 9b78297442 Add files via upload 2026-08-15 01:46:12 +08:00
公明 a6631a5cde Add files via upload 2026-08-15 01:45:01 +08:00
公明 31f80b2f0c Add files via upload 2026-08-15 01:43:02 +08:00
公明 70b01206e4 Add files via upload 2026-08-15 01:39:58 +08:00
公明 4fe6defa28 Add files via upload 2026-08-15 01:38:18 +08:00
公明 44a578e824 Add files via upload 2026-08-15 01:37:28 +08:00
公明 e591fe4a9b Add files via upload 2026-08-15 01:36:02 +08:00
公明 e691c5d9c6 Add files via upload 2026-08-15 01:33:33 +08:00
公明 20c9e871af Add files via upload 2026-08-15 01:30:37 +08:00
公明 c477c5507a Add files via upload 2026-08-15 01:28:58 +08:00
公明 43fe8fa7d6 Add files via upload 2026-08-15 01:26:13 +08:00
公明 d19ffc9ff4 Add files via upload 2026-08-15 01:25:40 +08:00
公明 c345880765 Update version number to v1.7.13 2026-08-14 15:44:45 +08:00
RuoJi6 a1615143be feat(ui): 显示 Agent 任务进度列表 (#251) 2026-08-14 10:44:03 +08:00
公明 7c45011074 Add files via upload 2026-08-13 22:15:08 +08:00
RuoJi6 eb6bab574f 优化项目对话、刷新续流、实时滚动与工具状态恢复 (#245)
* feat(chat): add project-based conversation sidebar

* feat(chat): refine Codex-style conversation UI

* feat(chat): add Codex-style conversation workflow

* feat(ui): 优化对话框与项目侧边栏交互

* fix(chat): 修复暗色输入框圆角填色

* fix(chat): 恢复输入区分层错位布局

* fix(hitl): isolate reviewer state per conversation

* feat(hitl): 增加双入口审批与倒计时进度

* fix(hitl): 汇总项目审批并隔离对话状态

* fix(ui): 修复审批状态与无项目新任务

* fix(ui): 优化审批状态与对话切换性能

* fix(ui): 修复中断任务审批仍计时

* fix(ui): 修复多对话并发切换卡顿

* fix(hitl): 主动同步审批并关闭中断状态

* fix(ui): 固定项目审批汇总为绿色

* feat(ui): 同步系统模型与推理强度

* feat(ui): 完善项目侧栏预览与新建入口

* fix(ui): 防止无项目文件夹误展开

* fix(ui): 防止长历史对话滚动误触审批

* fix(ui): 修复对话操作并补充项目置顶

* fix(ui): 移除对话分组并调整项目置顶排序

* feat(ui): 优化迭代导航与审批交互

* fix(hitl): 将 write_file 加入内置免审批工具

* fix(chat): 支持回车发送与 Shift 回车换行

* fix(chat): 优化对话刷新与 Codex 风格交互

* fix(ui): 显示对话具体更新时间

* fix(ui): 优化对话刷新与项目加载

* fix(ui): 修复 Agent 审查文字裁切

* fix(chat): 修复刷新续流与多标签页同步

* fix(chat): 修复滚动跟随与中断任务终态

* fix(ui): 修复流式滚动跳动与工具状态恢复

* fix(ui): 修复刷新后流式输出停止粘底
2026-08-13 21:28:37 +08:00
公明 b170f2c4b1 Add files via upload 2026-08-10 10:28:14 +08:00
RuoJi6 02216a4660 feat: exempt get_project_fact from HITL approval (#239) 2026-08-08 23:44:00 +08:00
tian-IRT a53e4a4a64 fix: recover from duplicate streaming tool call indexes (#231) 2026-08-06 17:52:04 +08:00
193 changed files with 26921 additions and 3119 deletions
+14 -8
View File
@@ -10,7 +10,7 @@
# ============================================
# 前端显示的版本号(可选,不填则显示默认版本)
version: "v1.7.12"
version: "v1.7.14"
# 服务器配置
server:
host: 0.0.0.0 # 监听地址,0.0.0.0 表示监听所有网络接口
@@ -76,6 +76,14 @@ ai:
allow_client_reasoning: true # false 时忽略对话请求体 reasoning,仅以下方为准
profile: openai_compat # auto | deepseek_compat | openai_compat | output_config_effort
# extra_request_fields: {} # 可选:管理员自定义根级 JSON 片段(高级)
# qwen-plus:
# name: Qwen Plus Fallback
# provider: openai_compatible
# base_url: https://dashscope.aliyuncs.com/compatible-mode/v1
# api_key: sk-xxxxxxx
# model: qwen-plus
# max_total_tokens: 120000
# max_completion_tokens: 32768
# 视觉分析(analyze_image MCP 工具;图片仅在单次 VL 调用中出现,Agent 上下文只保留文字摘要)
vision:
enabled: false # true 且 model 非空时注册 analyze_image
@@ -138,7 +146,7 @@ hitl:
# 已决策审计日志保留天数(与 MCP 监控一致;省略默认 90;0 表示不自动清理)
retention_days: 90
# 按你环境里的真实工具名增删(与侧栏一致、小写不敏感);不需要全局免审批可改为 []
tool_whitelist: [read_file, list_dir, glob, grep, tool_search, upsert_project_fact]
tool_whitelist: [read_file, ls, list_dir, glob, grep, tool_search, upsert_project_fact, get_project_fact, list_project_facts, search_project_facts, list_vulnerabilities, get_vulnerability, get_asset, query_assets, list_knowledge_risk_types, get_tool_execution, wait_tool_execution, batch_task_list, batch_task_get, manage_webshell_list, c2_event, c2_file]
# audit_agent_prompt: | # 审批模式;留空使用内置默认,可在「人机协同」页编辑
# audit_agent_prompt_review_edit: | # 审查编辑模式;留空使用内置默认
@@ -271,9 +279,6 @@ multi_agent:
# Eino ADK 中间件与 Deep/Supervisor/plan_execute Executor 调参(结构体见 internal/config/config.go → MultiAgentEinoMiddlewareConfig
# plan_execute:下列 patch/reduction/tool_search/plantask 等同样作用于 Executor(经 ExecPreMiddlewares);Planner/Replanner 不挂 MCP 前置中间件。
eino_middleware:
max_tool_arguments_bytes: 65536 # 单个工具 arguments 硬上限;超出时禁止执行并要求模型改写
max_shell_command_bytes: 65536 # exec/execute.command 硬上限;与普通工具 arguments 上限一致
model_output_repair_max_attempts: 1 # 非法/截断模型输出最多自动修复一次,避免循环
patch_tool_calls: true # true:修补历史中无 tool_result 的悬空 tool_call(流式中断/重试后更稳);false:关闭;字段省略时默认等同 true
tool_search_enable: true # true:工具数 ≥ min 时启用 tool_search,仅前 N 个工具常驻,其余按正则按需解锁,省 token、减误选;false:全量工具进上下文
tool_search_min_tools: 20 # 达到该数量才启用 tool_search(避免工具很少时多此一举);与 always_visible 配合使用
@@ -300,11 +305,12 @@ multi_agent:
plan_execute_max_step_result_runes: 4000 # plan_execute 每步结果最大字符数(超出截断)
plan_execute_keep_last_steps: 8 # plan_execute 仅保留最近 N 步正文,早期步骤折叠为标题
checkpoint_dir: data/eino-checkpoints # P0:进程崩溃/OOM 后同会话自动 ADK Resume;正常结束会删 .ckpt;与「中断并继续」(last_react_*) 是两套机制
run_retry_max_attempts: 0 # 408/409/425/429/5xx/网络抖动时可退避重试次数;0=默认 4(永久性 4xx 不重试)
run_retry_max_backoff_sec: 0 # 单次退避上限秒数;0=默认 30
model_retry_max_retries: 0 # Eino 原生 ChatModel retry408/409/425/429/5xx/网络抖动/空流式输出会重试;0=默认 4(永久性 4xx 不重试)
model_retry_max_backoff_sec: 0 # Eino 原生 ChatModel retry 单次退避上限秒数;0=默认 30
model_failover_channels: [] # Eino 原生 ChatModel failover;填写 ai.channels ID,例如 [qwen-plus]retry 耗尽后按顺序切换
model_failover_max_retries: 0 # 备用通道最多尝试数;0=尝试全部 model_failover_channels
empty_response_continue_max_attempts: 0 # Run 成功但未捕获助手正文(含流式中断)时 Handler 退避续跑次数;0=默认 5
deep_output_key: final_answer # P0Eino session 写入最终助手结论(框架内部;Deep/Supervisor 主/eino_single
deep_model_retry_max_retries: 0 # 已废弃,请用 run_retry_max_attempts;保留字段仅为兼容旧配置
task_tool_description_prefix: "" # 非空:仅 Deep 的 task 工具使用自定义描述前缀,运行时会拼接子代理名称;空则走 Eino 默认生成逻辑
# Eino callbacks + OpenTelemetry:框架级 span(与 Zap 对齐);默认不向终端用户 UI 推 eino_trace_*(见 sse_trace_to_client
eino_callbacks:
+10 -2
View File
@@ -25,7 +25,7 @@ hitl:
api_key: ""
model: "" # set a small model here; blank reuses the default AI channel model
retention_days: 90
tool_whitelist: [read_file, list_dir, glob, grep, tool_search]
tool_whitelist: [read_file, ls, glob, grep, tool_search, get_project_fact, list_project_facts, search_project_facts, list_vulnerabilities, get_vulnerability, get_asset, query_assets, list_knowledge_risk_types, get_tool_execution, wait_tool_execution, batch_task_list, batch_task_get, manage_webshell_list, c2_event, c2_file]
```
`audit_model` supports partial configuration. Empty fields inherit from the resolved default AI channel, so the common setup is to fill only `model` and run approvals on a cheaper small model.
@@ -91,10 +91,16 @@ In review-edit mode, you may narrow paths, targets, or command arguments before
Allowlisted tools skip approval, so keep the list stable and low-risk. Recommended examples:
- `read_file`
- `list_dir`
- `ls`
- `glob`
- `grep`
- `tool_search`
- Project and vulnerability reads: `get_project_fact`, `list_project_facts`, `search_project_facts`, `list_vulnerabilities`, `get_vulnerability`
- Asset and knowledge-metadata reads: `get_asset`, `query_assets`, `list_knowledge_risk_types`
- Execution and task-state reads: `get_tool_execution`, `wait_tool_execution`, `batch_task_list`, `batch_task_get`
- Local management-metadata reads: `manage_webshell_list`, `c2_event`, `c2_file`
These built-in MCP reads remain constrained by RBAC and resource scope; the allowlist only bypasses HITL approval and does not grant additional access. `list_dir` is effective only when that is the actual tool name; the Eino filesystem directory-listing tool is named `ls`.
Avoid globally allowlisting:
@@ -102,6 +108,8 @@ Avoid globally allowlisting:
- File write/delete tools
- C2 task tools
- WebShell command execution tools
- Nominally read-only tools that send requests to a target or external service, such as `webshell_file_read`, `webshell_file_list`, and `search_knowledge_base`
- Multiplexed tools whose actions include both reads and writes, such as `c2_session`, `c2_listener`, `c2_profile`, and `c2_task_manage`
## Mode Selection
File diff suppressed because one or more lines are too long
+10 -2
View File
@@ -25,7 +25,7 @@ hitl:
api_key: ""
model: "" # 可填小模型;留空复用默认 AI 通道的模型
retention_days: 90
tool_whitelist: [read_file, list_dir, glob, grep, tool_search]
tool_whitelist: [read_file, ls, glob, grep, tool_search, get_project_fact, list_project_facts, search_project_facts, list_vulnerabilities, get_vulnerability, get_asset, query_assets, list_knowledge_risk_types, get_tool_execution, wait_tool_execution, batch_task_list, batch_task_get, manage_webshell_list, c2_event, c2_file]
```
`audit_model` 的字段可以只填一部分。空字段会自动继承默认 AI 通道解析后的模型配置,因此常见做法是只填 `model`,让审计 Agent 使用更便宜的小模型。
@@ -91,10 +91,16 @@ hitl:
白名单工具会跳过审批,因此要保守维护。推荐放:
- `read_file`
- `list_dir`
- `ls`
- `glob`
- `grep`
- `tool_search`
- 项目与漏洞查询:`get_project_fact``list_project_facts``search_project_facts``list_vulnerabilities``get_vulnerability`
- 资产与知识元数据查询:`get_asset``query_assets``list_knowledge_risk_types`
- 执行与任务状态查询:`get_tool_execution``wait_tool_execution``batch_task_list``batch_task_get`
- 本地管理元数据查询:`manage_webshell_list``c2_event``c2_file`
上述内置 MCP 查询仍受 RBAC 和资源范围约束;白名单只跳过 HITL 审批,不扩大访问权限。`list_dir` 仅在实际工具名为该值时有效;Eino 文件系统的目录列表工具名是 `ls`
不建议直接全局白名单:
@@ -102,6 +108,8 @@ hitl:
- 文件写入/删除工具
- C2 任务工具
- WebShell 命令执行工具
- 会向目标或外部服务发起请求的“只读”工具,例如 `webshell_file_read``webshell_file_list``search_knowledge_base`
- 同一工具名同时包含读写 action 的复合工具,例如 `c2_session``c2_listener``c2_profile``c2_task_manage`
## 模式选择
+33 -23
View File
@@ -3,17 +3,18 @@ module cyberstrike-ai
// go mod download : go env -w GOPROXY=https://goproxy.cn,direct
// 使 scripts/bootstrap-go.sh
go 1.25
go 1.25.0
require (
github.com/bwmarrin/discordgo v0.29.0
github.com/bytedance/sonic v1.15.0
github.com/cloudwego/eino v0.8.13
github.com/cloudwego/eino-ext/adk/backend/local v0.0.0-20260416081055-0ebab92e14f2
github.com/cloudwego/eino v0.9.14
github.com/cloudwego/eino-ext/adk/backend/local v0.2.6
github.com/cloudwego/eino-ext/components/document/loader/file v0.0.0-20260427010451-749e3706378b
github.com/cloudwego/eino-ext/components/document/transformer/splitter/markdown v0.0.0-20260427010451-749e3706378b
github.com/cloudwego/eino-ext/components/document/transformer/splitter/recursive v0.0.0-20260427010451-749e3706378b
github.com/cloudwego/eino-ext/components/embedding/openai v0.0.0-20260427010451-749e3706378b
github.com/cloudwego/eino-ext/components/model/agenticopenai v0.2.1
github.com/cloudwego/eino-ext/components/model/openai v0.1.13
github.com/creack/pty v1.1.24
github.com/disintegration/imaging v1.6.2
@@ -30,46 +31,53 @@ require (
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
github.com/slack-go/slack v0.27.0
github.com/tencent-connect/botgo v0.2.1
go.opentelemetry.io/otel v1.34.0
go.opentelemetry.io/otel v1.39.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.34.0
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.34.0
go.opentelemetry.io/otel/sdk v1.34.0
go.opentelemetry.io/otel/trace v1.34.0
go.opentelemetry.io/otel/sdk v1.39.0
go.opentelemetry.io/otel/trace v1.39.0
go.uber.org/zap v1.26.0
golang.org/x/net v0.35.0
golang.org/x/term v0.32.0
golang.org/x/text v0.26.0
golang.org/x/crypto v0.50.0
golang.org/x/net v0.53.0
golang.org/x/term v0.42.0
golang.org/x/text v0.37.0
golang.org/x/time v0.14.0
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 // indirect
github.com/bahlo/generic-list-go v0.2.0 // indirect
github.com/bmatcuk/doublestar/v4 v4.10.0 // indirect
github.com/buger/jsonparser v1.1.1 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/cloudwego/eino-ext/libs/acl/openai v0.1.17 // indirect
github.com/cloudwego/eino-ext/libs/acl/openai v0.1.18-0.20260527084435-846f52bd97c6 // indirect
github.com/dlclark/regexp2 v1.10.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/evanphx/json-patch v0.5.2 // indirect
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-logr/logr v1.4.2 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.14.0 // indirect
github.com/go-resty/resty/v2 v2.6.0 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/google/jsonschema-go v0.3.0 // indirect
github.com/goph/emperror v0.17.2 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.25.1 // indirect
github.com/jolestar/go-commons-pool/v2 v2.1.2 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
github.com/klippa-app/go-pdfium v1.19.3 // indirect
github.com/leodido/go-urn v1.2.4 // indirect
github.com/mailru/easyjson v0.9.0 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect
@@ -77,34 +85,36 @@ require (
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/nikolalohinski/gonja v1.5.3 // indirect
github.com/openai/openai-go/v3 v3.35.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect
github.com/slongfield/pyfmt v0.0.0-20220222012616-ea85ff4c361f // indirect
github.com/tidwall/gjson v1.9.3 // indirect
github.com/tetratelabs/wazero v1.11.0 // indirect
github.com/tidwall/gjson v1.18.0 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.0 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
github.com/tidwall/sjson v1.2.5 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.11 // indirect
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
github.com/yargevad/filepathx v1.0.0 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.34.0 // indirect
go.opentelemetry.io/otel/metric v1.34.0 // indirect
go.opentelemetry.io/otel/metric v1.39.0 // indirect
go.opentelemetry.io/proto/otlp v1.5.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/arch v0.15.0 // indirect
golang.org/x/crypto v0.39.0 // indirect
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 // indirect
golang.org/x/oauth2 v0.30.0 // indirect
golang.org/x/sync v0.15.0 // indirect
golang.org/x/sys v0.33.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20250115164207-1a7da9e5054f // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250115164207-1a7da9e5054f // indirect
google.golang.org/grpc v1.69.4 // indirect
google.golang.org/protobuf v1.36.3 // indirect
golang.org/x/oauth2 v0.34.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.43.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
google.golang.org/grpc v1.79.3 // indirect
google.golang.org/protobuf v1.36.10 // indirect
)
// Stream SDK / "panic: send on closed channel"
+94 -46
View File
@@ -1,4 +1,10 @@
cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0 h1:g0EZJwz7xkXQiZAI5xi9f3WWFYBlX1CPTrR+NDToRkQ=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0/go.mod h1:XCW7KnZet0Opnr7HccfUw1PLc4CjHqpcaxW8DHklNkQ=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 h1:ywEEhmNahHBihViHepv3xPBn1663uRv2t2q/ESv9seY=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0/go.mod h1:iZDifYGJTIgIIkYRNWPENUnqx6bJ2xnSDFI2tjwZNuY=
github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/airbrake/gobrake v3.6.1+incompatible/go.mod h1:wM4gu3Cn0W0K7GUuVWnlXZU11AGBXMILnrdOU8Kn00o=
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
@@ -16,6 +22,7 @@ github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/mockey v1.3.0 h1:ONLRdvhqmCfr9rTasUB8ZKCfvbdD2tohOg4u+4Q/ed0=
github.com/bytedance/mockey v1.3.0/go.mod h1:1BPHF9sol5R1ud/+0VEHGQq/+i2lN+GTsr3O2Q9IENY=
github.com/bytedance/mockey v1.4.6 h1:pPkAFB6yiaaybvgp7DP1Rj4Ztiew3nsaMizoNkzsvNA=
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
@@ -25,12 +32,14 @@ github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyY
github.com/certifi/gocertifi v0.0.0-20190105021004-abcd57078448/go.mod h1:GJKEexRPVJrBSOjoqN5VNOIKJ5Q3RViH6eu3puDRwx4=
github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/cloudwego/eino v0.8.13 h1:z5dhaZNN8TWZbP/lgKxGmF26Ii8fPeUlQCGV/NTtms0=
github.com/cloudwego/eino v0.8.13/go.mod h1:+2N4nsMPxA6kGBHpH+75JuTfEcGprAMTdsZESrShKpU=
github.com/cloudwego/eino-ext/adk/backend/local v0.0.0-20260416081055-0ebab92e14f2 h1:v2w9TyLAmNsMWo8NwntCc76uvNf6isTFkHB+oZZ8NqI=
github.com/cloudwego/eino-ext/adk/backend/local v0.0.0-20260416081055-0ebab92e14f2/go.mod h1:os5Tq5FuSoz/MLqAdZER3ip49Oef9prc0kVsKsPYO48=
github.com/cloudwego/eino v0.9.14 h1:suNVibjtkPMiW7csFBdBqN3FRG0nRlCqoGKE7t0UDwY=
github.com/cloudwego/eino v0.9.14/go.mod h1:OBD1mrkfkt/pJa4rkg1P0VnaMeOVl7l8IAdEqY//3IQ=
github.com/cloudwego/eino-ext/adk/backend/local v0.2.6 h1:pxKAWcveIBWrbz28H2bDAMyJZnhysa9bh2Kh5s7iH7s=
github.com/cloudwego/eino-ext/adk/backend/local v0.2.6/go.mod h1:uJlpYclMA2ScgUlHWHu2om+DDguV2Lzc+TNulWBXqfo=
github.com/cloudwego/eino-ext/components/document/loader/file v0.0.0-20260427010451-749e3706378b h1:GIOC/VnXuSQx79mnQ3HgMvECjtyqvpJipmSUTFFfVsc=
github.com/cloudwego/eino-ext/components/document/loader/file v0.0.0-20260427010451-749e3706378b/go.mod h1:HnxTQxmhuev6zaBl92EHUy/vEDWCuoE/OE4cTiF5JCg=
github.com/cloudwego/eino-ext/components/document/transformer/splitter/markdown v0.0.0-20260427010451-749e3706378b h1:3owjV4nv+XRplavTeqFlCeAV4v7EHR2tIXDqLEmPc38=
@@ -39,10 +48,14 @@ github.com/cloudwego/eino-ext/components/document/transformer/splitter/recursive
github.com/cloudwego/eino-ext/components/document/transformer/splitter/recursive v0.0.0-20260427010451-749e3706378b/go.mod h1:9R0RQrQSpg1JaNnRtw7+RfRAAv0HgdE348YnrlZ6coo=
github.com/cloudwego/eino-ext/components/embedding/openai v0.0.0-20260427010451-749e3706378b h1:pOqupZQyc46rw2Z0HeybtTmSMTwqfTrbRuGDuDsNf2A=
github.com/cloudwego/eino-ext/components/embedding/openai v0.0.0-20260427010451-749e3706378b/go.mod h1:zyPrZT2bO6LyRJgVksQowR18jVgyLSvqK93hnO53/Lc=
github.com/cloudwego/eino-ext/components/model/agenticopenai v0.2.1 h1:bIMyUxEZdBEofWbXQCozfGxXCsbyNjpZcRMfW+Kt5u0=
github.com/cloudwego/eino-ext/components/model/agenticopenai v0.2.1/go.mod h1:84jUcVAcmlGDRVX0ExABVJEfrIaplaAeRbjFYW9mzkI=
github.com/cloudwego/eino-ext/components/model/openai v0.1.13 h1:5XHRTiTD5bt9KQrMHcfvuWNklEC3tpm3XHejdozt9vM=
github.com/cloudwego/eino-ext/components/model/openai v0.1.13/go.mod h1:mgIoqYYOc0eECCqvLbEYpOJrQNTNxkwXzSJzFU+v5sQ=
github.com/cloudwego/eino-ext/libs/acl/openai v0.1.17 h1:EeVcR1TslRA2IdNW1h/2LaGbPlffwGhQm99jM3zWZiI=
github.com/cloudwego/eino-ext/libs/acl/openai v0.1.17/go.mod h1:Zkcx6DPTR2NfWmtSXbhItswGw6hqUezNPhNcke0pOG8=
github.com/cloudwego/eino-ext/libs/acl/openai v0.1.18-0.20260527084435-846f52bd97c6 h1:ES/xufN5eqJ3h+9tw/tq6F8kkgnAxBAHVUB6nqKsIDU=
github.com/cloudwego/eino-ext/libs/acl/openai v0.1.18-0.20260527084435-846f52bd97c6/go.mod h1:5Xj74dGrfHo1z7I07Fzp3SlTF7Bt4tss3A2FSt8SqQ4=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
@@ -60,6 +73,8 @@ github.com/eino-contrib/jsonschema v1.0.3 h1:2Kfsm1xlMV0ssY2nuxshS4AwbLFuqmPmzIj
github.com/eino-contrib/jsonschema v1.0.3/go.mod h1:cpnX4SyKjWjGC7iN2EbhxaTdLqGjCi0e9DxpLYxddD4=
github.com/evanphx/json-patch v0.5.2 h1:xVCHIVMUu1wtM/VkR9jVZ45N3FhZfYMMYGorLCR8P3k=
github.com/evanphx/json-patch v0.5.2/go.mod h1:ZWS5hhDbVDyob71nXKNL0+PWn6ToqBHMikGIFbs31qQ=
github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8vw=
github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
@@ -72,8 +87,8 @@ github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SU
github.com/go-check/check v0.0.0-20180628173108-788fd7840127 h1:0gkP6mzaMqkmpcJYCFOLkIBwI7xFExG03bbkOkCvUPI=
github.com/go-check/check v0.0.0-20180628173108-788fd7840127/go.mod h1:9ES+weclKsC9YodN5RgxqK/VD9HM9JsCSh7rNhMZE98=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
@@ -87,9 +102,14 @@ github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QX
github.com/go-redis/redis/v8 v8.11.4/go.mod h1:2Z2wHZXdQpCDXEGzqMockDpNyYvi2l4Pxt6RJr792+w=
github.com/go-resty/resty/v2 v2.6.0 h1:joIR5PNLM2EFqqESUjCMGXrWmXNHEU9CEiK813oKYS4=
github.com/go-resty/resty/v2 v2.6.0/go.mod h1:PwvJS6hvaPkjtjNg9ph+VrSD92bi5Zq73w/BIH7cC3Q=
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0 h1:p104kn46Q8WdvHunIJ9dAyjPVtrBPhSr3KT2yUst43I=
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U=
github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/gofrs/uuid v3.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
@@ -119,6 +139,8 @@ github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/jsonschema-go v0.3.0 h1:6AH2TxVNtk3IlvkkhjrtbUc4S8AvO0Xii0DxIygDg+Q=
github.com/google/jsonschema-go v0.3.0/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
github.com/google/pprof v0.0.0-20260402051712-545e8a4df936 h1:EwtI+Al+DeppwYX2oXJCETMO23COyaKGP6fHVpkpWpg=
github.com/google/pprof v0.0.0-20260402051712-545e8a4df936/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
@@ -134,6 +156,8 @@ github.com/grpc-ecosystem/grpc-gateway/v2 v2.25.1 h1:VNqngBF40hVlDloBruUehVYC3Ar
github.com/grpc-ecosystem/grpc-gateway/v2 v2.25.1/go.mod h1:RBRO7fro65R6tjKzYgLAFo0t1QEXY1Dp+i/bvpRiqiQ=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
github.com/jolestar/go-commons-pool/v2 v2.1.2 h1:E+XGo58F23t7HtZiC/W6jzO2Ux2IccSH/yx4nD+J1CM=
github.com/jolestar/go-commons-pool/v2 v2.1.2/go.mod h1:r4NYccrkS5UqP1YQI1COyTZ9UjPJAAGTUxzcsK1kqhY=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
@@ -143,6 +167,8 @@ github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/klippa-app/go-pdfium v1.19.3 h1:3UVRNqA6F4XRwKXClYLwM8RMX+J5nmF5m/Kd4QA8dZQ=
github.com/klippa-app/go-pdfium v1.19.3/go.mod h1:9SpxpYVWG1EKkwc3+gFw3ykmaT30IohxxaL58l/97bs=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
@@ -159,8 +185,8 @@ github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4=
github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
github.com/mattn/go-colorable v0.1.2 h1:/bC9yWikZXAL9uJdulbSfyVNIR3n3trXl+v8+1sx8mU=
github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40=
github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.18 h1:JL0eqdCOq6DJVNPSvArO/bIV9/P7fbGrV00LZHc+5aI=
@@ -184,10 +210,17 @@ github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+W
github.com/onsi/ginkgo v1.8.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0=
github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE=
github.com/onsi/ginkgo/v2 v2.28.3 h1:4JvMdwtFU0imd8fHx25OJXoDMRexnf8v5NHKYSTTji4=
github.com/onsi/ginkgo/v2 v2.28.3/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44=
github.com/onsi/gomega v1.5.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
github.com/onsi/gomega v1.16.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY=
github.com/onsi/gomega v1.40.0 h1:Vtol0e1MghCD2ZVIilPDIg44XSL9l2QAn8ZNaljWcJc=
github.com/onsi/gomega v1.40.0/go.mod h1:M/Uqpu/8qTjtzCLUA2zJHX9Iilrau25x1PdoSRbWh5A=
github.com/openai/openai-go/v3 v3.35.0 h1:109x3epXMSE423KW2euR506GGFezcEt0s87MoWejpH0=
github.com/openai/openai-go/v3 v3.35.0/go.mod h1:cdufnVK14cWcT9qA1rRtrXx4FTRsgbDPW7Ia7SS5cZo=
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
@@ -237,12 +270,21 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tencent-connect/botgo v0.2.1 h1:+BrTt9Zh+awL28GWC4g5Na3nQaGRWb0N5IctS8WqBCk=
github.com/tencent-connect/botgo v0.2.1/go.mod h1:oO1sG9ybhXNickvt+CVym5khwQ+uKhTR+IhTqEfOVsI=
github.com/tetratelabs/wazero v1.11.0 h1:+gKemEuKCTevU4d7ZTzlsvgd1uaToIDtlQlmNbwqYhA=
github.com/tetratelabs/wazero v1.11.0/go.mod h1:eV28rsN8Q+xwjogd7f4/Pp4xFxO7uOGbLcD/LzB1wiU=
github.com/tidwall/gjson v1.9.3 h1:hqzS9wAHMO+KVBBkLxYdkEeeFHuqr95GfClRLKlgK0E=
github.com/tidwall/gjson v1.9.3/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
@@ -260,34 +302,36 @@ github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT0
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/otel v1.34.0 h1:zRLXxLCgL1WyKsPVrgbSdMN4c0FMkDAskSTQP+0hdUY=
go.opentelemetry.io/otel v1.34.0/go.mod h1:OWFPOQ+h4G8xpyjgqo4SxJYdDQ/qmRH+wivy7zzx9oI=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.34.0 h1:OeNbIYk/2C15ckl7glBlOBp5+WlYsOElzTNmiPW/x60=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.34.0/go.mod h1:7Bept48yIeqxP2OZ9/AqIpYS94h2or0aB4FypJTc8ZM=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.34.0 h1:BEj3SPM81McUZHYjRS5pEgNgnmzGJ5tRpU5krWnV8Bs=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.34.0/go.mod h1:9cKLGBDzI/F3NoHLQGm4ZrYdIHsvGt6ej6hUowxY0J4=
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.34.0 h1:jBpDk4HAUsrnVO1FsfCfCOTEc/MkInJmvfCHYLFiT80=
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.34.0/go.mod h1:H9LUIM1daaeZaz91vZcfeM0fejXPmgCYE8ZhzqfJuiU=
go.opentelemetry.io/otel/metric v1.34.0 h1:+eTR3U0MyfWjRDhmFMxe2SsW64QrZ84AOhvqS7Y+PoQ=
go.opentelemetry.io/otel/metric v1.34.0/go.mod h1:CEDrp0fy2D0MvkXE+dPV7cMi8tWZwX3dmaIhwPOaqHE=
go.opentelemetry.io/otel/sdk v1.34.0 h1:95zS4k/2GOy069d321O8jWgYsW3MzVV+KuSPKp7Wr1A=
go.opentelemetry.io/otel/sdk v1.34.0/go.mod h1:0e/pNiaMAqaykJGKbi+tSjWfNNHMTxoC9qANsCzbyxU=
go.opentelemetry.io/otel/sdk/metric v1.31.0 h1:i9hxxLJF/9kkvfHppyLL55aW7iIJz4JjxTeYusH7zMc=
go.opentelemetry.io/otel/sdk/metric v1.31.0/go.mod h1:CRInTMVvNhUKgSAMbKyTMxqOBC0zgyxzW55lZzX43Y8=
go.opentelemetry.io/otel/trace v1.34.0 h1:+ouXS2V8Rd4hp4580a8q23bg0azF2nI8cqLYnC8mh/k=
go.opentelemetry.io/otel/trace v1.34.0/go.mod h1:Svm7lSjQD7kG7KJ/MUHPVXSDGz2OX4h0M2jHBhmSfRE=
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8=
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
go.opentelemetry.io/proto/otlp v1.5.0 h1:xJvq7gMzB31/d406fB8U5CBdyQGw4P399D1aQWU/3i4=
go.opentelemetry.io/proto/otlp v1.5.0/go.mod h1:keN8WnHxOy8PG0rQZjJJ5A2ebUoafqWp0eVQ4yIXvJ4=
go.uber.org/goleak v1.2.0 h1:xqgm/S+aQvhWFTtR0XK3Jvg7z8kGV8P4X14IzwN3Eqk=
go.uber.org/goleak v1.2.0/go.mod h1:XJYK+MuIchqpmGmUSAzotztawfKvYLUIgg7guXrwVUo=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/mock v0.4.0 h1:VcM4ZOtdbR4f6VXfiOpwpVJDL6lCReaZ6mw31wqh7KU=
go.uber.org/mock v0.4.0/go.mod h1:a6FSlNadKUHUa9IP5Vyt1zh4fC7uAwxMutEAscFbkZc=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo=
go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/arch v0.15.0 h1:QtOrQd0bTUnhNVNndMpLHNWrDmYzZ2KDqSrEymqInZw=
golang.org/x/arch v0.15.0/go.mod h1:JmwW7aLIoRUKgaTzhkiEFxvcEiQGyOg9BMonBJUS7EE=
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
@@ -297,8 +341,8 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw=
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 h1:hVwzHzIUGRjiF7EcUjqNxk3NCfkPxbDKRdnNE1Rpg0U=
@@ -307,6 +351,8 @@ golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
@@ -320,19 +366,19 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U=
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -354,15 +400,15 @@ golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0=
golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
@@ -370,8 +416,8 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -381,18 +427,20 @@ golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4f
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/genproto/googleapis/api v0.0.0-20250115164207-1a7da9e5054f h1:gap6+3Gk41EItBuyi4XX/bp4oqJ3UwuIMl25yGinuAA=
google.golang.org/genproto/googleapis/api v0.0.0-20250115164207-1a7da9e5054f/go.mod h1:Ic02D47M+zbarjYYUlK57y316f2MoN0gjAwI3f2S95o=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250115164207-1a7da9e5054f h1:OxYkA3wjPsZyBylwymxSHa7ViiW1Sml4ToBrncvFehI=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250115164207-1a7da9e5054f/go.mod h1:+2Yz8+CLJbIfL9z73EW45avw8Lmge3xVElCP9zEKi50=
google.golang.org/grpc v1.69.4 h1:MF5TftSMkd8GLw/m0KM6V8CMOCY6NZ1NQDPGFgbTt4A=
google.golang.org/grpc v1.69.4/go.mod h1:vyjdE6jLBI76dgpDojsFGNaHlxdjXN9ghpnd2o7JGZ4=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls=
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE=
google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
@@ -401,8 +449,8 @@ google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzi
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.36.3 h1:82DV7MYdb8anAVi3qge1wSnMDrnKK7ebr+I0hHRN1BU=
google.golang.org/protobuf v1.36.3/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
+13
View File
@@ -820,6 +820,19 @@ func (a *Agent) UpdateMCPExecutionDisplayResult(executionID, resultText string)
}
}
// MCPExecutionResultText returns the monitor-facing result text after storage
// guards such as large-output spilling have been applied.
func (a *Agent) MCPExecutionResultText(executionID string) string {
if a == nil || a.mcpServer == nil || strings.TrimSpace(executionID) == "" {
return ""
}
exec, ok := a.mcpServer.GetExecution(executionID)
if !ok || exec == nil || exec.Result == nil {
return ""
}
return mcp.ToolResultPlainText(exec.Result)
}
// CancelMCPToolExecutionWithNote 取消一次进行中的 MCP 工具(先内部后外部),与监控页「终止工具」一致;note 非空时合并进返回给模型的文本。
func (a *Agent) CancelMCPToolExecutionWithNote(executionID, note string) bool {
executionID = strings.TrimSpace(executionID)
+2
View File
@@ -443,6 +443,7 @@ func New(cfg *config.Config, log *logger.Logger, configPath string) (*App, error
conversationHandler := handler.NewConversationHandler(db, log.Logger)
conversationHandler.SetAudit(auditSvc)
conversationHandler.SetTaskStopper(agentHandler)
conversationHandler.SetTaskStateProvider(agentHandler)
auditHandler := handler.NewAuditHandler(db, auditSvc, log.Logger)
robotHandler := handler.NewRobotHandler(cfg, db, agentHandler, log.Logger)
robotHandler.SetAudit(auditSvc)
@@ -1029,6 +1030,7 @@ func setupRoutes(
protected.POST("/conversations", conversationHandler.CreateConversation)
protected.GET("/conversations", conversationHandler.ListConversations)
protected.GET("/conversations/:id", conversationHandler.GetConversation)
protected.GET("/conversations/:id/plan-tasks", conversationHandler.GetConversationPlanTasks)
protected.GET("/messages/:id/process-details", conversationHandler.GetMessageProcessDetails)
protected.GET("/process-details/:id", conversationHandler.GetProcessDetail)
protected.PUT("/conversations/:id", conversationHandler.UpdateConversation)
+32 -48
View File
@@ -54,9 +54,6 @@ type EnsureLocalConfigResult struct {
const (
DefaultMaxCompletionTokens = 16384
DefaultMaxToolArgumentsBytes = 65536
DefaultMaxShellCommandBytes = 65536
DefaultModelOutputRepairMaxAttempts = 1
DefaultSummarizationUserIntentLedgerMaxRunes = 96000
DefaultSummarizationUserIntentLedgerEntryMaxRunes = 16000
DefaultLatestUserMessageMaxRunes = 48000
@@ -256,12 +253,6 @@ func (c MultiAgentEinoCallbacksConfig) EinoCallbacksMaxOutputSummaryRunes() int
// MultiAgentEinoMiddlewareConfig optional Eino ADK middleware and Deep / supervisor tuning.
type MultiAgentEinoMiddlewareConfig struct {
// MaxToolArgumentsBytes hard-rejects oversized model-generated tool arguments before execution.
MaxToolArgumentsBytes int `yaml:"max_tool_arguments_bytes,omitempty" json:"max_tool_arguments_bytes,omitempty"`
// MaxShellCommandBytes applies a stricter limit to exec/execute command strings.
MaxShellCommandBytes int `yaml:"max_shell_command_bytes,omitempty" json:"max_shell_command_bytes,omitempty"`
// ModelOutputRepairMaxAttempts limits consecutive model-output repair attempts.
ModelOutputRepairMaxAttempts int `yaml:"model_output_repair_max_attempts,omitempty" json:"model_output_repair_max_attempts,omitempty"`
// PatchToolCalls inserts placeholder tool results for dangling assistant tool_calls (nil = enabled).
PatchToolCalls *bool `yaml:"patch_tool_calls,omitempty" json:"patch_tool_calls,omitempty"`
// ToolSearch enables dynamictool/toolsearch: hide tail tools until model calls tool_search (reduces prompt tools).
@@ -297,7 +288,7 @@ type MultiAgentEinoMiddlewareConfig struct {
LatestUserMessageHeadRunes int `yaml:"latest_user_message_head_runes,omitempty" json:"latest_user_message_head_runes,omitempty"`
// LatestUserMessageTailRunes keeps the tail preview for an oversized current user turn.
LatestUserMessageTailRunes int `yaml:"latest_user_message_tail_runes,omitempty" json:"latest_user_message_tail_runes,omitempty"`
// SummarizationRetryMaxAttempts 已废弃:summarization 与 run loop 共用 run_retry_max_attempts 及 isEinoTransientRunError。
// SummarizationRetryMaxAttempts 已废弃:summarization 与 Eino 原生 ModelRetry 共用 model_retry_max_retries 及 isEinoTransientRunError。
SummarizationRetryMaxAttempts int `yaml:"summarization_retry_max_attempts,omitempty" json:"summarization_retry_max_attempts,omitempty"`
// PlanExecuteUserInputBudgetRatio caps planner/replanner/executor userInput prompt budget ratio (default 0.35).
PlanExecuteUserInputBudgetRatio float64 `yaml:"plan_execute_user_input_budget_ratio,omitempty" json:"plan_execute_user_input_budget_ratio,omitempty"`
@@ -311,11 +302,19 @@ type MultiAgentEinoMiddlewareConfig struct {
CheckpointDir string `yaml:"checkpoint_dir,omitempty" json:"checkpoint_dir,omitempty"`
// DeepOutputKey passed to deep.Config OutputKey (session final text); empty = off.
DeepOutputKey string `yaml:"deep_output_key,omitempty" json:"deep_output_key,omitempty"`
// DeepModelRetryMaxRetries 已废弃:临时错误统一由 run loop 内 isEinoTransientRunError + run_retry_max_attempts 处理
// DeepModelRetryMaxRetries 已废弃:请用 model_retry_max_retries;保留字段仅为兼容旧配置
DeepModelRetryMaxRetries int `yaml:"deep_model_retry_max_retries,omitempty" json:"deep_model_retry_max_retries,omitempty"`
// RunRetryMaxAttempts > 0408/409/425/429/5xx/网络抖动时可退避重试次数(run loop 与 summarization 共用);0=默认 4
// ModelRetryMaxRetries configures Eino ADK native ChatModel retry attempts; 0=default 4.
ModelRetryMaxRetries int `yaml:"model_retry_max_retries,omitempty" json:"model_retry_max_retries,omitempty"`
// ModelRetryMaxBackoffSec caps native model retry backoff seconds; 0=default 30.
ModelRetryMaxBackoffSec int `yaml:"model_retry_max_backoff_sec,omitempty" json:"model_retry_max_backoff_sec,omitempty"`
// ModelFailoverChannels lists ai.channels IDs to try after native model retry is exhausted.
ModelFailoverChannels []string `yaml:"model_failover_channels,omitempty" json:"model_failover_channels,omitempty"`
// ModelFailoverMaxRetries caps distinct failover channel attempts; 0=all configured failover channels.
ModelFailoverMaxRetries int `yaml:"model_failover_max_retries,omitempty" json:"model_failover_max_retries,omitempty"`
// RunRetryMaxAttempts 已废弃:模型临时错误由 Eino 原生 ModelRetry 处理;仅保留给非模型层 run loop 兜底与 summarization 旧字段。
RunRetryMaxAttempts int `yaml:"run_retry_max_attempts,omitempty" json:"run_retry_max_attempts,omitempty"`
// RunRetryMaxBackoffSec 单次退避上限秒数;0=默认 30
// RunRetryMaxBackoffSec 已废弃:请用 model_retry_max_backoff_sec;仅保留给非模型层 run loop 兜底与 summarization 旧字段
RunRetryMaxBackoffSec int `yaml:"run_retry_max_backoff_sec,omitempty" json:"run_retry_max_backoff_sec,omitempty"`
// EmptyResponseContinueMaxAttempts Run 成功但未捕获助手正文时 Handler 层退避续跑次数;0=默认 5。
EmptyResponseContinueMaxAttempts int `yaml:"empty_response_continue_max_attempts,omitempty" json:"empty_response_continue_max_attempts,omitempty"`
@@ -323,27 +322,6 @@ type MultiAgentEinoMiddlewareConfig struct {
TaskToolDescriptionPrefix string `yaml:"task_tool_description_prefix,omitempty" json:"task_tool_description_prefix,omitempty"`
}
func (c MultiAgentEinoMiddlewareConfig) MaxToolArgumentsBytesEffective() int {
if c.MaxToolArgumentsBytes > 0 {
return c.MaxToolArgumentsBytes
}
return DefaultMaxToolArgumentsBytes
}
func (c MultiAgentEinoMiddlewareConfig) MaxShellCommandBytesEffective() int {
if c.MaxShellCommandBytes > 0 {
return c.MaxShellCommandBytes
}
return DefaultMaxShellCommandBytes
}
func (c MultiAgentEinoMiddlewareConfig) ModelOutputRepairMaxAttemptsEffective() int {
if c.ModelOutputRepairMaxAttempts > 0 {
return c.ModelOutputRepairMaxAttempts
}
return DefaultModelOutputRepairMaxAttempts
}
func (c MultiAgentEinoMiddlewareConfig) SummarizationTriggerRatioEffective() float64 {
v := c.SummarizationTriggerRatio
if v <= 0 {
@@ -514,6 +492,10 @@ type MultiAgentPublic struct {
LatestUserMessageMaxRunes int `json:"latest_user_message_max_runes"`
LatestUserMessageHeadRunes int `json:"latest_user_message_head_runes"`
LatestUserMessageTailRunes int `json:"latest_user_message_tail_runes"`
ModelRetryMaxRetries int `json:"model_retry_max_retries"`
ModelRetryMaxBackoffSec int `json:"model_retry_max_backoff_sec"`
ModelFailoverChannels []string `json:"model_failover_channels,omitempty"`
ModelFailoverMaxRetries int `json:"model_failover_max_retries"`
ToolSearchAlwaysVisibleTools []string `json:"tool_search_always_visible_tools,omitempty"`
ToolSearchAlwaysVisibleEffectiveTools []string `json:"tool_search_always_visible_effective_tools,omitempty"`
}
@@ -564,6 +546,10 @@ type MultiAgentAPIUpdate struct {
LatestUserMessageMaxRunes *int `json:"latest_user_message_max_runes,omitempty"`
LatestUserMessageHeadRunes *int `json:"latest_user_message_head_runes,omitempty"`
LatestUserMessageTailRunes *int `json:"latest_user_message_tail_runes,omitempty"`
ModelRetryMaxRetries *int `json:"model_retry_max_retries,omitempty"`
ModelRetryMaxBackoffSec *int `json:"model_retry_max_backoff_sec,omitempty"`
ModelFailoverChannels *[]string `json:"model_failover_channels,omitempty"`
ModelFailoverMaxRetries *int `json:"model_failover_max_retries,omitempty"`
// 指针区分「JSON 未传该字段」与「传空数组要清空」;省略时不应覆盖 YAML 中的常驻工具白名单。
ToolSearchAlwaysVisibleTools *[]string `json:"tool_search_always_visible_tools,omitempty"`
}
@@ -972,6 +958,16 @@ func (c OpenAIConfig) MaxCompletionTokensEffective() int {
return DefaultMaxCompletionTokens
}
// IsDeepSeekEndpointOrModel reports whether the channel targets DeepSeek's
// official-compatible API or a DeepSeek model family. This is separate from the
// reasoning profile: profile controls field mapping, while DeepSeek has provider
// constraints such as default thinking mode and no tool_choice in thinking mode.
func (c OpenAIConfig) IsDeepSeekEndpointOrModel() bool {
baseURL := strings.ToLower(strings.TrimSpace(c.BaseURL))
model := strings.ToLower(strings.TrimSpace(c.Model))
return strings.Contains(baseURL, "deepseek") || strings.Contains(model, "deepseek")
}
// OpenAIReasoningConfig 全局默认与网关 profile(对话页可通过 ChatRequest.reasoning 覆盖,受 AllowClientReasoning 约束)。
type OpenAIReasoningConfig struct {
// Mode: auto(默认)| on | off | default(与 auto 相同)。
@@ -1377,7 +1373,7 @@ func Load(path string) (*Config, error) {
cfg.Audit.MaxDetailBytes = 8192
}
cfg.ApplyDefaultAIChannel()
if err := validateModelOutputLimits(cfg.OpenAI, cfg.MultiAgent.EinoMiddleware); err != nil {
if err := validateOpenAIOutputLimits(cfg.OpenAI); err != nil {
return nil, err
}
// 如果配置了工具目录,从目录加载工具配置
@@ -1442,22 +1438,10 @@ func Load(path string) (*Config, error) {
return &cfg, nil
}
func validateModelOutputLimits(openAI OpenAIConfig, mw MultiAgentEinoMiddlewareConfig) error {
func validateOpenAIOutputLimits(openAI OpenAIConfig) error {
if openAI.MaxCompletionTokens < 0 {
return fmt.Errorf("openai.max_completion_tokens 必须为正数")
}
if mw.MaxToolArgumentsBytes < 0 {
return fmt.Errorf("multi_agent.eino_middleware.max_tool_arguments_bytes 必须为正数")
}
if mw.MaxShellCommandBytes < 0 {
return fmt.Errorf("multi_agent.eino_middleware.max_shell_command_bytes 必须为正数")
}
if mw.ModelOutputRepairMaxAttempts < 0 {
return fmt.Errorf("multi_agent.eino_middleware.model_output_repair_max_attempts 必须为正数")
}
if mw.MaxShellCommandBytesEffective() > mw.MaxToolArgumentsBytesEffective() {
return fmt.Errorf("multi_agent.eino_middleware.max_shell_command_bytes 不能大于 max_tool_arguments_bytes")
}
return nil
}
+2 -9
View File
@@ -169,18 +169,11 @@ func TestSummarizationOutputReserveTokensEffective(t *testing.T) {
}
}
func TestModelOutputLimitDefaultsAndValidation(t *testing.T) {
func TestOpenAIOutputLimitValidation(t *testing.T) {
if got := (OpenAIConfig{}).MaxCompletionTokensEffective(); got != DefaultMaxCompletionTokens {
t.Fatalf("max completion default=%d", got)
}
mw := MultiAgentEinoMiddlewareConfig{}
if mw.MaxToolArgumentsBytesEffective() != 65536 || mw.MaxShellCommandBytesEffective() != 65536 || mw.ModelOutputRepairMaxAttemptsEffective() != 1 {
t.Fatalf("unexpected guard defaults: %+v", mw)
}
if err := validateModelOutputLimits(OpenAIConfig{}, MultiAgentEinoMiddlewareConfig{MaxShellCommandBytes: 100, MaxToolArgumentsBytes: 99}); err == nil {
t.Fatal("shell limit greater than generic limit must fail")
}
if err := validateModelOutputLimits(OpenAIConfig{MaxCompletionTokens: -1}, MultiAgentEinoMiddlewareConfig{}); err == nil {
if err := validateOpenAIOutputLimits(OpenAIConfig{MaxCompletionTokens: -1}); err == nil {
t.Fatal("negative completion limit must fail")
}
}
+85
View File
@@ -1353,6 +1353,39 @@ func (db *DB) AddProcessDetailWithID(messageID, conversationID, eventType, messa
return id, nil
}
// UpdateProcessDetailContent 更新流式聚合详情的正文与元数据。使用固定记录 ID,
// 避免每个 token 新增一行,同时让页面刷新能读取到尚未结束的规划输出。
func (db *DB) UpdateProcessDetailContent(id, message string, data interface{}) error {
var dataJSON string
if data != nil {
jsonData, err := json.Marshal(data)
if err != nil {
return fmt.Errorf("序列化过程详情数据失败: %w", err)
}
dataJSON = string(jsonData)
}
result, err := db.Exec(
"UPDATE process_details SET message = ?, data = ? WHERE id = ?",
message, dataJSON, strings.TrimSpace(id),
)
if err != nil {
return fmt.Errorf("更新过程详情失败: %w", err)
}
if affected, affectedErr := result.RowsAffected(); affectedErr == nil && affected == 0 {
return fmt.Errorf("过程详情不存在: %s", id)
}
return nil
}
// DeleteProcessDetail 删除被判定为工具结果回显的临时规划记录。
func (db *DB) DeleteProcessDetail(id string) error {
_, err := db.Exec("DELETE FROM process_details WHERE id = ?", strings.TrimSpace(id))
if err != nil {
return fmt.Errorf("删除过程详情失败: %w", err)
}
return nil
}
// GetProcessDetails 获取消息的过程详情
func (db *DB) GetProcessDetails(messageID string) ([]ProcessDetail, error) {
rows, err := db.Query(
@@ -1420,6 +1453,10 @@ type ProcessDetailsSummary struct {
ToolCount int `json:"toolCount"`
ToolExecutions []ProcessDetailsToolExecution `json:"toolExecutions,omitempty"`
MCPExecutionIDs []string `json:"mcpExecutionIds,omitempty"`
StartedAt *time.Time `json:"startedAt,omitempty"`
CompletedAt *time.Time `json:"completedAt,omitempty"`
DurationMs int64 `json:"durationMs"`
Status string `json:"status,omitempty"`
}
type ProcessDetailsToolExecution struct {
@@ -1442,6 +1479,54 @@ func (db *DB) GetProcessDetailsSummary(messageID string) (*ProcessDetailsSummary
}
summary := &ProcessDetailsSummary{Total: total}
var messageCreatedAt, messageUpdatedAt sql.NullString
var messageContent string
if err := db.QueryRow(
"SELECT created_at, updated_at, content FROM messages WHERE id = ?",
messageID,
).Scan(&messageCreatedAt, &messageUpdatedAt, &messageContent); err != nil && !errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("查询过程详情耗时失败: %w", err)
}
if messageCreatedAt.Valid {
if startedAt := parseDBTime(messageCreatedAt.String); !startedAt.IsZero() {
summary.StartedAt = &startedAt
}
}
var terminalEvent, terminalCreatedAt string
terminalErr := db.QueryRow(`
SELECT event_type, created_at
FROM process_details
WHERE message_id = ? AND event_type IN ('cancelled', 'timeout', 'error')
ORDER BY created_at DESC, rowid DESC
LIMIT 1`, messageID).Scan(&terminalEvent, &terminalCreatedAt)
if terminalErr != nil && !errors.Is(terminalErr, sql.ErrNoRows) {
return nil, fmt.Errorf("查询过程详情终态失败: %w", terminalErr)
}
if terminalEvent != "" {
switch terminalEvent {
case "cancelled":
summary.Status = "cancelled"
case "timeout":
summary.Status = "timeout"
default:
summary.Status = "failed"
}
if completedAt := parseDBTime(terminalCreatedAt); !completedAt.IsZero() {
summary.CompletedAt = &completedAt
}
} else if strings.TrimSpace(messageContent) == "处理中..." || strings.TrimSpace(messageContent) == "Processing..." {
summary.Status = "running"
} else {
summary.Status = "completed"
if messageUpdatedAt.Valid {
if completedAt := parseDBTime(messageUpdatedAt.String); !completedAt.IsZero() {
summary.CompletedAt = &completedAt
}
}
}
if summary.StartedAt != nil && summary.CompletedAt != nil && !summary.CompletedAt.Before(*summary.StartedAt) {
summary.DurationMs = summary.CompletedAt.Sub(*summary.StartedAt).Milliseconds()
}
if total == 0 {
return summary, nil
}
+125
View File
@@ -0,0 +1,125 @@
package database
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
"go.uber.org/zap"
)
// ConversationPlanTask mirrors the public fields persisted by Eino plantask.
// Keeping the transport model here avoids coupling the HTTP layer to Eino's
// private task type.
type ConversationPlanTask struct {
ID string `json:"id"`
Subject string `json:"subject"`
Description string `json:"description,omitempty"`
Status string `json:"status"`
Blocks []string `json:"blocks,omitempty"`
BlockedBy []string `json:"blockedBy,omitempty"`
ActiveForm string `json:"activeForm,omitempty"`
Owner string `json:"owner,omitempty"`
}
// ListConversationPlanTasks returns the live Eino task board for one
// conversation. A missing task directory is the normal state for short or
// legacy conversations and therefore returns an empty list.
func (db *DB) ListConversationPlanTasks(conversationID string) ([]ConversationPlanTask, error) {
return db.ListConversationPlanTasksSince(conversationID, time.Time{})
}
// ListConversationPlanTasksSince limits the board to files written during the
// current agent run. The Eino backend intentionally keeps older task files for
// model continuity, but the conversation UI must not surface those files before
// the new run has called TaskCreate.
func (db *DB) ListConversationPlanTasksSince(conversationID string, since time.Time) ([]ConversationPlanTask, error) {
if db == nil {
return []ConversationPlanTask{}, nil
}
conversationID = strings.TrimSpace(conversationID)
if conversationID == "" {
return nil, fmt.Errorf("conversation id is required")
}
base := strings.TrimSpace(db.einoPlantaskBaseDir)
if base == "" {
return []ConversationPlanTask{}, nil
}
dir := filepath.Join(base, sanitizeConversationPathSegment(conversationID))
entries, err := os.ReadDir(dir)
if os.IsNotExist(err) {
return []ConversationPlanTask{}, nil
}
if err != nil {
return nil, fmt.Errorf("read conversation plan tasks: %w", err)
}
type numberedTask struct {
number int
task ConversationPlanTask
}
numbered := make([]numberedTask, 0, len(entries))
for _, entry := range entries {
if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" {
continue
}
idText := strings.TrimSuffix(entry.Name(), ".json")
number, parseErr := strconv.Atoi(idText)
if parseErr != nil || number < 1 {
continue
}
if !since.IsZero() {
info, infoErr := entry.Info()
if infoErr != nil {
continue
}
if info.ModTime().Before(since) {
continue
}
}
content, readErr := os.ReadFile(filepath.Join(dir, entry.Name()))
if readErr != nil {
if db.logger != nil {
db.logger.Debug("读取 Eino 任务文件失败",
zap.String("conversationId", conversationID),
zap.String("file", entry.Name()),
zap.Error(readErr))
}
continue
}
var task ConversationPlanTask
if decodeErr := json.Unmarshal(content, &task); decodeErr != nil {
// TaskUpdate writes files concurrently with this read. A partial read
// is transient, so skip it and let the next poll recover.
if db.logger != nil {
db.logger.Debug("解析 Eino 任务文件失败",
zap.String("conversationId", conversationID),
zap.String("file", entry.Name()),
zap.Error(decodeErr))
}
continue
}
if strings.TrimSpace(task.ID) == "" {
task.ID = idText
}
if strings.EqualFold(strings.TrimSpace(task.Status), "deleted") {
continue
}
numbered = append(numbered, numberedTask{number: number, task: task})
}
sort.SliceStable(numbered, func(i, j int) bool {
return numbered[i].number < numbered[j].number
})
tasks := make([]ConversationPlanTask, 0, len(numbered))
for _, item := range numbered {
tasks = append(tasks, item.task)
}
return tasks, nil
}
+104
View File
@@ -0,0 +1,104 @@
package database
import (
"os"
"path/filepath"
"testing"
"time"
"go.uber.org/zap"
)
func TestListConversationPlanTasksSortedAndToleratesMissingDirectory(t *testing.T) {
tmp := t.TempDir()
db, err := NewDB(filepath.Join(tmp, "plantask.db"), zap.NewNop())
if err != nil {
t.Fatalf("NewDB: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
base := filepath.Join(tmp, "skills", ".eino", "plantask")
db.SetEinoConversationDirs(base, "", "", "")
missing, err := db.ListConversationPlanTasks("missing")
if err != nil || len(missing) != 0 {
t.Fatalf("missing task board = %#v, err=%v", missing, err)
}
dir := filepath.Join(base, "conversation-1")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
files := map[string]string{
"10.json": `{"id":"10","subject":"最后检查","status":"pending"}`,
"2.json": `{"id":"2","subject":"实现接口","status":"in_progress","activeForm":"正在实现接口"}`,
"1.json": `{"id":"1","subject":"梳理需求","status":"completed"}`,
"bad.json": `{`,
}
for name, content := range files {
if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644); err != nil {
t.Fatalf("WriteFile(%s): %v", name, err)
}
}
if err := os.WriteFile(filepath.Join(dir, ".highwatermark"), []byte("10"), 0o644); err != nil {
t.Fatalf("WriteFile(highwatermark): %v", err)
}
tasks, err := db.ListConversationPlanTasks("conversation-1")
if err != nil {
t.Fatalf("ListConversationPlanTasks: %v", err)
}
if len(tasks) != 3 {
t.Fatalf("tasks = %#v, want 3", tasks)
}
if tasks[0].ID != "1" || tasks[1].ID != "2" || tasks[2].ID != "10" {
t.Fatalf("task order = %q, %q, %q", tasks[0].ID, tasks[1].ID, tasks[2].ID)
}
if tasks[1].ActiveForm != "正在实现接口" {
t.Fatalf("activeForm = %q", tasks[1].ActiveForm)
}
}
func TestListConversationPlanTasksSinceHidesPreviousRunUntilTaskCreate(t *testing.T) {
tmp := t.TempDir()
db, err := NewDB(filepath.Join(tmp, "plantask-current-run.db"), zap.NewNop())
if err != nil {
t.Fatalf("NewDB: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
base := filepath.Join(tmp, "plantask")
db.SetEinoConversationDirs(base, "", "", "")
dir := filepath.Join(base, "conversation-current-run")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
oldPath := filepath.Join(dir, "1.json")
if err := os.WriteFile(oldPath, []byte(`{"id":"1","subject":"上一轮任务","status":"in_progress"}`), 0o644); err != nil {
t.Fatalf("WriteFile(old): %v", err)
}
runStartedAt := time.Now().Add(-time.Second)
oldTime := runStartedAt.Add(-time.Minute)
if err := os.Chtimes(oldPath, oldTime, oldTime); err != nil {
t.Fatalf("Chtimes(old): %v", err)
}
tasks, err := db.ListConversationPlanTasksSince("conversation-current-run", runStartedAt)
if err != nil {
t.Fatalf("ListConversationPlanTasksSince(before TaskCreate): %v", err)
}
if len(tasks) != 0 {
t.Fatalf("stale tasks shown before current TaskCreate: %#v", tasks)
}
newPath := filepath.Join(dir, "2.json")
if err := os.WriteFile(newPath, []byte(`{"id":"2","subject":"本轮任务","status":"pending"}`), 0o644); err != nil {
t.Fatalf("WriteFile(new): %v", err)
}
tasks, err = db.ListConversationPlanTasksSince("conversation-current-run", runStartedAt)
if err != nil {
t.Fatalf("ListConversationPlanTasksSince(after TaskCreate): %v", err)
}
if len(tasks) != 1 || tasks[0].ID != "2" {
t.Fatalf("current tasks = %#v, want task 2 only", tasks)
}
}
@@ -3,6 +3,7 @@ package database
import (
"path/filepath"
"testing"
"time"
"go.uber.org/zap"
)
@@ -105,6 +106,63 @@ func TestProcessDetailsSummaryDoesNotReportPersistedOrphanAsRunning(t *testing.T
}
}
func TestProcessDetailsSummaryIncludesPersistedTurnTiming(t *testing.T) {
db, _, messageID := setupProcessDetailsSummaryTest(t)
startedAt := "2026-08-10T08:00:00Z"
completedAt := "2026-08-10T08:12:59Z"
if _, err := db.Exec(
"UPDATE messages SET content = ?, created_at = ?, updated_at = ? WHERE id = ?",
"done", startedAt, completedAt, messageID,
); err != nil {
t.Fatalf("update message timing: %v", err)
}
summary, err := db.GetProcessDetailsSummary(messageID)
if err != nil {
t.Fatalf("GetProcessDetailsSummary: %v", err)
}
if summary.Status != "completed" {
t.Fatalf("status = %q, want completed", summary.Status)
}
if summary.StartedAt == nil || summary.CompletedAt == nil {
t.Fatalf("timing missing: %#v", summary)
}
if want := int64((12*time.Minute + 59*time.Second) / time.Millisecond); summary.DurationMs != want {
t.Fatalf("durationMs = %d, want %d", summary.DurationMs, want)
}
}
func TestProcessDetailsSummaryTreatsCancelledPlaceholderAsTerminal(t *testing.T) {
db, conversationID, messageID := setupProcessDetailsSummaryTest(t)
startedAt := "2026-08-10T08:00:00Z"
if _, err := db.Exec(
"UPDATE messages SET content = ?, created_at = ?, updated_at = ? WHERE id = ?",
"处理中...", startedAt, startedAt, messageID,
); err != nil {
t.Fatalf("update running placeholder: %v", err)
}
if _, err := db.Exec(`
INSERT INTO process_details (id, message_id, conversation_id, event_type, message, data, created_at)
VALUES ('cancelled-detail', ?, ?, 'cancelled', 'interrupted', '{}', '2026-08-10T08:02:05Z')`,
messageID, conversationID); err != nil {
t.Fatalf("insert cancelled detail: %v", err)
}
summary, err := db.GetProcessDetailsSummary(messageID)
if err != nil {
t.Fatalf("GetProcessDetailsSummary: %v", err)
}
if summary.Status != "cancelled" {
t.Fatalf("status = %q, want cancelled", summary.Status)
}
if summary.CompletedAt == nil {
t.Fatal("cancelled summary should expose a fixed completion time")
}
if want := int64((2*time.Minute + 5*time.Second) / time.Millisecond); summary.DurationMs != want {
t.Fatalf("durationMs = %d, want %d", summary.DurationMs, want)
}
}
func setupProcessDetailsSummaryTest(t *testing.T) (*DB, string, string) {
t.Helper()
db, err := NewDB(filepath.Join(t.TempDir(), "process-details.db"), zap.NewNop())
@@ -0,0 +1,57 @@
package database
import (
"database/sql"
"encoding/json"
"fmt"
"strings"
"time"
)
// FindNearestToolExecutionArguments returns the arguments for the execution record
// closest to a persisted tool_call detail. Eino can persist a tool_call with empty
// model arguments while the monitor execution row still has the real command/URL.
func (db *DB) FindNearestToolExecutionArguments(conversationID, toolName string, at time.Time, window time.Duration) (string, map[string]interface{}, error) {
conversationID = strings.TrimSpace(conversationID)
toolName = strings.TrimSpace(toolName)
if db == nil || conversationID == "" || toolName == "" || at.IsZero() {
return "", nil, sql.ErrNoRows
}
if window <= 0 {
window = 5 * time.Second
}
names := []string{toolName}
if !strings.Contains(toolName, "::") {
names = append(names, "eino_fs::"+toolName)
}
start := at.Add(-window)
end := at.Add(window)
rows, err := db.Query(`
SELECT id, arguments
FROM tool_executions
WHERE conversation_id = ?
AND tool_name IN (?, ?)
AND julianday(start_time) BETWEEN julianday(?) AND julianday(?)
ORDER BY ABS(julianday(start_time) - julianday(?)) ASC, start_time ASC
LIMIT 1`, conversationID, names[0], names[len(names)-1], start, end, at)
if err != nil {
return "", nil, err
}
defer rows.Close()
if !rows.Next() {
if err := rows.Err(); err != nil {
return "", nil, err
}
return "", nil, sql.ErrNoRows
}
var id string
var raw string
if err := rows.Scan(&id, &raw); err != nil {
return "", nil, err
}
var args map[string]interface{}
if err := json.Unmarshal([]byte(raw), &args); err != nil {
return "", nil, fmt.Errorf("parse tool execution arguments: %w", err)
}
return strings.TrimSpace(id), args, nil
}
+5 -1
View File
@@ -38,6 +38,7 @@ type Params struct {
ConversationID string
OrchMode string
OrchestratorName string
RunID string
}
// AttachAgentRunCallbacks returns ctx wrapped with callbacks.InitCallbacks when enabled.
@@ -53,7 +54,10 @@ func AttachAgentRunCallbacks(ctx context.Context, cfg *config.MultiAgentEinoCall
if mode == "off" {
return ctx
}
runID := uuid.New().String()
runID := strings.TrimSpace(p.RunID)
if runID == "" {
runID = uuid.New().String()
}
if p.Progress != nil && cfg.ShouldEmitEinoTraceSSE(mode) {
p.Progress("eino_trace_run", "Eino callbacks session", map[string]interface{}{
"runId": runID,
+23
View File
@@ -16,6 +16,29 @@ func TestAttachAgentRunCallbacks_Disabled(t *testing.T) {
}
}
func TestAttachAgentRunCallbacksUsesProvidedRunID(t *testing.T) {
emit := true
var gotRunID string
ctx := context.Background()
cfg := &config.MultiAgentEinoCallbacksConfig{Enabled: true, Mode: "sse", SseTraceToClient: &emit}
AttachAgentRunCallbacks(ctx, cfg, Params{
RunID: "run-shared",
Progress: func(eventType, _ string, data interface{}) {
if eventType != "eino_trace_run" {
return
}
if m, ok := data.(map[string]interface{}); ok {
gotRunID, _ = m["runId"].(string)
}
},
})
if gotRunID != "run-shared" {
t.Fatalf("runId = %q, want run-shared", gotRunID)
}
}
func TestTruncateRunes(t *testing.T) {
if got := truncateRunes("abc", 10); got != "abc" {
t.Fatalf("got %q", got)
+98 -21
View File
@@ -77,6 +77,9 @@ found:
type responsePlanAgg struct {
meta map[string]interface{}
b strings.Builder
detailID string
lastPersistAt time.Time
lastPersistSize int
}
// thinkingBuf aggregates thinking_stream_* / reasoning_chain_stream_* before flush to process_details.
@@ -145,30 +148,36 @@ func responseStreamIterationFromMeta(m map[string]interface{}) int {
}
}
func discardPlanningIfEchoesToolResult(respPlan *responsePlanAgg, toolData interface{}) {
func discardPlanningIfEchoesToolResult(respPlan *responsePlanAgg, toolData interface{}) string {
if respPlan == nil {
return
return ""
}
plan := normalizeProcessDetailText(respPlan.b.String())
if plan == "" {
return
return ""
}
dataMap, ok := toolData.(map[string]interface{})
if !ok {
return
return ""
}
res, ok := dataMap["result"].(string)
if !ok {
return
return ""
}
r := normalizeProcessDetailText(res)
if r == "" {
return
return ""
}
if plan == r || strings.HasSuffix(plan, r) {
detailID := respPlan.detailID
respPlan.meta = nil
respPlan.b.Reset()
respPlan.detailID = ""
respPlan.lastPersistAt = time.Time{}
respPlan.lastPersistSize = 0
return detailID
}
return ""
}
// AgentHandler Agent处理器
@@ -221,6 +230,20 @@ func (h *AgentHandler) CancelRunningTaskForConversation(conversationID string) {
}
}
// ConversationTaskRuntimeState exposes the authoritative live state and start
// time used to scope persisted TaskCreate files to the current run. A task
// already entering cancellation must stop driving progress UI immediately.
func (h *AgentHandler) ConversationTaskRuntimeState(conversationID string) (bool, time.Time) {
if h == nil || h.tasks == nil || strings.TrimSpace(conversationID) == "" {
return false, time.Time{}
}
task := h.tasks.GetTaskSnapshot(strings.TrimSpace(conversationID))
if task == nil || !strings.EqualFold(strings.TrimSpace(task.Status), "running") {
return false, time.Time{}
}
return true, task.StartedAt
}
func (h *AgentHandler) cancelRunningMCPToolsForConversation(conversationID string) {
if h == nil || h.agent == nil {
return
@@ -888,6 +911,32 @@ func (h *AgentHandler) publishProgressToTaskEventBus(conversationID, eventType,
h.taskEventBus.Publish(conversationID, sseLine)
}
func isInternalEinoDiagnosticProgress(eventType, message string, data interface{}) bool {
switch eventType {
case "model_output_rejected":
return true
case "progress":
msg := strings.TrimSpace(message)
if msg == "Eino TurnLoop 常驻多轮 runtime 已接管本轮会话。" ||
msg == "Eino TurnLoop 已在安全点切换到用户补充后的下一轮。" ||
msg == "已将用户补充推入 Eino TurnLoop,正在等待安全点切换…" {
return true
}
m, ok := data.(map[string]interface{})
if !ok {
return false
}
switch strings.TrimSpace(fmt.Sprint(m["kind"])) {
case "turn_loop_takeover", "turn_loop_preempted":
return true
default:
return false
}
default:
return false
}
}
// enrichProgressEventData 为 SSE / taskEventBus 事件补齐 conversationId、messageId,便于前端懒加载过程详情。
func enrichProgressEventData(data interface{}, conversationID, assistantMessageID string) interface{} {
if strings.TrimSpace(conversationID) == "" && strings.TrimSpace(assistantMessageID) == "" {
@@ -976,14 +1025,15 @@ func (h *AgentHandler) createProgressCallback(runCtx context.Context, cancelRun
syncHitlCognition := func() {
h.syncHitlCognitionFromProgress(conversationID, assistantMessageID, thinkingStreams, &respPlan)
}
flushResponsePlan := func() {
persistResponsePlan := func(reset bool) {
if assistantMessageID == "" {
return
}
content := strings.TrimSpace(respPlan.b.String())
if content == "" {
respPlan.meta = nil
respPlan.b.Reset()
if reset {
respPlan = responsePlanAgg{}
}
return
}
data := map[string]interface{}{
@@ -992,13 +1042,26 @@ func (h *AgentHandler) createProgressCallback(runCtx context.Context, cancelRun
for k, v := range respPlan.meta {
data[k] = v
}
if err := h.db.AddProcessDetail(assistantMessageID, conversationID, "planning", content, data); err != nil {
var err error
if respPlan.detailID == "" {
respPlan.detailID, err = h.db.AddProcessDetailWithID(
assistantMessageID, conversationID, "planning", content, data,
)
} else {
err = h.db.UpdateProcessDetailContent(respPlan.detailID, content, data)
}
if err != nil {
h.logger.Warn("保存过程详情失败", zap.Error(err), zap.String("eventType", "planning"))
} else {
respPlan.lastPersistAt = time.Now()
respPlan.lastPersistSize = respPlan.b.Len()
}
syncHitlCognition()
respPlan.meta = nil
respPlan.b.Reset()
if reset {
respPlan = responsePlanAgg{}
}
}
flushResponsePlan := func() { persistResponsePlan(true) }
flushThinkingStreams := func() {
if assistantMessageID == "" {
@@ -1039,6 +1102,10 @@ func (h *AgentHandler) createProgressCallback(runCtx context.Context, cancelRun
progressMu.Lock()
defer progressMu.Unlock()
if isInternalEinoDiagnosticProgress(eventType, message, data) {
return
}
// 上游在重试/补偿时可能重复回调相同 tool_call/tool_result。
// 这里做幂等过滤,保证前端展示和 process_details 都以唯一事件为准。
if (eventType == "tool_call" || eventType == "tool_result") && data != nil {
@@ -1068,15 +1135,15 @@ func (h *AgentHandler) createProgressCallback(runCtx context.Context, cancelRun
}
deferToolProgressSend := eventType == "tool_call" || eventType == "tool_result"
// 流式:写 HTTP SSE;非流式(机器人等):镜像到 taskEventBus 供 Web 订阅。
// 工具事件需先落库拿 processDetailId,再向前端发送摘要,避免大 payload 默认进入浏览器。
// HTTP SSE 与 taskEventBus 必须同时写入:页面刷新会切断原连接,刷新后的
// GET task-events 订阅依赖 eventBus 才能继续收到后续迭代。机器人等无主 SSE
// 的来源同样只写 eventBus。工具事件需先落库拿 processDetailId,再发送摘要。
if !deferToolProgressSend {
clientData := enrichProgressEventData(data, conversationID, assistantMessageID)
if sendEventFunc != nil {
sendEventFunc(eventType, message, clientData)
} else {
h.publishProgressToTaskEventBus(conversationID, eventType, message, clientData)
}
h.publishProgressToTaskEventBus(conversationID, eventType, message, clientData)
}
// 保存tool_call事件中的参数
@@ -1329,6 +1396,14 @@ func (h *AgentHandler) createProgressCallback(runCtx context.Context, cancelRun
respPlan.meta[k] = v
}
}
// 运行中的主回复不能只保存在内存:刷新会销毁旧页面,新的 task-events
// 订阅只能收到未来增量。按时间或增量大小节流更新同一条 planning 记录,
// 这样刷新时能从数据库恢复刷新前已经展示的全部文本。
if respPlan.lastPersistAt.IsZero() ||
time.Since(respPlan.lastPersistAt) >= 300*time.Millisecond ||
respPlan.b.Len()-respPlan.lastPersistSize >= 1024 {
persistResponsePlan(false)
}
syncHitlCognition()
return
}
@@ -1439,7 +1514,11 @@ func (h *AgentHandler) createProgressCallback(runCtx context.Context, cancelRun
eventType != "eino_agent_reply_stream_delta" &&
eventType != "eino_agent_reply_stream_end" {
if eventType == "tool_result" {
discardPlanningIfEchoesToolResult(&respPlan, data)
if detailID := discardPlanningIfEchoesToolResult(&respPlan, data); detailID != "" {
if err := h.db.DeleteProcessDetail(detailID); err != nil {
h.logger.Warn("删除工具结果回显规划失败", zap.Error(err), zap.String("processDetailId", detailID))
}
}
}
// 在关键过程事件落库前,先把「规划中」与聚合中的 thinking / reasoning_chain 流落库
flushResponsePlan()
@@ -1455,17 +1534,15 @@ func (h *AgentHandler) createProgressCallback(runCtx context.Context, cancelRun
}
if sendEventFunc != nil {
sendEventFunc(eventType, message, clientData)
} else {
h.publishProgressToTaskEventBus(conversationID, eventType, message, clientData)
}
h.publishProgressToTaskEventBus(conversationID, eventType, message, clientData)
}
} else if deferToolProgressSend {
clientData := enrichProgressEventData(summarizeProcessDetailData(eventType, data), conversationID, assistantMessageID)
if sendEventFunc != nil {
sendEventFunc(eventType, message, clientData)
} else {
h.publishProgressToTaskEventBus(conversationID, eventType, message, clientData)
}
h.publishProgressToTaskEventBus(conversationID, eventType, message, clientData)
}
}
}
@@ -5,8 +5,10 @@ import (
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"cyberstrike-ai/internal/config"
"cyberstrike-ai/internal/database"
@@ -51,6 +53,124 @@ func TestCreateProgressCallback_ConcurrentToolEvents(t *testing.T) {
wg.Wait()
}
// TestCreateProgressCallback_MirrorsWebStreamEvents 页面刷新后 task-events 订阅必须
// 继续收到原 Web SSE 任务的后续事件,不能只等数据库最终结果。
func TestCreateProgressCallback_MirrorsWebStreamEvents(t *testing.T) {
bus := NewTaskEventBus()
h := &AgentHandler{logger: zap.NewNop(), config: &config.Config{}, taskEventBus: bus}
_, events := bus.Subscribe("conv-refresh-stream")
primaryCalls := 0
cb := h.createProgressCallback(
context.Background(), nil, "conv-refresh-stream", "",
func(eventType, message string, data interface{}) { primaryCalls++ },
)
cb("progress", "第 3 轮", map[string]interface{}{"iteration": 3})
if primaryCalls != 1 {
t.Fatalf("expected primary SSE callback once, got %d", primaryCalls)
}
select {
case payload := <-events:
body := string(payload)
if !strings.Contains(body, `"type":"progress"`) || !strings.Contains(body, `"conversationId":"conv-refresh-stream"`) {
t.Fatalf("unexpected mirrored event: %s", body)
}
case <-time.After(time.Second):
t.Fatal("expected progress event mirrored to task event bus")
}
}
func TestCreateProgressCallback_HidesInternalEinoDiagnostics(t *testing.T) {
tmp := t.TempDir()
db, err := database.NewDB(filepath.Join(tmp, "test.sqlite"), zap.NewNop())
if err != nil {
t.Fatalf("NewDB: %v", err)
}
conv, err := db.CreateConversation("diag-hidden", database.ConversationCreateMeta{})
if err != nil {
t.Fatalf("CreateConversation: %v", err)
}
asst, err := db.AddMessage(conv.ID, "assistant", "处理中...", nil)
if err != nil {
t.Fatalf("AddMessage: %v", err)
}
bus := NewTaskEventBus()
h := &AgentHandler{logger: zap.NewNop(), db: db, taskEventBus: bus}
_, events := bus.Subscribe(conv.ID)
primaryCalls := 0
cb := h.createProgressCallback(
context.Background(), nil, conv.ID, asst.ID,
func(string, string, interface{}) { primaryCalls++ },
)
cb("model_output_rejected", "模型工具调用不完整或参数不安全,已阻止执行并要求重写。", map[string]interface{}{
"reason": "invalid_tool_arguments_json",
})
cb("progress", "Eino TurnLoop 常驻多轮 runtime 已接管本轮会话。", map[string]interface{}{
"kind": "turn_loop_takeover",
})
if primaryCalls != 0 {
t.Fatalf("primary SSE calls = %d, want hidden diagnostics", primaryCalls)
}
select {
case payload := <-events:
t.Fatalf("unexpected mirrored diagnostic event: %s", string(payload))
default:
}
details, err := db.GetProcessDetails(asst.ID)
if err != nil {
t.Fatalf("GetProcessDetails: %v", err)
}
if len(details) != 0 {
t.Fatalf("process details = %+v, want no diagnostics persisted", details)
}
}
func TestCreateProgressCallback_PersistsRunningResponseBeforeDone(t *testing.T) {
tmp := t.TempDir()
db, err := database.NewDB(filepath.Join(tmp, "test.sqlite"), zap.NewNop())
if err != nil {
t.Fatalf("NewDB: %v", err)
}
conv, err := db.CreateConversation("refresh-running", database.ConversationCreateMeta{})
if err != nil {
t.Fatalf("CreateConversation: %v", err)
}
asst, err := db.AddMessage(conv.ID, "assistant", "处理中...", nil)
if err != nil {
t.Fatalf("AddMessage: %v", err)
}
h := &AgentHandler{logger: zap.NewNop(), db: db}
cb := h.createProgressCallback(context.Background(), nil, conv.ID, asst.ID, nil)
meta := map[string]interface{}{
"streamId": "response-refresh-1",
"einoAgent": "cyberstrike-eino-single",
"orchestration": "eino_single",
}
cb("response_start", "", meta)
cb("response_delta", "刷新前已生成的第一部分", openai.WithSSEAccumulated(meta, "刷新前已生成的第一部分"))
details, err := db.GetProcessDetails(asst.ID)
if err != nil {
t.Fatalf("GetProcessDetails: %v", err)
}
if len(details) != 1 || details[0].EventType != "planning" || details[0].Message != "刷新前已生成的第一部分" {
t.Fatalf("expected one running planning snapshot, got %+v", details)
}
longer := "刷新前已生成的第一部分" + strings.Repeat("继续迭代", 300)
cb("response_delta", "继续迭代", openai.WithSSEAccumulated(meta, longer))
details, err = db.GetProcessDetails(asst.ID)
if err != nil {
t.Fatalf("GetProcessDetails after update: %v", err)
}
if len(details) != 1 || details[0].Message != longer {
t.Fatalf("running snapshot should update in-place, rows=%d len=%d", len(details), len(details[0].Message))
}
}
// TestCreateProgressCallback_FlushesReasoningOnDone 流式推理聚合须在 done/response 时落库,刷新后可回放。
func TestCreateProgressCallback_FlushesReasoningOnDone(t *testing.T) {
tmp := t.TempDir()
+13 -2
View File
@@ -13,6 +13,7 @@ import (
"cyberstrike-ai/internal/audit"
"cyberstrike-ai/internal/authctx"
"cyberstrike-ai/internal/config"
"cyberstrike-ai/internal/database"
"cyberstrike-ai/internal/mcp"
"cyberstrike-ai/internal/multiagent"
@@ -118,8 +119,7 @@ func (h *AgentHandler) executeOneBatchSubTask(queueID string, queue *BatchTaskQu
}
principal := authctx.NewPrincipalWithScopes(access.User.ID, access.User.Username, access.Scope, access.Permissions, access.PermissionScopes)
title := safeTruncateString(task.Message, 50)
batchMeta := audit.ConversationCreateMeta("batch_task")
batchMeta.ProjectID = effectiveProjectID(h.config, queue.ProjectID)
batchMeta := batchSubTaskConversationMeta(h.config, queue)
conv, err := h.db.CreateConversation(title, batchMeta)
if err != nil {
h.logger.Error("创建对话失败", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.Error(err))
@@ -321,6 +321,17 @@ func (h *AgentHandler) executeOneBatchSubTask(queueID string, queue *BatchTaskQu
h.batchTaskManager.UpdateTaskStatusWithConversationID(queueID, task.ID, BatchTaskStatusCompleted, resText, "", conversationID)
}
func batchSubTaskConversationMeta(cfg *config.Config, queue *BatchTaskQueue) database.ConversationCreateMeta {
meta := audit.ConversationCreateMeta("batch_task")
if queue == nil {
meta.ProjectID = effectiveProjectID(cfg, "")
return meta
}
meta.ProjectID = effectiveProjectID(cfg, queue.ProjectID)
meta.RoleName = strings.TrimSpace(queue.Role)
return meta
}
func (h *AgentHandler) handleBatchSubTaskRunError(
queueID string,
task *BatchTask,
@@ -61,6 +61,18 @@ func TestBatchQueueExecutionShouldStop(t *testing.T) {
}
}
func TestBatchSubTaskConversationMetaKeepsQueueRole(t *testing.T) {
t.Parallel()
meta := batchSubTaskConversationMeta(nil, &BatchTaskQueue{Role: " 渗透测试 "})
if meta.Source != "batch_task" {
t.Fatalf("expected batch_task source, got %q", meta.Source)
}
if meta.RoleName != "渗透测试" {
t.Fatalf("expected queue role to be stored on child conversation, got %q", meta.RoleName)
}
}
func TestDeleteQueueBlockedWhileExecutorActive(t *testing.T) {
t.Parallel()
m := NewBatchTaskManager(zap.NewNop())
+40
View File
@@ -356,6 +356,10 @@ func (h *ConfigHandler) GetConfig(c *gin.Context) {
LatestUserMessageMaxRunes: h.config.MultiAgent.EinoMiddleware.LatestUserMessageMaxRunesEffective(),
LatestUserMessageHeadRunes: h.config.MultiAgent.EinoMiddleware.LatestUserMessageHeadRunesEffective(),
LatestUserMessageTailRunes: h.config.MultiAgent.EinoMiddleware.LatestUserMessageTailRunesEffective(),
ModelRetryMaxRetries: h.config.MultiAgent.EinoMiddleware.ModelRetryMaxRetries,
ModelRetryMaxBackoffSec: h.config.MultiAgent.EinoMiddleware.ModelRetryMaxBackoffSec,
ModelFailoverChannels: append([]string(nil), h.config.MultiAgent.EinoMiddleware.ModelFailoverChannels...),
ModelFailoverMaxRetries: h.config.MultiAgent.EinoMiddleware.ModelFailoverMaxRetries,
ToolSearchAlwaysVisibleTools: append([]string(nil), h.config.MultiAgent.EinoMiddleware.ToolSearchAlwaysVisibleTools...),
ToolSearchAlwaysVisibleEffectiveTools: mergeToolNameLists(
h.config.MultiAgent.EinoMiddleware.ToolSearchAlwaysVisibleTools,
@@ -1002,6 +1006,30 @@ func (h *ConfigHandler) UpdateConfig(c *gin.Context) {
}
h.config.MultiAgent.EinoMiddleware.LatestUserMessageTailRunes = v
}
if req.MultiAgent.ModelRetryMaxRetries != nil {
v := *req.MultiAgent.ModelRetryMaxRetries
if v < 0 {
v = 0
}
h.config.MultiAgent.EinoMiddleware.ModelRetryMaxRetries = v
}
if req.MultiAgent.ModelRetryMaxBackoffSec != nil {
v := *req.MultiAgent.ModelRetryMaxBackoffSec
if v < 0 {
v = 0
}
h.config.MultiAgent.EinoMiddleware.ModelRetryMaxBackoffSec = v
}
if req.MultiAgent.ModelFailoverChannels != nil {
h.config.MultiAgent.EinoMiddleware.ModelFailoverChannels = dedupeTrimmedStringList(*req.MultiAgent.ModelFailoverChannels)
}
if req.MultiAgent.ModelFailoverMaxRetries != nil {
v := *req.MultiAgent.ModelFailoverMaxRetries
if v < 0 {
v = 0
}
h.config.MultiAgent.EinoMiddleware.ModelFailoverMaxRetries = v
}
if req.MultiAgent.ToolSearchAlwaysVisibleTools != nil {
h.config.MultiAgent.EinoMiddleware.ToolSearchAlwaysVisibleTools = dedupeToolNameList(*req.MultiAgent.ToolSearchAlwaysVisibleTools)
}
@@ -1015,6 +1043,10 @@ func (h *ConfigHandler) UpdateConfig(c *gin.Context) {
zap.Int("latest_user_message_max_runes", h.config.MultiAgent.EinoMiddleware.LatestUserMessageMaxRunesEffective()),
zap.Int("latest_user_message_head_runes", h.config.MultiAgent.EinoMiddleware.LatestUserMessageHeadRunesEffective()),
zap.Int("latest_user_message_tail_runes", h.config.MultiAgent.EinoMiddleware.LatestUserMessageTailRunesEffective()),
zap.Int("model_retry_max_retries", h.config.MultiAgent.EinoMiddleware.ModelRetryMaxRetries),
zap.Int("model_retry_max_backoff_sec", h.config.MultiAgent.EinoMiddleware.ModelRetryMaxBackoffSec),
zap.Int("model_failover_channels", len(h.config.MultiAgent.EinoMiddleware.ModelFailoverChannels)),
zap.Int("model_failover_max_retries", h.config.MultiAgent.EinoMiddleware.ModelFailoverMaxRetries),
zap.Int("tool_search_always_visible_tools", len(h.config.MultiAgent.EinoMiddleware.ToolSearchAlwaysVisibleTools)),
)
}
@@ -2191,10 +2223,18 @@ func updateMultiAgentConfig(doc *yaml.Node, cfg config.MultiAgentConfig) {
setIntInMap(mwNode, "latest_user_message_max_runes", cfg.EinoMiddleware.LatestUserMessageMaxRunesEffective())
setIntInMap(mwNode, "latest_user_message_head_runes", cfg.EinoMiddleware.LatestUserMessageHeadRunesEffective())
setIntInMap(mwNode, "latest_user_message_tail_runes", cfg.EinoMiddleware.LatestUserMessageTailRunesEffective())
setIntInMap(mwNode, "model_retry_max_retries", cfg.EinoMiddleware.ModelRetryMaxRetries)
setIntInMap(mwNode, "model_retry_max_backoff_sec", cfg.EinoMiddleware.ModelRetryMaxBackoffSec)
setFlowStringSliceInMap(mwNode, "model_failover_channels", dedupeTrimmedStringList(cfg.EinoMiddleware.ModelFailoverChannels))
setIntInMap(mwNode, "model_failover_max_retries", cfg.EinoMiddleware.ModelFailoverMaxRetries)
setFlowStringSliceInMap(mwNode, "tool_search_always_visible_tools", dedupeToolNameList(cfg.EinoMiddleware.ToolSearchAlwaysVisibleTools))
}
func dedupeToolNameList(in []string) []string {
return dedupeTrimmedStringList(in)
}
func dedupeTrimmedStringList(in []string) []string {
if len(in) == 0 {
return []string{}
}
@@ -0,0 +1,64 @@
package handler
import (
"testing"
"cyberstrike-ai/internal/config"
"gopkg.in/yaml.v3"
)
func TestUpdateMultiAgentConfigWritesEinoModelResilience(t *testing.T) {
doc := &yaml.Node{
Kind: yaml.DocumentNode,
Content: []*yaml.Node{{
Kind: yaml.MappingNode,
Tag: "!!map",
}},
}
updateMultiAgentConfig(doc, config.MultiAgentConfig{
Enabled: true,
RobotDefaultAgentMode: "deep",
PlanExecuteLoopMaxIterations: 3,
EinoMiddleware: config.MultiAgentEinoMiddlewareConfig{
ModelRetryMaxRetries: 5,
ModelRetryMaxBackoffSec: 45,
ModelFailoverChannels: []string{"backup-openai", "backup-claude", "backup-openai"},
ModelFailoverMaxRetries: 2,
},
})
var got struct {
MultiAgent struct {
EinoMiddleware struct {
ModelRetryMaxRetries int `yaml:"model_retry_max_retries"`
ModelRetryMaxBackoffSec int `yaml:"model_retry_max_backoff_sec"`
ModelFailoverChannels []string `yaml:"model_failover_channels"`
ModelFailoverMaxRetries int `yaml:"model_failover_max_retries"`
} `yaml:"eino_middleware"`
} `yaml:"multi_agent"`
}
if err := doc.Decode(&got); err != nil {
t.Fatalf("decode config yaml: %v", err)
}
mw := got.MultiAgent.EinoMiddleware
if mw.ModelRetryMaxRetries != 5 {
t.Fatalf("model_retry_max_retries = %d, want 5", mw.ModelRetryMaxRetries)
}
if mw.ModelRetryMaxBackoffSec != 45 {
t.Fatalf("model_retry_max_backoff_sec = %d, want 45", mw.ModelRetryMaxBackoffSec)
}
if mw.ModelFailoverMaxRetries != 2 {
t.Fatalf("model_failover_max_retries = %d, want 2", mw.ModelFailoverMaxRetries)
}
wantChannels := []string{"backup-openai", "backup-claude"}
if len(mw.ModelFailoverChannels) != len(wantChannels) {
t.Fatalf("model_failover_channels = %#v, want %#v", mw.ModelFailoverChannels, wantChannels)
}
for i, want := range wantChannels {
if mw.ModelFailoverChannels[i] != want {
t.Fatalf("model_failover_channels[%d] = %q, want %q", i, mw.ModelFailoverChannels[i], want)
}
}
}
+132 -4
View File
@@ -2,9 +2,11 @@ package handler
import (
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"cyberstrike-ai/internal/audit"
"cyberstrike-ai/internal/database"
@@ -18,12 +20,20 @@ type ConversationTaskStopper interface {
CancelRunningTaskForConversation(conversationID string)
}
// ConversationTaskStateProvider reports whether the in-memory agent task for
// a conversation is still genuinely running. Plan files may survive a service
// restart or cancellation, so their status alone is not authoritative.
type ConversationTaskStateProvider interface {
ConversationTaskRuntimeState(conversationID string) (running bool, startedAt time.Time)
}
// ConversationHandler 对话处理器
type ConversationHandler struct {
db *database.DB
logger *zap.Logger
audit *audit.Service
taskStopper ConversationTaskStopper
taskState ConversationTaskStateProvider
}
// SetAudit wires platform audit logging.
@@ -36,6 +46,12 @@ func (h *ConversationHandler) SetTaskStopper(stopper ConversationTaskStopper) {
h.taskStopper = stopper
}
// SetTaskStateProvider wires the live agent task registry used by supplemental
// conversation UI such as the agent-maintained plan list.
func (h *ConversationHandler) SetTaskStateProvider(provider ConversationTaskStateProvider) {
h.taskState = provider
}
// NewConversationHandler 创建新的对话处理器
func NewConversationHandler(db *database.DB, logger *zap.Logger) *ConversationHandler {
return &ConversationHandler{
@@ -206,6 +222,70 @@ func (h *ConversationHandler) GetConversation(c *gin.Context) {
c.JSON(http.StatusOK, conv)
}
// GetConversationPlanTasks returns the task list maintained by the agent's
// TaskCreate/TaskUpdate tools for this conversation.
func (h *ConversationHandler) GetConversationPlanTasks(c *gin.Context) {
id := strings.TrimSpace(c.Param("id"))
session, ok := security.CurrentSession(c)
if !ok || !h.db.UserCanAccessResource(session.UserID, session.Scope, "conversation", id) {
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该对话"})
return
}
if _, err := h.db.GetConversationLite(id); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "对话不存在"})
return
}
running := false
startedAt := time.Time{}
if h.taskState != nil {
running, startedAt = h.taskState.ConversationTaskRuntimeState(id)
}
if !running {
c.JSON(http.StatusOK, gin.H{
"tasks": []database.ConversationPlanTask{}, "total": 0,
"completed": 0, "activeStep": 0, "running": false,
})
return
}
tasks, err := h.db.ListConversationPlanTasksSince(id, startedAt)
if err != nil {
h.logger.Error("获取对话任务列表失败", zap.String("conversationId", id), zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取任务列表失败"})
return
}
completed := 0
activeStep := 0
for i, task := range tasks {
status := strings.ToLower(strings.TrimSpace(task.Status))
if status == "completed" {
completed++
}
if activeStep == 0 && status == "in_progress" {
activeStep = i + 1
}
}
if activeStep == 0 {
for i, task := range tasks {
if strings.ToLower(strings.TrimSpace(task.Status)) != "completed" {
activeStep = i + 1
break
}
}
}
if activeStep == 0 && len(tasks) > 0 {
activeStep = len(tasks)
}
c.JSON(http.StatusOK, gin.H{
"tasks": tasks,
"total": len(tasks),
"completed": completed,
"activeStep": activeStep,
"running": true,
})
}
const (
defaultProcessDetailsPageLimit = 50
maxProcessDetailsPageLimit = 500
@@ -246,7 +326,7 @@ func (h *ConversationHandler) GetMessageProcessDetails(c *gin.Context) {
}
details = database.DedupeConsecutiveProcessDetails(details)
out := processDetailsToJSON(h.logger, details, true)
out := processDetailsToJSON(h.logger, h.db, details, true)
c.JSON(http.StatusOK, gin.H{
"processDetails": out,
"total": len(out),
@@ -295,7 +375,7 @@ func (h *ConversationHandler) GetMessageProcessDetails(c *gin.Context) {
return
}
details = database.DedupeConsecutiveProcessDetails(details)
out := processDetailsToJSON(h.logger, details, false)
out := processDetailsToJSON(h.logger, h.db, details, false)
// A page may end between tool_call and tool_result. Return the full-history
// execution summary so the UI can render terminal status without pretending
// that an unloaded result is still running.
@@ -330,7 +410,7 @@ func (h *ConversationHandler) GetProcessDetail(c *gin.Context) {
c.JSON(http.StatusNotFound, gin.H{"error": "过程详情不存在"})
return
}
out := processDetailsToJSON(h.logger, []database.ProcessDetail{*detail}, true)
out := processDetailsToJSON(h.logger, h.db, []database.ProcessDetail{*detail}, true)
if len(out) == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "过程详情不存在"})
return
@@ -338,7 +418,7 @@ func (h *ConversationHandler) GetProcessDetail(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"processDetail": out[0]})
}
func processDetailsToJSON(logger *zap.Logger, details []database.ProcessDetail, includeToolPayload bool) []map[string]interface{} {
func processDetailsToJSON(logger *zap.Logger, db *database.DB, details []database.ProcessDetail, includeToolPayload bool) []map[string]interface{} {
out := make([]map[string]interface{}, 0, len(details))
for _, d := range details {
var data interface{}
@@ -347,6 +427,9 @@ func processDetailsToJSON(logger *zap.Logger, details []database.ProcessDetail,
logger.Warn("解析过程详情数据失败", zap.Error(err))
}
}
if m, ok := data.(map[string]interface{}); ok {
enrichEmptyToolCallArgumentsFromExecution(logger, db, d, m)
}
if !includeToolPayload {
data = summarizeProcessDetailData(d.EventType, data)
}
@@ -363,6 +446,50 @@ func processDetailsToJSON(logger *zap.Logger, details []database.ProcessDetail,
return out
}
func enrichEmptyToolCallArgumentsFromExecution(logger *zap.Logger, db *database.DB, detail database.ProcessDetail, data map[string]interface{}) {
if db == nil || detail.EventType != "tool_call" || !toolCallArgumentsEmpty(data) {
return
}
toolName := strings.TrimSpace(fmt.Sprint(data["toolName"]))
if toolName == "" || detail.ConversationID == "" || detail.CreatedAt.IsZero() {
return
}
execID, args, err := db.FindNearestToolExecutionArguments(detail.ConversationID, toolName, detail.CreatedAt, 5*time.Second)
if err != nil {
if logger != nil {
logger.Debug("未能从工具执行记录补全过程详情参数",
zap.Error(err),
zap.String("processDetailId", detail.ID),
zap.String("toolName", toolName))
}
return
}
if len(args) == 0 {
return
}
data["argumentsObj"] = args
if b, err := json.Marshal(args); err == nil {
data["arguments"] = string(b)
}
if strings.TrimSpace(execID) != "" {
data["executionId"] = strings.TrimSpace(execID)
}
}
func toolCallArgumentsEmpty(data map[string]interface{}) bool {
if data == nil {
return true
}
if args, ok := data["argumentsObj"].(map[string]interface{}); ok && len(args) > 0 {
return false
}
if raw, ok := data["arguments"]; ok {
s := strings.TrimSpace(fmt.Sprint(raw))
return s == "" || s == "{}" || s == "null"
}
return true
}
func summarizeProcessDetailData(eventType string, data interface{}) interface{} {
m, ok := data.(map[string]interface{})
if !ok || (eventType != "tool_call" && eventType != "tool_result") {
@@ -370,6 +497,7 @@ func summarizeProcessDetailData(eventType string, data interface{}) interface{}
}
allow := map[string]bool{
"toolName": true, "toolCallId": true, "index": true, "total": true,
"arguments": true, "argumentsObj": true,
"success": true, "isError": true, "executionId": true,
"einoAgent": true, "einoRole": true, "einoScope": true, "orchestration": true,
"agentFacing": true,
@@ -0,0 +1,151 @@
package handler
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"
"cyberstrike-ai/internal/database"
"cyberstrike-ai/internal/security"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
type staticConversationTaskState struct {
running bool
startedAt time.Time
}
func (s staticConversationTaskState) ConversationTaskRuntimeState(string) (bool, time.Time) {
return s.running, s.startedAt
}
func TestGetConversationPlanTasksRequiresAccessAndReportsProgress(t *testing.T) {
gin.SetMode(gin.TestMode)
tmp := t.TempDir()
db, err := database.NewDB(filepath.Join(tmp, "conversation-plantask.db"), zap.NewNop())
if err != nil {
t.Fatalf("NewDB: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
conversation, err := db.CreateConversation("plan", database.ConversationCreateMeta{})
if err != nil {
t.Fatalf("CreateConversation: %v", err)
}
user, err := db.CreateRBACUser("plan-user", "Plan User", "hash", true, nil)
if err != nil {
t.Fatalf("CreateRBACUser: %v", err)
}
base := filepath.Join(tmp, "plantask")
db.SetEinoConversationDirs(base, "", "", "")
dir := filepath.Join(base, conversation.ID)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
for name, content := range map[string]string{
"1.json": `{"id":"1","subject":"完成项","status":"completed"}`,
"2.json": `{"id":"2","subject":"当前项","status":"in_progress"}`,
"3.json": `{"id":"3","subject":"等待项","status":"pending"}`,
} {
if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
}
handler := NewConversationHandler(db, zap.NewNop())
handler.SetTaskStateProvider(staticConversationTaskState{running: true})
request := func() *httptest.ResponseRecorder {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodGet, "/api/conversations/"+conversation.ID+"/plan-tasks", nil)
c.Params = gin.Params{{Key: "id", Value: conversation.ID}}
c.Set(security.ContextSessionKey, security.Session{
UserID: user.ID,
Scope: database.RBACScopeAssigned,
})
handler.GetConversationPlanTasks(c)
return w
}
w := request()
if w.Code != http.StatusForbidden {
t.Fatalf("unassigned status = %d, want %d", w.Code, http.StatusForbidden)
}
if err := db.AssignResourceToUser(user.ID, "conversation", conversation.ID); err != nil {
t.Fatalf("AssignResourceToUser: %v", err)
}
w = request()
if w.Code != http.StatusOK {
t.Fatalf("assigned status = %d: %s", w.Code, w.Body.String())
}
var response struct {
Total int `json:"total"`
Completed int `json:"completed"`
ActiveStep int `json:"activeStep"`
Tasks []database.ConversationPlanTask `json:"tasks"`
Running bool `json:"running"`
}
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
t.Fatalf("decode response: %v", err)
}
if response.Total != 3 || response.Completed != 1 || response.ActiveStep != 2 || !response.Running {
t.Fatalf("progress = %#v", response)
}
}
func TestGetConversationPlanTasksReportsStoppedLiveTask(t *testing.T) {
gin.SetMode(gin.TestMode)
tmp := t.TempDir()
db, err := database.NewDB(filepath.Join(tmp, "conversation-plantask-stopped.db"), zap.NewNop())
if err != nil {
t.Fatalf("NewDB: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
conversation, err := db.CreateConversation("stopped plan", database.ConversationCreateMeta{})
if err != nil {
t.Fatalf("CreateConversation: %v", err)
}
user, err := db.CreateRBACUser("stopped-plan-user", "Stopped Plan User", "hash", true, nil)
if err != nil {
t.Fatalf("CreateRBACUser: %v", err)
}
if err := db.AssignResourceToUser(user.ID, "conversation", conversation.ID); err != nil {
t.Fatalf("AssignResourceToUser: %v", err)
}
base := filepath.Join(tmp, "plantask")
db.SetEinoConversationDirs(base, "", "", "")
dir := filepath.Join(base, conversation.ID)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
if err := os.WriteFile(filepath.Join(dir, "1.json"), []byte(`{"id":"1","subject":"残留项","status":"in_progress"}`), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
handler := NewConversationHandler(db, zap.NewNop())
handler.SetTaskStateProvider(staticConversationTaskState{running: false})
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodGet, "/api/conversations/"+conversation.ID+"/plan-tasks", nil)
c.Params = gin.Params{{Key: "id", Value: conversation.ID}}
c.Set(security.ContextSessionKey, security.Session{UserID: user.ID, Scope: database.RBACScopeAssigned})
handler.GetConversationPlanTasks(c)
if w.Code != http.StatusOK {
t.Fatalf("status = %d: %s", w.Code, w.Body.String())
}
var response struct {
Running bool `json:"running"`
Total int `json:"total"`
}
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
t.Fatalf("decode response: %v", err)
}
if response.Running || response.Total != 0 {
t.Fatalf("response = %#v", response)
}
}
@@ -6,8 +6,10 @@ import (
"net/http/httptest"
"path/filepath"
"testing"
"time"
"cyberstrike-ai/internal/database"
"cyberstrike-ai/internal/mcp"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
@@ -73,3 +75,131 @@ func TestProcessDetailsPageIncludesTerminalToolStatusAcrossPageBoundary(t *testi
}
}
}
func TestProcessDetailsFullBackfillsEmptyToolCallArgumentsFromExecution(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := database.NewDB(filepath.Join(t.TempDir(), "process-details-args.db"), zap.NewNop())
if err != nil {
t.Fatalf("NewDB: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
conversation, err := db.CreateConversation("empty args", database.ConversationCreateMeta{})
if err != nil {
t.Fatalf("CreateConversation: %v", err)
}
message, err := db.AddMessage(conversation.ID, "assistant", "done", nil)
if err != nil {
t.Fatalf("AddMessage: %v", err)
}
if err := db.AddProcessDetail(message.ID, conversation.ID, "tool_call", "calling exec", map[string]interface{}{
"toolName": "exec", "toolCallId": "call-empty", "arguments": "", "argumentsObj": nil,
}); err != nil {
t.Fatalf("AddProcessDetail(tool_call): %v", err)
}
if err := db.SaveToolExecution(&mcp.ToolExecution{
ID: "exec-whoami",
ToolName: "exec",
Arguments: map[string]interface{}{"command": "whoami"},
Status: "completed",
StartTime: time.Now(),
ConversationID: conversation.ID,
}); err != nil {
t.Fatalf("SaveToolExecution: %v", err)
}
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/api/messages/"+message.ID+"/process-details?full=1", nil)
c.Params = gin.Params{{Key: "id", Value: message.ID}}
NewConversationHandler(db, zap.NewNop()).GetMessageProcessDetails(c)
if w.Code != 200 {
t.Fatalf("status = %d: %s", w.Code, w.Body.String())
}
var response struct {
ProcessDetails []map[string]interface{} `json:"processDetails"`
}
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
t.Fatalf("decode response: %v", err)
}
if len(response.ProcessDetails) != 1 {
t.Fatalf("process details = %d, want 1", len(response.ProcessDetails))
}
data, ok := response.ProcessDetails[0]["data"].(map[string]interface{})
if !ok {
t.Fatalf("data = %#v", response.ProcessDetails[0]["data"])
}
args, ok := data["argumentsObj"].(map[string]interface{})
if !ok {
t.Fatalf("argumentsObj = %#v", data["argumentsObj"])
}
if args["command"] != "whoami" {
t.Fatalf("command = %#v, want whoami", args["command"])
}
if data["executionId"] != "exec-whoami" {
t.Fatalf("executionId = %#v, want exec-whoami", data["executionId"])
}
}
func TestProcessDetailsPageBackfillsEinoFilesystemArgumentsFromPrefixedExecution(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := database.NewDB(filepath.Join(t.TempDir(), "process-details-eino-fs-args.db"), zap.NewNop())
if err != nil {
t.Fatalf("NewDB: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
conversation, err := db.CreateConversation("eino fs args", database.ConversationCreateMeta{})
if err != nil {
t.Fatalf("CreateConversation: %v", err)
}
message, err := db.AddMessage(conversation.ID, "assistant", "done", nil)
if err != nil {
t.Fatalf("AddMessage: %v", err)
}
if err := db.AddProcessDetail(message.ID, conversation.ID, "tool_call", "calling read_file", map[string]interface{}{
"toolName": "read_file", "toolCallId": "call-read", "arguments": "", "argumentsObj": nil,
}); err != nil {
t.Fatalf("AddProcessDetail(tool_call): %v", err)
}
if err := db.SaveToolExecution(&mcp.ToolExecution{
ID: "exec-read",
ToolName: "eino_fs::read_file",
Arguments: map[string]interface{}{"file_path": "/tmp/requirements.txt", "limit": float64(2000)},
Status: "completed",
StartTime: time.Now(),
ConversationID: conversation.ID,
}); err != nil {
t.Fatalf("SaveToolExecution: %v", err)
}
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/api/messages/"+message.ID+"/process-details?limit=50&offset=0", nil)
c.Params = gin.Params{{Key: "id", Value: message.ID}}
NewConversationHandler(db, zap.NewNop()).GetMessageProcessDetails(c)
if w.Code != 200 {
t.Fatalf("status = %d: %s", w.Code, w.Body.String())
}
var response struct {
ProcessDetails []map[string]interface{} `json:"processDetails"`
}
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
t.Fatalf("decode response: %v", err)
}
if len(response.ProcessDetails) != 1 {
t.Fatalf("process details = %d, want 1", len(response.ProcessDetails))
}
data, ok := response.ProcessDetails[0]["data"].(map[string]interface{})
if !ok {
t.Fatalf("data = %#v", response.ProcessDetails[0]["data"])
}
args, ok := data["argumentsObj"].(map[string]interface{})
if !ok {
t.Fatalf("argumentsObj = %#v", data["argumentsObj"])
}
if args["file_path"] != "/tmp/requirements.txt" {
t.Fatalf("file_path = %#v, want /tmp/requirements.txt", args["file_path"])
}
if data["arguments"] == nil {
t.Fatalf("arguments should be preserved in summarized page data: %#v", data)
}
}
+14
View File
@@ -227,6 +227,12 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
taskCtxLoop := mcp.WithMCPConversationID(taskCtx, conversationID)
taskCtxLoop = mcp.WithToolRunRegistry(taskCtxLoop, h.tasks)
taskCtxLoop = mcp.WithEinoExecuteRunRegistry(taskCtxLoop, h.tasks)
taskCtxLoop = multiagent.WithAgentRuntimeCancelRegistrar(taskCtxLoop, func(cancel func(error) bool) func() {
return h.tasks.BindAgentRuntimeCancel(conversationID, cancel)
})
taskCtxLoop = multiagent.WithAgentTurnLoopInterruptRegistrar(taskCtxLoop, func(push func(string) bool) func() {
return h.tasks.BindAgentTurnLoopInterrupt(conversationID, push)
})
taskCtxLoop = multiagent.WithHITLToolInterceptor(taskCtxLoop, func(ctx context.Context, toolName, arguments string) (string, error) {
return h.interceptHITLForEinoTool(ctx, cancelWithCause, conversationID, assistantMessageID, sendEvent, toolName, arguments)
})
@@ -273,6 +279,14 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
}
cause := context.Cause(baseCtx)
if cause == nil {
switch {
case errors.Is(runErr, multiagent.ErrInterruptContinue):
cause = multiagent.ErrInterruptContinue
case errors.Is(runErr, ErrTaskCancelled):
cause = ErrTaskCancelled
}
}
if errors.Is(cause, multiagent.ErrInterruptContinue) {
if shouldPersistEinoAgentTraceAfterRunError(baseCtx) {
h.persistEinoAgentTraceForResume(conversationID, result)
+248 -25
View File
@@ -74,6 +74,7 @@ CREATE TABLE IF NOT EXISTS hitl_interrupts (
tool_call_id TEXT,
payload TEXT,
status TEXT NOT NULL,
reviewer TEXT NOT NULL DEFAULT 'human',
decision TEXT,
decision_comment TEXT,
created_at DATETIME NOT NULL,
@@ -98,15 +99,179 @@ CREATE TABLE IF NOT EXISTS hitl_conversation_configs (
// On startup, cancel all orphaned pending interrupts from previous process.
// Their in-memory channels are gone, so they can never be resolved.
res, err := m.db.Exec(`UPDATE hitl_interrupts SET status='cancelled', decision='reject',
decision_comment='process restarted', decided_at=CURRENT_TIMESTAMP WHERE status='pending'`)
decision_comment='process restarted', decided_at=CURRENT_TIMESTAMP, decided_by='system'
WHERE status='pending'`)
if err != nil {
m.logger.Warn("failed to cancel orphaned HITL interrupts", zap.Error(err))
} else if n, _ := res.RowsAffected(); n > 0 {
m.logger.Info("cancelled orphaned HITL interrupts from previous process", zap.Int64("count", n))
}
if err := m.reconcileRestartInterruptedMessages(); err != nil {
m.logger.Warn("failed to finalize assistant messages interrupted by process restart", zap.Error(err))
}
return nil
}
// reconcileRestartInterruptedMessages completes durable terminal state for
// historical assistant placeholders that have explicit evidence of being over:
// a terminal HITL/process event, or a later message in the same conversation.
// The evidence requirement avoids rewriting a placeholder that could still be
// recoverable by another runtime.
func (m *HITLManager) reconcileRestartInterruptedMessages() error {
rows, err := m.db.Query(`
SELECT msg.id, msg.conversation_id,
COALESCE((
SELECT pd.event_type
FROM process_details pd
WHERE pd.message_id = msg.id
AND pd.event_type IN ('cancelled', 'timeout', 'error')
ORDER BY pd.created_at DESC LIMIT 1
), '') AS terminal_event,
COALESCE((
SELECT hi.status
FROM hitl_interrupts hi
WHERE hi.message_id = msg.id
ORDER BY COALESCE(hi.decided_at, hi.created_at) DESC LIMIT 1
), '') AS hitl_status,
COALESCE((
SELECT hi.decision
FROM hitl_interrupts hi
WHERE hi.message_id = msg.id
ORDER BY COALESCE(hi.decided_at, hi.created_at) DESC LIMIT 1
), '') AS hitl_decision,
COALESCE((
SELECT hi.decision_comment
FROM hitl_interrupts hi
WHERE hi.message_id = msg.id
ORDER BY COALESCE(hi.decided_at, hi.created_at) DESC LIMIT 1
), '') AS decision_comment,
COALESCE((
SELECT MAX(COALESCE(hi.decided_at, hi.created_at))
FROM hitl_interrupts hi
WHERE hi.message_id = msg.id
), (
SELECT MIN(later.created_at)
FROM messages later
WHERE later.conversation_id = msg.conversation_id
AND later.created_at > msg.created_at
), (
SELECT MAX(pd.created_at)
FROM process_details pd
WHERE pd.message_id = msg.id
), msg.updated_at, msg.created_at) AS interrupted_at
FROM messages msg
WHERE msg.role = 'assistant'
AND TRIM(msg.content) IN ('处理中...', 'Processing...')
AND (
EXISTS (
SELECT 1 FROM hitl_interrupts hi
WHERE hi.message_id = msg.id
AND (hi.status IN ('cancelled', 'timeout')
OR (hi.status = 'decided' AND hi.decision = 'reject'))
)
OR EXISTS (
SELECT 1 FROM process_details pd
WHERE pd.message_id = msg.id
AND pd.event_type IN ('cancelled', 'timeout', 'error')
)
OR EXISTS (
SELECT 1 FROM messages later
WHERE later.conversation_id = msg.conversation_id
AND later.created_at > msg.created_at
)
)`)
if err != nil {
return err
}
type interruptedMessage struct {
messageID string
conversationID string
terminalEvent string
hitlStatus string
hitlDecision string
decisionComment string
interruptedAt string
}
var interrupted []interruptedMessage
for rows.Next() {
var item interruptedMessage
if err := rows.Scan(&item.messageID, &item.conversationID, &item.terminalEvent,
&item.hitlStatus, &item.hitlDecision, &item.decisionComment, &item.interruptedAt); err != nil {
rows.Close()
return err
}
interrupted = append(interrupted, item)
}
if err := rows.Close(); err != nil {
return err
}
if len(interrupted) == 0 {
return nil
}
tx, err := m.db.Begin()
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
for _, item := range interrupted {
eventType := strings.ToLower(strings.TrimSpace(item.terminalEvent))
decision := strings.ToLower(strings.TrimSpace(item.hitlDecision))
comment := strings.ToLower(strings.TrimSpace(item.decisionComment))
if eventType == "" {
if strings.EqualFold(strings.TrimSpace(item.hitlStatus), "timeout") || strings.Contains(comment, "timeout") {
eventType = "timeout"
} else {
eventType = "cancelled"
}
}
notice := "任务因服务重启已中断。"
reason := "process_restarted"
switch eventType {
case "timeout":
notice = "任务等待审批超时,已自动拒绝。"
reason = "hitl_timeout"
case "error":
notice = "任务执行失败,已停止。"
reason = "execution_error"
case "cancelled":
if decision == "reject" && comment != "process restarted" {
notice = "任务审批已拒绝,执行已停止。"
reason = "hitl_rejected"
} else if comment == "process restarted" {
notice = "任务因服务重启已中断,审批已取消。"
}
default:
eventType = "cancelled"
}
detailData, _ := json.Marshal(map[string]string{"reason": reason, "status": eventType})
result, err := tx.Exec(`
UPDATE messages
SET content = ?, updated_at = ?
WHERE id = ? AND TRIM(content) IN ('处理中...', 'Processing...')`,
notice, item.interruptedAt, item.messageID)
if err != nil {
return err
}
updated, _ := result.RowsAffected()
if updated == 0 {
continue
}
if _, err := tx.Exec(`
INSERT INTO process_details (id, message_id, conversation_id, event_type, message, data, created_at)
SELECT ?, ?, ?, ?, ?, ?, ?
WHERE NOT EXISTS (
SELECT 1 FROM process_details
WHERE message_id = ? AND event_type IN ('cancelled', 'timeout', 'error')
)`, uuid.NewString(), item.messageID, item.conversationID, eventType, notice, string(detailData),
item.interruptedAt, item.messageID); err != nil {
return err
}
}
return tx.Commit()
}
func normalizeHitlMode(mode string) string {
v := strings.ToLower(strings.TrimSpace(mode))
if v == "" {
@@ -234,13 +399,14 @@ func (m *HITLManager) NeedsToolApproval(conversationID, toolName string) bool {
return need
}
func (m *HITLManager) CreatePendingInterrupt(conversationID, assistantMessageID, mode, toolName, toolCallID, payload string) (*pendingInterrupt, error) {
func (m *HITLManager) CreatePendingInterrupt(conversationID, assistantMessageID, mode, toolName, toolCallID, payload, reviewer string) (*pendingInterrupt, error) {
now := time.Now()
id := "hitl_" + strings.ReplaceAll(uuid.New().String(), "-", "")
reviewer = normalizeHitlReviewer(reviewer)
if _, err := m.db.Exec(`INSERT INTO hitl_interrupts
(id, conversation_id, message_id, mode, tool_name, tool_call_id, payload, status, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, 'pending', ?)`,
id, conversationID, assistantMessageID, mode, toolName, toolCallID, payload, now); err != nil {
(id, conversation_id, message_id, mode, tool_name, tool_call_id, payload, status, reviewer, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?)`,
id, conversationID, assistantMessageID, mode, toolName, toolCallID, payload, reviewer, now); err != nil {
return nil, err
}
// 刷新页面后侧栏依赖 DB 配置;若仅内存 Activate 未落库,会导致「有待审批却显示关闭」
@@ -253,9 +419,12 @@ func (m *HITLManager) CreatePendingInterrupt(conversationID, assistantMessageID,
ToolCallID: toolCallID,
decideCh: make(chan hitlDecision, 1),
}
// Agent 审查不会等待人工决策,也不应进入人工审批的内存待办队列。
if reviewer != "audit_agent" {
m.mu.Lock()
m.pending[id] = p
m.mu.Unlock()
}
return p, nil
}
@@ -471,66 +640,107 @@ func (h *AgentHandler) waitHITLApproval(runCtx context.Context, cancelRun contex
return nil, nil
}
h.enrichHitlApprovalPayload(conversationID, assistantMessageID, payload)
approvalStartedAt := time.Now().UTC()
timeoutSeconds := int(cfg.Timeout / time.Second)
var approvalExpiresAt *time.Time
if timeoutSeconds > 0 {
expiresAt := approvalStartedAt.Add(cfg.Timeout)
approvalExpiresAt = &expiresAt
}
payload["hitlApproval"] = map[string]interface{}{
"createdAt": approvalStartedAt,
"timeoutSeconds": timeoutSeconds,
"expiresAt": approvalExpiresAt,
}
payloadRaw, _ := json.Marshal(payload)
p, err := h.hitlManager.CreatePendingInterrupt(conversationID, assistantMessageID, cfg.Mode, toolName, toolCallID, string(payloadRaw))
p, err := h.hitlManager.CreatePendingInterrupt(conversationID, assistantMessageID, cfg.Mode, toolName, toolCallID, string(payloadRaw), cfg.Reviewer)
if err != nil {
h.logger.Warn("创建 HITL 中断失败", zap.Error(err))
return nil, err
}
emitHITL := func(eventType, message string, eventData map[string]interface{}) {
clientData := enrichProgressEventData(eventData, conversationID, assistantMessageID)
if sendEventFunc != nil {
sendEventFunc(eventType, message, clientData)
}
if strings.TrimSpace(assistantMessageID) != "" && h.db != nil {
if err := h.db.AddProcessDetail(assistantMessageID, conversationID, eventType, message, clientData); err != nil {
h.logger.Warn("保存 HITL 过程详情失败", zap.Error(err), zap.String("eventType", eventType))
}
}
}
if cfg.Reviewer == "audit_agent" {
emitHITL("hitl_audit_agent_started", "审计 Agent 正在审查此请求", map[string]interface{}{
"conversationId": conversationID,
"interruptId": p.InterruptID,
"toolName": toolName,
"toolCallId": toolCallID,
"mode": cfg.Mode,
"reviewer": "audit_agent",
"status": "audit_running",
"payload": payload,
})
ad := h.auditAgentReview(runCtx, cfg.Mode, toolName, payload)
now := time.Now()
_, _ = h.db.Exec(`UPDATE hitl_interrupts SET status='decided', decision=?, decision_comment=?, decided_at=?, decided_by='audit_agent' WHERE id=?`,
ad.Decision, ad.Comment, now, p.InterruptID)
if sendEventFunc != nil {
sendEventFunc("hitl_audit_agent", "审计 Agent 已裁决", map[string]interface{}{
emitHITL("hitl_audit_agent", "审计 Agent 已裁决", map[string]interface{}{
"conversationId": conversationID,
"interruptId": p.InterruptID,
"toolName": toolName,
"toolCallId": toolCallID,
"mode": cfg.Mode,
"status": "decided",
"decision": ad.Decision,
"comment": ad.Comment,
"editedArgs": ad.EditedArguments,
"decidedBy": "audit_agent",
"reviewer": "audit_agent",
})
}
if ad.Decision == "reject" {
if sendEventFunc != nil {
sendEventFunc("hitl_rejected", "审计 Agent 拒绝本次工具调用", map[string]interface{}{
emitHITL("hitl_rejected", "审计 Agent 拒绝本次工具调用", map[string]interface{}{
"conversationId": conversationID,
"interruptId": p.InterruptID,
"toolName": toolName,
"toolCallId": toolCallID,
"mode": cfg.Mode,
"decision": "reject",
"comment": ad.Comment,
"decidedBy": "audit_agent",
"reviewer": "audit_agent",
})
}
return &ad, nil
}
if sendEventFunc != nil {
sendEventFunc("hitl_resumed", "审计 Agent 已通过,继续执行", map[string]interface{}{
emitHITL("hitl_resumed", "审计 Agent 已通过,继续执行", map[string]interface{}{
"conversationId": conversationID,
"interruptId": p.InterruptID,
"toolName": toolName,
"toolCallId": toolCallID,
"mode": cfg.Mode,
"decision": "approve",
"comment": ad.Comment,
"editedArgs": ad.EditedArguments,
"decidedBy": "audit_agent",
"reviewer": "audit_agent",
})
}
h.hitlManager.TrackApprovedHitlExecution(p.InterruptID, conversationID, toolName, toolCallID)
return &ad, nil
}
if sendEventFunc != nil {
sendEventFunc("hitl_interrupt", "命中人机协同审批", map[string]interface{}{
emitHITL("hitl_interrupt", "命中人机协同审批", map[string]interface{}{
"conversationId": conversationID,
"interruptId": p.InterruptID,
"mode": cfg.Mode,
"toolName": toolName,
"toolCallId": toolCallID,
"reviewer": "human",
"status": "pending",
"createdAt": approvalStartedAt,
"timeoutSeconds": timeoutSeconds,
"expiresAt": approvalExpiresAt,
"payload": payload,
})
}
d, waitErr := h.hitlManager.waitDecision(runCtx, p, cfg.Timeout)
if waitErr != nil {
if cancelRun != nil && (errors.Is(waitErr, context.Canceled) || errors.Is(waitErr, context.DeadlineExceeded)) {
@@ -550,28 +760,41 @@ func (h *AgentHandler) waitHITLApproval(runCtx context.Context, cancelRun contex
}
if d.Decision == "reject" {
rejectMsg := "人工拒绝本次工具调用,模型将基于反馈继续迭代"
if strings.Contains(strings.ToLower(strings.TrimSpace(d.Comment)), "timeout") {
timedOut := strings.Contains(strings.ToLower(strings.TrimSpace(d.Comment)), "timeout")
if timedOut {
rejectMsg = "审批超时,安全起见已自动拒绝,模型将基于反馈继续迭代"
}
if sendEventFunc != nil {
sendEventFunc("hitl_rejected", rejectMsg, map[string]interface{}{
status := "decided"
decidedBy := "human"
if timedOut {
status = "timeout"
decidedBy = "system"
}
emitHITL("hitl_rejected", rejectMsg, map[string]interface{}{
"conversationId": conversationID,
"interruptId": p.InterruptID,
"toolName": toolName,
"toolCallId": toolCallID,
"mode": cfg.Mode,
"status": status,
"decision": "reject",
"comment": d.Comment,
"decidedBy": decidedBy,
"reviewer": "human",
})
}
return &d, nil
}
if sendEventFunc != nil {
sendEventFunc("hitl_resumed", "人工确认通过,继续执行", map[string]interface{}{
emitHITL("hitl_resumed", "人工确认通过,继续执行", map[string]interface{}{
"conversationId": conversationID,
"interruptId": p.InterruptID,
"toolName": toolName,
"toolCallId": toolCallID,
"mode": cfg.Mode,
"decision": "approve",
"comment": d.Comment,
"editedArgs": d.EditedArguments,
"reviewer": "human",
})
}
h.hitlManager.TrackApprovedHitlExecution(p.InterruptID, conversationID, toolName, toolCallID)
return &d, nil
}
+16 -10
View File
@@ -39,11 +39,14 @@ func normalizeHitlDecidedBy(v string) string {
func (m *HITLManager) migrateHitlSchemaColumns() {
_, _ = m.db.Exec(`ALTER TABLE hitl_interrupts ADD COLUMN decided_by TEXT NOT NULL DEFAULT 'human'`)
_, _ = m.db.Exec(`ALTER TABLE hitl_interrupts ADD COLUMN reviewer TEXT NOT NULL DEFAULT 'human'`)
_, _ = m.db.Exec(`UPDATE hitl_interrupts SET reviewer='audit_agent'
WHERE COALESCE(decided_by, '') IN ('audit_agent', 'agent', 'ai')`)
_, _ = m.db.Exec(`ALTER TABLE hitl_conversation_configs ADD COLUMN reviewer TEXT NOT NULL DEFAULT 'human'`)
}
func hitlInterruptRowToMap(
id, cid, mode, toolName, toolCallID, payload, rowStatus, decidedBy string,
id, cid, mode, toolName, toolCallID, payload, rowStatus, reviewer, decidedBy string,
messageID sql.NullString,
decision, comment sql.NullString,
createdAt time.Time,
@@ -62,6 +65,7 @@ func hitlInterruptRowToMap(
"toolCallId": toolCallID,
"payload": payload,
"status": rowStatus,
"reviewer": reviewer,
"decision": decision.String,
"comment": comment.String,
"decidedBy": decidedBy,
@@ -77,7 +81,7 @@ func hitlInterruptRowToMap(
func (h *AgentHandler) buildHitlListQuery(logs bool) (string, []interface{}) {
where, args := h.buildHitlLogsWhere(logs)
q := `SELECT id, conversation_id, message_id, mode, tool_name, tool_call_id, payload, status, decision, decision_comment, COALESCE(decided_by,'human'), created_at, decided_at FROM hitl_interrupts` + where
q := `SELECT id, conversation_id, message_id, mode, tool_name, tool_call_id, payload, status, COALESCE(reviewer,'human'), decision, decision_comment, COALESCE(decided_by,'human'), created_at, decided_at FROM hitl_interrupts` + where
return q, args
}
@@ -87,7 +91,9 @@ func (h *AgentHandler) buildHitlLogsWhere(logs bool) (string, []interface{}) {
if logs {
q += " AND status != 'pending'"
} else {
q += " AND status = 'pending'"
// 该接口只返回真正等待用户操作的人工审批。Agent 审查即使正在运行,
// 也不应触发弹窗、倒计时或项目待审批计数。
q += " AND status = 'pending' AND COALESCE(reviewer,'human') = 'human'"
}
return q, args
}
@@ -131,15 +137,15 @@ func (h *AgentHandler) appendHitlListFilters(q string, args []interface{}, c *gi
func (h *AgentHandler) scanHitlInterruptRows(rows *sql.Rows) ([]map[string]interface{}, error) {
items := make([]map[string]interface{}, 0)
for rows.Next() {
var id, cid, mode, toolName, toolCallID, payload, rowStatus, decidedBy string
var id, cid, mode, toolName, toolCallID, payload, rowStatus, reviewer, decidedBy string
var messageID sql.NullString
var decision, comment sql.NullString
var createdAt time.Time
var decidedAt sql.NullTime
if err := rows.Scan(&id, &cid, &messageID, &mode, &toolName, &toolCallID, &payload, &rowStatus, &decision, &comment, &decidedBy, &createdAt, &decidedAt); err != nil {
if err := rows.Scan(&id, &cid, &messageID, &mode, &toolName, &toolCallID, &payload, &rowStatus, &reviewer, &decision, &comment, &decidedBy, &createdAt, &decidedAt); err != nil {
continue
}
items = append(items, hitlInterruptRowToMap(id, cid, mode, toolName, toolCallID, payload, rowStatus, decidedBy, messageID, decision, comment, createdAt, decidedAt))
items = append(items, hitlInterruptRowToMap(id, cid, mode, toolName, toolCallID, payload, rowStatus, reviewer, decidedBy, messageID, decision, comment, createdAt, decidedAt))
}
return items, nil
}
@@ -252,13 +258,13 @@ func (h *AgentHandler) GetHITLLog(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "id is required"})
return
}
q := `SELECT id, conversation_id, message_id, mode, tool_name, tool_call_id, payload, status, decision, decision_comment, COALESCE(decided_by,'human'), created_at, decided_at FROM hitl_interrupts WHERE id = ?`
var rowID, cid, mode, toolName, toolCallID, payload, rowStatus, decidedBy string
q := `SELECT id, conversation_id, message_id, mode, tool_name, tool_call_id, payload, status, COALESCE(reviewer,'human'), decision, decision_comment, COALESCE(decided_by,'human'), created_at, decided_at FROM hitl_interrupts WHERE id = ?`
var rowID, cid, mode, toolName, toolCallID, payload, rowStatus, reviewer, decidedBy string
var messageID sql.NullString
var decision, comment sql.NullString
var createdAt time.Time
var decidedAt sql.NullTime
err := h.db.QueryRow(q, id).Scan(&rowID, &cid, &messageID, &mode, &toolName, &toolCallID, &payload, &rowStatus, &decision, &comment, &decidedBy, &createdAt, &decidedAt)
err := h.db.QueryRow(q, id).Scan(&rowID, &cid, &messageID, &mode, &toolName, &toolCallID, &payload, &rowStatus, &reviewer, &decision, &comment, &decidedBy, &createdAt, &decidedAt)
if errors.Is(err, sql.ErrNoRows) {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
@@ -271,7 +277,7 @@ func (h *AgentHandler) GetHITLLog(c *gin.Context) {
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"})
return
}
c.JSON(http.StatusOK, hitlInterruptRowToMap(rowID, cid, mode, toolName, toolCallID, payload, rowStatus, decidedBy, messageID, decision, comment, createdAt, decidedAt))
c.JSON(http.StatusOK, hitlInterruptRowToMap(rowID, cid, mode, toolName, toolCallID, payload, rowStatus, reviewer, decidedBy, messageID, decision, comment, createdAt, decidedAt))
}
func (h *AgentHandler) filterAllowedHitlInterruptIDs(c *gin.Context, ids []string) ([]string, error) {
+236
View File
@@ -0,0 +1,236 @@
package handler
import (
"database/sql"
"path/filepath"
"strings"
"testing"
"cyberstrike-ai/internal/database"
"go.uber.org/zap"
)
func TestEnsureSchemaCancelsPendingInterruptsAfterRestart(t *testing.T) {
db, err := database.NewDB(filepath.Join(t.TempDir(), "hitl-restart.db"), zap.NewNop())
if err != nil {
t.Fatalf("open db: %v", err)
}
defer func() { _ = db.Close() }()
manager := NewHITLManager(db, zap.NewNop())
if err := manager.EnsureSchema(); err != nil {
t.Fatalf("ensure schema: %v", err)
}
conversation, err := db.CreateConversation("restart interrupted", database.ConversationCreateMeta{})
if err != nil {
t.Fatalf("create conversation: %v", err)
}
message, err := db.AddMessage(conversation.ID, "assistant", "处理中...", nil)
if err != nil {
t.Fatalf("create assistant placeholder: %v", err)
}
if _, err := db.Exec(`INSERT INTO hitl_interrupts
(id, conversation_id, message_id, mode, tool_name, tool_call_id, payload, status, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, 'pending', CURRENT_TIMESTAMP)`,
"restart-pending", conversation.ID, message.ID, "approval", "browser", "tool-call-1", `{}`); err != nil {
t.Fatalf("insert pending interrupt: %v", err)
}
if err := manager.EnsureSchema(); err != nil {
t.Fatalf("reconcile restart: %v", err)
}
var status, decision, comment, decidedBy string
var decidedAt sql.NullTime
if err := db.QueryRow(`SELECT status, decision, decision_comment, decided_by, decided_at
FROM hitl_interrupts WHERE id = ?`, "restart-pending").
Scan(&status, &decision, &comment, &decidedBy, &decidedAt); err != nil {
t.Fatalf("query reconciled interrupt: %v", err)
}
if status != "cancelled" || decision != "reject" || comment != "process restarted" {
t.Fatalf("unexpected restart decision: status=%q decision=%q comment=%q", status, decision, comment)
}
if decidedBy != "system" {
t.Fatalf("decided_by=%q, want system", decidedBy)
}
if !decidedAt.Valid {
t.Fatal("decided_at should be set after restart reconciliation")
}
var content string
var updatedAt sql.NullTime
if err := db.QueryRow(`SELECT content, updated_at FROM messages WHERE id = ?`, message.ID).
Scan(&content, &updatedAt); err != nil {
t.Fatalf("query reconciled assistant message: %v", err)
}
if content != "任务因服务重启已中断,审批已取消。" {
t.Fatalf("assistant content=%q, want restart interruption notice", content)
}
if !updatedAt.Valid {
t.Fatal("assistant updated_at should be set to the interruption time")
}
var eventType, eventMessage string
if err := db.QueryRow(`SELECT event_type, message FROM process_details WHERE message_id = ?`, message.ID).
Scan(&eventType, &eventMessage); err != nil {
t.Fatalf("query restart cancellation process detail: %v", err)
}
if eventType != "cancelled" || eventMessage != content {
t.Fatalf("unexpected terminal detail: type=%q message=%q", eventType, eventMessage)
}
}
func TestEnsureSchemaFinalizesOnlyHistoricalPlaceholdersWithTerminalEvidence(t *testing.T) {
db, err := database.NewDB(filepath.Join(t.TempDir(), "hitl-history.db"), zap.NewNop())
if err != nil {
t.Fatalf("open db: %v", err)
}
defer func() { _ = db.Close() }()
manager := NewHITLManager(db, zap.NewNop())
if err := manager.EnsureSchema(); err != nil {
t.Fatalf("ensure schema: %v", err)
}
supersededConversation, err := db.CreateConversation("superseded placeholder", database.ConversationCreateMeta{})
if err != nil {
t.Fatalf("create superseded conversation: %v", err)
}
superseded, err := db.AddMessage(supersededConversation.ID, "assistant", "处理中...", nil)
if err != nil {
t.Fatalf("create superseded placeholder: %v", err)
}
if _, err := db.AddMessage(supersededConversation.ID, "user", "继续", nil); err != nil {
t.Fatalf("create later message: %v", err)
}
timeoutConversation, err := db.CreateConversation("timeout placeholder", database.ConversationCreateMeta{})
if err != nil {
t.Fatalf("create timeout conversation: %v", err)
}
timedOut, err := db.AddMessage(timeoutConversation.ID, "assistant", "处理中...", nil)
if err != nil {
t.Fatalf("create timeout placeholder: %v", err)
}
if _, err := db.Exec(`INSERT INTO hitl_interrupts
(id, conversation_id, message_id, mode, tool_name, status, decision, decision_comment, created_at, decided_at)
VALUES (?, ?, ?, 'approval', 'browser', 'timeout', 'reject', 'HITL timeout auto-reject for safety', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`,
"timeout-interrupt", timeoutConversation.ID, timedOut.ID); err != nil {
t.Fatalf("insert timeout interrupt: %v", err)
}
rejectedConversation, err := db.CreateConversation("rejected placeholder", database.ConversationCreateMeta{})
if err != nil {
t.Fatalf("create rejected conversation: %v", err)
}
rejected, err := db.AddMessage(rejectedConversation.ID, "assistant", "处理中...", nil)
if err != nil {
t.Fatalf("create rejected placeholder: %v", err)
}
if _, err := db.Exec(`INSERT INTO hitl_interrupts
(id, conversation_id, message_id, mode, tool_name, status, decision, decision_comment, created_at, decided_at)
VALUES (?, ?, ?, 'approval', 'exec', 'decided', 'reject', 'user rejected', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`,
"rejected-interrupt", rejectedConversation.ID, rejected.ID); err != nil {
t.Fatalf("insert rejected interrupt: %v", err)
}
activeConversation, err := db.CreateConversation("potentially active placeholder", database.ConversationCreateMeta{})
if err != nil {
t.Fatalf("create active conversation: %v", err)
}
potentiallyActive, err := db.AddMessage(activeConversation.ID, "assistant", "处理中...", nil)
if err != nil {
t.Fatalf("create potentially active placeholder: %v", err)
}
if err := manager.EnsureSchema(); err != nil {
t.Fatalf("reconcile historical placeholders: %v", err)
}
assertTerminal := func(messageID, wantContent, wantEvent string) {
t.Helper()
var content, eventType string
if err := db.QueryRow(`SELECT content FROM messages WHERE id = ?`, messageID).Scan(&content); err != nil {
t.Fatalf("query message %s: %v", messageID, err)
}
if content != wantContent {
t.Fatalf("message %s content=%q, want %q", messageID, content, wantContent)
}
if err := db.QueryRow(`SELECT event_type FROM process_details WHERE message_id = ?
AND event_type IN ('cancelled', 'timeout', 'error')`, messageID).Scan(&eventType); err != nil {
t.Fatalf("query terminal detail %s: %v", messageID, err)
}
if eventType != wantEvent {
t.Fatalf("message %s event=%q, want %q", messageID, eventType, wantEvent)
}
}
assertTerminal(superseded.ID, "任务因服务重启已中断。", "cancelled")
assertTerminal(timedOut.ID, "任务等待审批超时,已自动拒绝。", "timeout")
assertTerminal(rejected.ID, "任务审批已拒绝,执行已停止。", "cancelled")
var activeContent string
if err := db.QueryRow(`SELECT content FROM messages WHERE id = ?`, potentiallyActive.ID).Scan(&activeContent); err != nil {
t.Fatalf("query potentially active message: %v", err)
}
if activeContent != "处理中..." {
t.Fatalf("potentially active message was rewritten to %q", activeContent)
}
var terminalCount int
if err := db.QueryRow(`SELECT COUNT(*) FROM process_details WHERE message_id = ?
AND event_type IN ('cancelled', 'timeout', 'error')`, potentiallyActive.ID).Scan(&terminalCount); err != nil {
t.Fatalf("count active terminal details: %v", err)
}
if terminalCount != 0 {
t.Fatalf("potentially active message got %d terminal details", terminalCount)
}
}
func TestAuditAgentInterruptIsNotHumanPendingWork(t *testing.T) {
db, err := database.NewDB(filepath.Join(t.TempDir(), "hitl-reviewer.db"), zap.NewNop())
if err != nil {
t.Fatalf("open db: %v", err)
}
defer func() { _ = db.Close() }()
manager := NewHITLManager(db, zap.NewNop())
if err := manager.EnsureSchema(); err != nil {
t.Fatalf("ensure schema: %v", err)
}
audit, err := manager.CreatePendingInterrupt("conversation-audit", "message-audit", "review_edit", "exec", "call-audit", `{}`, "audit_agent")
if err != nil {
t.Fatalf("create audit interrupt: %v", err)
}
human, err := manager.CreatePendingInterrupt("conversation-human", "message-human", "approval", "exec", "call-human", `{}`, "human")
if err != nil {
t.Fatalf("create human interrupt: %v", err)
}
manager.mu.RLock()
_, auditWaitsForHuman := manager.pending[audit.InterruptID]
_, humanWaitsForHuman := manager.pending[human.InterruptID]
manager.mu.RUnlock()
if auditWaitsForHuman {
t.Fatal("audit-agent interrupt must not enter the human pending queue")
}
if !humanWaitsForHuman {
t.Fatal("human interrupt should enter the human pending queue")
}
query, args := (&AgentHandler{}).buildHitlListQuery(false)
if len(args) != 0 {
t.Fatalf("unexpected pending query args: %v", args)
}
if !strings.Contains(query, "COALESCE(reviewer,'human') = 'human'") {
t.Fatalf("pending query must filter out audit-agent work: %s", query)
}
rows, err := db.Query(query)
if err != nil {
t.Fatalf("query human pending interrupts: %v", err)
}
defer rows.Close()
items, err := (&AgentHandler{}).scanHitlInterruptRows(rows)
if err != nil {
t.Fatalf("scan human pending interrupts: %v", err)
}
if len(items) != 1 || items[0]["id"] != human.InterruptID || items[0]["reviewer"] != "human" {
t.Fatalf("unexpected human pending result: %#v", items)
}
}
+21
View File
@@ -0,0 +1,21 @@
package handler
import "testing"
func TestHITLBuiltInWhitelistExemptsWriteFile(t *testing.T) {
h := &AgentHandler{}
req := h.hitlRequestWithMergedConfigWhitelist(&HITLRequest{
Enabled: true,
Mode: "approval",
})
manager := NewHITLManager(nil, nil)
manager.ActivateConversation("conversation-1", req)
if manager.NeedsToolApproval("conversation-1", "write_file") {
t.Fatal("write_file should use the built-in HITL exemption")
}
if !manager.NeedsToolApproval("conversation-1", "exec") {
t.Fatal("non-exempt tools should still require approval")
}
}
+14
View File
@@ -240,6 +240,12 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
taskCtxLoop := mcp.WithMCPConversationID(taskCtx, conversationID)
taskCtxLoop = mcp.WithToolRunRegistry(taskCtxLoop, h.tasks)
taskCtxLoop = mcp.WithEinoExecuteRunRegistry(taskCtxLoop, h.tasks)
taskCtxLoop = multiagent.WithAgentRuntimeCancelRegistrar(taskCtxLoop, func(cancel func(error) bool) func() {
return h.tasks.BindAgentRuntimeCancel(conversationID, cancel)
})
taskCtxLoop = multiagent.WithAgentTurnLoopInterruptRegistrar(taskCtxLoop, func(push func(string) bool) func() {
return h.tasks.BindAgentTurnLoopInterrupt(conversationID, push)
})
taskCtxLoop = multiagent.WithHITLToolInterceptor(taskCtxLoop, func(ctx context.Context, toolName, arguments string) (string, error) {
return h.interceptHITLForEinoTool(ctx, cancelWithCause, conversationID, assistantMessageID, sendEvent, toolName, arguments)
})
@@ -287,6 +293,14 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
}
cause := context.Cause(baseCtx)
if cause == nil {
switch {
case errors.Is(runErr, multiagent.ErrInterruptContinue):
cause = multiagent.ErrInterruptContinue
case errors.Is(runErr, ErrTaskCancelled):
cause = ErrTaskCancelled
}
}
if errors.Is(cause, multiagent.ErrInterruptContinue) {
if shouldPersistEinoAgentTraceAfterRunError(baseCtx) {
h.persistEinoAgentTraceForResume(conversationID, result)
+17
View File
@@ -21,6 +21,22 @@ import (
"go.uber.org/zap"
)
func withTempWorkingDir(t *testing.T) string {
t.Helper()
old, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
dir := t.TempDir()
if err := os.Chdir(dir); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
_ = os.Chdir(old)
})
return dir
}
func TestDetachedAgentContextRetainsPrincipalWithoutParentCancellation(t *testing.T) {
parent, cancel := context.WithCancel(context.Background())
parent = authctx.WithPrincipal(parent, authctx.NewPrincipal("u1", "user", database.RBACScopeAssigned, map[string]bool{"agent:execute": true}))
@@ -149,6 +165,7 @@ func TestChatUploadPathAuthorizationFollowsConversationAccess(t *testing.T) {
}
func TestChatUploadsListIncludesAuthorizedProjectWorkspaceFiles(t *testing.T) {
withTempWorkingDir(t)
db, user := setupConversationRBACTest(t)
fsBase := t.TempDir()
workspaceBase := filepath.Join(fsBase, "workspace")
+77 -1
View File
@@ -46,6 +46,14 @@ type AgentTask struct {
// hitlCognition 本轮运行中供 HITL/审计 Agent 读取的上下文(用户原话 + 思考,不含会话历史)
hitlCognition *hitlCognitionState
// agentRuntimeCancel 当前 Eino ADK 原生 AgentCancelFunc 包装;取消任务时先触发它,再走 context 兜底。
agentRuntimeCancel func(error) bool
agentRuntimeCancelVersion uint64
// agentTurnLoopInterrupt 当前 Eino TurnLoop 用户补充 push hook;中断并继续时优先将补充作为新 turn item 入队。
agentTurnLoopInterrupt func(string) bool
agentTurnLoopInterruptVersion uint64
cancel func(error)
}
@@ -220,6 +228,58 @@ func (m *AgentTaskManager) BindTaskCancel(conversationID string, cancel context.
}
}
// BindAgentRuntimeCancel 登记当前运行段的 Eino 原生 cancel hook。
func (m *AgentTaskManager) BindAgentRuntimeCancel(conversationID string, cancel func(error) bool) func() {
conversationID = strings.TrimSpace(conversationID)
if conversationID == "" || cancel == nil {
return func() {}
}
m.mu.Lock()
t, ok := m.tasks[conversationID]
if !ok || t == nil {
m.mu.Unlock()
return func() {}
}
t.agentRuntimeCancelVersion++
version := t.agentRuntimeCancelVersion
t.agentRuntimeCancel = cancel
m.mu.Unlock()
return func() {
m.mu.Lock()
defer m.mu.Unlock()
if cur, exists := m.tasks[conversationID]; exists && cur != nil && cur.agentRuntimeCancelVersion == version {
cur.agentRuntimeCancel = nil
}
}
}
// BindAgentTurnLoopInterrupt 登记当前运行任务的 Eino TurnLoop 用户补充入队 hook。
func (m *AgentTaskManager) BindAgentTurnLoopInterrupt(conversationID string, push func(string) bool) func() {
conversationID = strings.TrimSpace(conversationID)
if conversationID == "" || push == nil {
return func() {}
}
m.mu.Lock()
t, ok := m.tasks[conversationID]
if !ok || t == nil {
m.mu.Unlock()
return func() {}
}
t.agentTurnLoopInterruptVersion++
version := t.agentTurnLoopInterruptVersion
t.agentTurnLoopInterrupt = push
m.mu.Unlock()
return func() {
m.mu.Lock()
defer m.mu.Unlock()
if cur, exists := m.tasks[conversationID]; exists && cur != nil && cur.agentTurnLoopInterruptVersion == version {
cur.agentTurnLoopInterrupt = nil
}
}
}
// ActiveMCPExecutionID 返回当前会话进行中的工具 executionId,无则空串。
func (m *AgentTaskManager) ActiveMCPExecutionID(conversationID string) string {
conversationID = strings.TrimSpace(conversationID)
@@ -402,13 +462,29 @@ func (m *AgentTaskManager) CancelTask(conversationID string, cause error) (bool,
if cause == nil {
cause = ErrTaskCancelled
}
interruptPush := task.agentTurnLoopInterrupt
interruptNote := task.InterruptContinueNote
runtimeCancel := task.agentRuntimeCancel
var toolCanceler func(string)
if errors.Is(cause, ErrTaskCancelled) {
toolCanceler = m.toolCanceler
}
m.mu.Unlock()
if cancel != nil {
if errors.Is(cause, multiagent.ErrInterruptContinue) && interruptPush != nil && interruptPush(interruptNote) {
m.mu.Lock()
if cur, exists := m.tasks[conversationID]; exists && cur != nil {
cur.InterruptContinueNote = ""
}
m.mu.Unlock()
return true, nil
}
runtimeHandled := false
if runtimeCancel != nil {
runtimeHandled = runtimeCancel(cause)
}
if cancel != nil && !runtimeHandled {
cancel(cause)
}
if toolCanceler != nil {
@@ -32,6 +32,78 @@ func TestCancelTaskInvokesToolCancelerOnFullStop(t *testing.T) {
}
}
func TestCancelTaskUsesAgentRuntimeCancelAsPrimaryPath(t *testing.T) {
tm := NewAgentTaskManager()
var order []string
tm.SetToolCanceler(func(conversationID string) {
if conversationID == "conv-native" {
order = append(order, "tool")
}
})
_, cancel := context.WithCancelCause(context.Background())
if _, err := tm.StartTask("conv-native", "hello", func(err error) {
order = append(order, "context")
cancel(err)
}); err != nil {
t.Fatalf("StartTask: %v", err)
}
unregister := tm.BindAgentRuntimeCancel("conv-native", func(err error) bool {
if !errors.Is(err, ErrTaskCancelled) {
t.Fatalf("runtime cancel got %v", err)
}
order = append(order, "runtime")
return true
})
defer unregister()
ok, err := tm.CancelTask("conv-native", ErrTaskCancelled)
if err != nil || !ok {
t.Fatalf("CancelTask: ok=%v err=%v", ok, err)
}
want := []string{"runtime", "tool"}
if len(order) != len(want) {
t.Fatalf("order length got %d want %d: %#v", len(order), len(want), order)
}
for i := range want {
if order[i] != want[i] {
t.Fatalf("order[%d] got %q want %q; full=%#v", i, order[i], want[i], order)
}
}
}
func TestCancelTaskFallsBackToContextWhenAgentRuntimeCancelMisses(t *testing.T) {
tm := NewAgentTaskManager()
var order []string
_, cancel := context.WithCancelCause(context.Background())
if _, err := tm.StartTask("conv-fallback", "hello", func(err error) {
order = append(order, "context")
cancel(err)
}); err != nil {
t.Fatalf("StartTask: %v", err)
}
unregister := tm.BindAgentRuntimeCancel("conv-fallback", func(err error) bool {
order = append(order, "runtime")
return false
})
defer unregister()
ok, err := tm.CancelTask("conv-fallback", ErrTaskCancelled)
if err != nil || !ok {
t.Fatalf("CancelTask: ok=%v err=%v", ok, err)
}
want := []string{"runtime", "context"}
if len(order) != len(want) {
t.Fatalf("order length got %d want %d: %#v", len(order), len(want), order)
}
for i := range want {
if order[i] != want[i] {
t.Fatalf("order[%d] got %q want %q; full=%#v", i, order[i], want[i], order)
}
}
}
func TestCancelTaskSkipsToolCancelerOnInterruptContinue(t *testing.T) {
tm := NewAgentTaskManager()
called := false
@@ -54,6 +126,80 @@ func TestCancelTaskSkipsToolCancelerOnInterruptContinue(t *testing.T) {
}
}
func TestCancelTaskPushesInterruptContinueToTurnLoopFirst(t *testing.T) {
tm := NewAgentTaskManager()
ctx, cancel := context.WithCancelCause(context.Background())
if _, err := tm.StartTask("conv-turn", "hello", cancel); err != nil {
t.Fatalf("StartTask: %v", err)
}
tm.SetInterruptContinueNote("conv-turn", "focus ssh")
var gotNote string
unregister := tm.BindAgentTurnLoopInterrupt("conv-turn", func(note string) bool {
gotNote = note
return true
})
defer unregister()
ok, err := tm.CancelTask("conv-turn", multiagent.ErrInterruptContinue)
if err != nil || !ok {
t.Fatalf("CancelTask: ok=%v err=%v", ok, err)
}
if gotNote != "focus ssh" {
t.Fatalf("turn loop note = %q, want focus ssh", gotNote)
}
if cause := context.Cause(ctx); cause != nil {
t.Fatalf("context should not be cancelled when turn loop accepted interrupt, got %v", cause)
}
if note := tm.TakeInterruptContinueNote("conv-turn"); note != "" {
t.Fatalf("interrupt note should be consumed after turn loop push, got %q", note)
}
}
func TestCancelTaskFallsBackWhenTurnLoopInterruptRejects(t *testing.T) {
tm := NewAgentTaskManager()
var order []string
_, cancel := context.WithCancelCause(context.Background())
if _, err := tm.StartTask("conv-turn-fallback", "hello", func(err error) {
order = append(order, "context")
cancel(err)
}); err != nil {
t.Fatalf("StartTask: %v", err)
}
tm.SetInterruptContinueNote("conv-turn-fallback", "fallback note")
unregisterTurn := tm.BindAgentTurnLoopInterrupt("conv-turn-fallback", func(note string) bool {
order = append(order, "turn")
if note != "fallback note" {
t.Fatalf("turn loop note = %q, want fallback note", note)
}
return false
})
defer unregisterTurn()
unregisterRuntime := tm.BindAgentRuntimeCancel("conv-turn-fallback", func(err error) bool {
order = append(order, "runtime")
return false
})
defer unregisterRuntime()
ok, err := tm.CancelTask("conv-turn-fallback", multiagent.ErrInterruptContinue)
if err != nil || !ok {
t.Fatalf("CancelTask: ok=%v err=%v", ok, err)
}
want := []string{"turn", "runtime", "context"}
if len(order) != len(want) {
t.Fatalf("order length got %d want %d: %#v", len(order), len(want), order)
}
for i := range want {
if order[i] != want[i] {
t.Fatalf("order[%d] got %q want %q; full=%#v", i, order[i], want[i], order)
}
}
if note := tm.TakeInterruptContinueNote("conv-turn-fallback"); note != "fallback note" {
t.Fatalf("interrupt note should remain for fallback rerun, got %q", note)
}
}
func TestCancelTaskDefaultCauseIsTaskCancelled(t *testing.T) {
tm := NewAgentTaskManager()
var gotCause error
@@ -13,6 +13,7 @@ import (
"cyberstrike-ai/internal/database"
"cyberstrike-ai/internal/security"
workflowrunner "cyberstrike-ai/internal/workflow"
workflowpkg "cyberstrike-ai/internal/workflow/package"
"github.com/gin-gonic/gin"
@@ -22,6 +23,7 @@ import (
func TestWorkflowPackageHandlerInspectionAndCreateImport(t *testing.T) {
gin.SetMode(gin.TestMode)
workflowrunner.SetCheckpointDir(filepath.Join(t.TempDir(), "workflow-checkpoints"))
db, err := database.NewDB(filepath.Join(t.TempDir(), "workflow-package-handler.db"), zap.NewNop())
if err != nil {
t.Fatal(err)
File diff suppressed because it is too large Load Diff
@@ -15,7 +15,7 @@ func TestRecvSchemaMessageStream_EOF(t *testing.T) {
_ = sw.Send(schema.ToolMessage("hello", "tc-1"), nil)
sw.Close()
content, tid, err := recvSchemaMessageStream(context.Background(), sr)
content, tid, toolName, err := recvSchemaMessageStream(context.Background(), sr)
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
@@ -25,6 +25,23 @@ func TestRecvSchemaMessageStream_EOF(t *testing.T) {
if tid != "tc-1" {
t.Fatalf("toolCallID=%q want tc-1", tid)
}
if toolName != "" {
t.Fatalf("toolName=%q want empty", toolName)
}
}
func TestRecvSchemaMessageStream_CapturesToolName(t *testing.T) {
sr, sw := schema.Pipe[*schema.Message](4)
_ = sw.Send(schema.ToolMessage("hello", "tc-1", schema.WithToolName("execute")), nil)
sw.Close()
content, tid, toolName, err := recvSchemaMessageStream(context.Background(), sr)
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if content != "hello" || tid != "tc-1" || toolName != "execute" {
t.Fatalf("content=%q tid=%q toolName=%q", content, tid, toolName)
}
}
func TestRecvSchemaMessageStream_ContextCancel(t *testing.T) {
@@ -37,7 +54,7 @@ func TestRecvSchemaMessageStream_ContextCancel(t *testing.T) {
cancel()
}()
content, _, err := recvSchemaMessageStream(ctx, sr)
content, _, _, err := recvSchemaMessageStream(ctx, sr)
if !errors.Is(err, context.Canceled) {
t.Fatalf("want context.Canceled, got %v content=%q", err, content)
}
@@ -49,16 +66,16 @@ func TestRecvSchemaMessageStream_RecvError(t *testing.T) {
_ = sw.Send(nil, want)
sw.Close()
_, _, err := recvSchemaMessageStream(context.Background(), sr)
_, _, _, err := recvSchemaMessageStream(context.Background(), sr)
if !errors.Is(err, want) {
t.Fatalf("want %v, got %v", want, err)
}
}
func TestRecvSchemaMessageStream_NilStream(t *testing.T) {
content, tid, err := recvSchemaMessageStream(context.Background(), nil)
if err != nil || content != "" || tid != "" {
t.Fatalf("nil stream: content=%q tid=%q err=%v", content, tid, err)
content, tid, toolName, err := recvSchemaMessageStream(context.Background(), nil)
if err != nil || content != "" || tid != "" || toolName != "" {
t.Fatalf("nil stream: content=%q tid=%q toolName=%q err=%v", content, tid, toolName, err)
}
}
@@ -67,8 +84,39 @@ func TestRecvSchemaMessageStream_EOFViaEmptyRead(t *testing.T) {
_ = sw.Send(nil, io.EOF)
sw.Close()
_, _, err := recvSchemaMessageStream(context.Background(), sr)
_, _, _, err := recvSchemaMessageStream(context.Background(), sr)
if err != nil {
t.Fatalf("EOF should not surface as error, got %v", err)
}
}
func TestRecvEinoSchemaMessageStreamWithContext_SkipsNilChunks(t *testing.T) {
sr, sw := schema.Pipe[*schema.Message](4)
_ = sw.Send(nil, nil)
_ = sw.Send(schema.AssistantMessage("hello", nil), nil)
sw.Close()
var got []string
err := recvEinoSchemaMessageStreamWithContext(context.Background(), sr, 1, func(chunk *schema.Message) {
got = append(got, chunk.Content)
})
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if len(got) != 1 || got[0] != "hello" {
t.Fatalf("chunks = %#v, want [hello]", got)
}
}
func TestRecvEinoSchemaMessageStreamWithContext_NilStream(t *testing.T) {
called := false
err := recvEinoSchemaMessageStreamWithContext(context.Background(), nil, 0, func(*schema.Message) {
called = true
})
if err != nil {
t.Fatalf("nil stream should not error, got %v", err)
}
if called {
t.Fatal("nil stream should not call handler")
}
}
@@ -0,0 +1,81 @@
package multiagent
import (
"context"
"fmt"
"github.com/cloudwego/eino/adk"
"github.com/cloudwego/eino/schema"
)
type einoAgenticMessageAgentAdapter struct {
inner adk.TypedAgent[*schema.AgenticMessage]
}
func newEinoAgenticMessageAgentAdapter(inner adk.TypedAgent[*schema.AgenticMessage]) adk.Agent {
if inner == nil {
return nil
}
return &einoAgenticMessageAgentAdapter{inner: inner}
}
func (a *einoAgenticMessageAgentAdapter) Name(ctx context.Context) string {
if a == nil || a.inner == nil {
return ""
}
return a.inner.Name(ctx)
}
func (a *einoAgenticMessageAgentAdapter) Description(ctx context.Context) string {
if a == nil || a.inner == nil {
return ""
}
return a.inner.Description(ctx)
}
func (a *einoAgenticMessageAgentAdapter) Run(ctx context.Context, input *adk.AgentInput, opts ...adk.AgentRunOption) *adk.AsyncIterator[*adk.AgentEvent] {
return a.runTyped(ctx, input, nil, opts...)
}
func (a *einoAgenticMessageAgentAdapter) Resume(ctx context.Context, info *adk.ResumeInfo, opts ...adk.AgentRunOption) *adk.AsyncIterator[*adk.AgentEvent] {
return a.runTyped(ctx, nil, info, opts...)
}
func (a *einoAgenticMessageAgentAdapter) runTyped(ctx context.Context, input *adk.AgentInput, resumeInfo *adk.ResumeInfo, opts ...adk.AgentRunOption) *adk.AsyncIterator[*adk.AgentEvent] {
iter, gen := adk.NewAsyncIteratorPair[*adk.AgentEvent]()
go func() {
defer gen.Close()
if a == nil || a.inner == nil {
gen.Send(&adk.AgentEvent{Err: fmt.Errorf("agentic adapter: inner agent is nil")})
return
}
var agenticIter *adk.AsyncIterator[*adk.TypedAgentEvent[*schema.AgenticMessage]]
if resumeInfo != nil {
resumable, ok := a.inner.(adk.TypedResumableAgent[*schema.AgenticMessage])
if !ok {
gen.Send(&adk.AgentEvent{Err: fmt.Errorf("agentic adapter: inner agent does not support resume")})
return
}
agenticIter = resumable.Resume(ctx, resumeInfo, opts...)
} else {
agenticInput := &adk.TypedAgentInput[*schema.AgenticMessage]{}
if input != nil {
agenticInput.EnableStreaming = input.EnableStreaming
agenticInput.Messages = EinoMessagesToAgentic(input.Messages)
}
agenticIter = a.inner.Run(ctx, agenticInput, opts...)
}
for {
ev, ok := agenticIter.Next()
if !ok {
return
}
for _, adapted := range adaptAgenticEventToEinoEvents(ev) {
if adapted != nil {
gen.Send(adapted)
}
}
}
}()
return iter
}
@@ -0,0 +1,145 @@
package multiagent
import (
"context"
"testing"
"github.com/cloudwego/eino/adk"
"github.com/cloudwego/eino/schema"
)
type fakeAgenticMessageAgent struct {
name string
description string
captured *adk.TypedAgentInput[*schema.AgenticMessage]
resumeInfo *adk.ResumeInfo
events []*adk.TypedAgentEvent[*schema.AgenticMessage]
}
func (f *fakeAgenticMessageAgent) Name(context.Context) string {
return f.name
}
func (f *fakeAgenticMessageAgent) Description(context.Context) string {
return f.description
}
func (f *fakeAgenticMessageAgent) Run(_ context.Context, input *adk.TypedAgentInput[*schema.AgenticMessage], _ ...adk.AgentRunOption) *adk.AsyncIterator[*adk.TypedAgentEvent[*schema.AgenticMessage]] {
f.captured = input
iter, gen := adk.NewAsyncIteratorPair[*adk.TypedAgentEvent[*schema.AgenticMessage]]()
go func() {
defer gen.Close()
for _, ev := range f.events {
gen.Send(ev)
}
}()
return iter
}
func (f *fakeAgenticMessageAgent) Resume(_ context.Context, info *adk.ResumeInfo, _ ...adk.AgentRunOption) *adk.AsyncIterator[*adk.TypedAgentEvent[*schema.AgenticMessage]] {
f.resumeInfo = info
iter, gen := adk.NewAsyncIteratorPair[*adk.TypedAgentEvent[*schema.AgenticMessage]]()
go func() {
defer gen.Close()
for _, ev := range f.events {
gen.Send(ev)
}
}()
return iter
}
func TestEinoAgenticMessageAgentAdapterConvertsInputAndEvents(t *testing.T) {
inner := &fakeAgenticMessageAgent{
name: "agentic",
description: "typed agent",
events: []*adk.TypedAgentEvent[*schema.AgenticMessage]{
{
AgentName: "agentic",
Output: &adk.TypedAgentOutput[*schema.AgenticMessage]{
MessageOutput: &adk.TypedMessageVariant[*schema.AgenticMessage]{
Message: &schema.AgenticMessage{
Role: schema.AgenticRoleTypeAssistant,
ContentBlocks: []*schema.ContentBlock{
schema.NewContentBlock(&schema.AssistantGenText{Text: "hello"}),
},
},
},
},
},
},
}
agent := newEinoAgenticMessageAgentAdapter(inner)
if agent.Name(context.Background()) != "agentic" || agent.Description(context.Background()) != "typed agent" {
t.Fatalf("adapter metadata name=%q desc=%q", agent.Name(context.Background()), agent.Description(context.Background()))
}
iter := agent.Run(context.Background(), &adk.AgentInput{
EnableStreaming: true,
Messages: []*schema.Message{
schema.UserMessage("hi"),
},
})
ev, ok := iter.Next()
if !ok {
t.Fatal("expected adapted event")
}
if inner.captured == nil || !inner.captured.EnableStreaming || len(inner.captured.Messages) != 1 {
t.Fatalf("captured input = %#v", inner.captured)
}
if inner.captured.Messages[0].Role != schema.AgenticRoleTypeUser || inner.captured.Messages[0].ContentBlocks[0].UserInputText.Text != "hi" {
t.Fatalf("captured message = %#v", inner.captured.Messages[0])
}
if ev.AgentName != "agentic" || ev.Output == nil || ev.Output.MessageOutput == nil {
t.Fatalf("event = %#v", ev)
}
if ev.Output.MessageOutput.Role != schema.Assistant || ev.Output.MessageOutput.Message.Content != "hello" {
t.Fatalf("message output = %#v", ev.Output.MessageOutput)
}
if _, ok := iter.Next(); ok {
t.Fatal("expected iterator to close")
}
}
func TestEinoAgenticMessageAgentAdapterNilInnerReturnsNil(t *testing.T) {
if got := newEinoAgenticMessageAgentAdapter(nil); got != nil {
t.Fatalf("adapter = %#v, want nil", got)
}
}
func TestEinoAgenticMessageAgentAdapterResumeConvertsEvents(t *testing.T) {
inner := &fakeAgenticMessageAgent{
name: "agentic",
events: []*adk.TypedAgentEvent[*schema.AgenticMessage]{
{
AgentName: "agentic",
Output: &adk.TypedAgentOutput[*schema.AgenticMessage]{
MessageOutput: &adk.TypedMessageVariant[*schema.AgenticMessage]{
Message: &schema.AgenticMessage{
Role: schema.AgenticRoleTypeAssistant,
ContentBlocks: []*schema.ContentBlock{
schema.NewContentBlock(&schema.AssistantGenText{Text: "resumed"}),
},
},
},
},
},
},
}
agent, ok := newEinoAgenticMessageAgentAdapter(inner).(adk.ResumableAgent)
if !ok {
t.Fatal("adapter must implement adk.ResumableAgent")
}
info := &adk.ResumeInfo{WasInterrupted: true}
iter := agent.Resume(context.Background(), info)
ev, ok := iter.Next()
if !ok {
t.Fatal("expected adapted resume event")
}
if inner.resumeInfo != info {
t.Fatalf("resume info = %#v, want original pointer", inner.resumeInfo)
}
if ev.Output == nil || ev.Output.MessageOutput == nil || ev.Output.MessageOutput.Message.Content != "resumed" {
t.Fatalf("resume event = %#v", ev)
}
}
@@ -0,0 +1,64 @@
package multiagent
import (
"context"
"fmt"
"github.com/cloudwego/eino/adk"
"github.com/cloudwego/eino/components/model"
"github.com/cloudwego/eino/components/tool"
"github.com/cloudwego/eino/schema"
)
type einoAgenticChatModelAgentConfig struct {
Name string
Description string
Instruction string
Model model.AgenticModel
ToolsConfig adk.ToolsConfig
MaxIterations int
Exit tool.BaseTool
GenModelInput adk.TypedGenModelInput[*schema.AgenticMessage]
Handlers []adk.TypedChatModelAgentMiddleware[*schema.AgenticMessage]
ModelRetryConfig *adk.TypedModelRetryConfig[*schema.AgenticMessage]
ModelFailoverConfig *adk.ModelFailoverConfig[*schema.AgenticMessage]
OutputKey string
}
func newEinoAgenticChatModelAgent(ctx context.Context, cfg einoAgenticChatModelAgentConfig) (adk.TypedResumableAgent[*schema.AgenticMessage], error) {
if cfg.Model == nil {
return nil, fmt.Errorf("eino agentic ChatModelAgent: model is required")
}
typedCfg := &adk.TypedChatModelAgentConfig[*schema.AgenticMessage]{
Name: cfg.Name,
Description: cfg.Description,
Instruction: cfg.Instruction,
Model: cfg.Model,
ToolsConfig: cfg.ToolsConfig,
MaxIterations: cfg.MaxIterations,
Exit: cfg.Exit,
GenModelInput: cfg.GenModelInput,
Handlers: cfg.Handlers,
ModelRetryConfig: cfg.ModelRetryConfig,
ModelFailoverConfig: cfg.ModelFailoverConfig,
OutputKey: cfg.OutputKey,
}
typedAgent, err := adk.NewTypedChatModelAgent(ctx, typedCfg)
if err != nil {
return nil, fmt.Errorf("eino agentic NewTypedChatModelAgent: %w", err)
}
return typedAgent, nil
}
func newEinoAgenticChatModelAgentAdapter(ctx context.Context, cfg einoAgenticChatModelAgentConfig) (adk.Agent, error) {
typedAgent, err := newEinoAgenticChatModelAgent(ctx, cfg)
if err != nil {
return nil, err
}
agent := newEinoAgenticMessageAgentAdapter(typedAgent)
if agent == nil {
return nil, fmt.Errorf("eino agentic ChatModelAgent: adapter is nil")
}
return agent, nil
}
@@ -0,0 +1,163 @@
package multiagent
import (
"context"
"strings"
"sync"
"testing"
"github.com/cloudwego/eino/adk"
"github.com/cloudwego/eino/components/model"
"github.com/cloudwego/eino/schema"
)
type capturingAgenticChatModel struct {
mu sync.Mutex
inputs [][]*schema.AgenticMessage
output *schema.AgenticMessage
}
func (m *capturingAgenticChatModel) Generate(_ context.Context, input []*schema.AgenticMessage, _ ...model.Option) (*schema.AgenticMessage, error) {
m.mu.Lock()
m.inputs = append(m.inputs, input)
m.mu.Unlock()
if m.output != nil {
return m.output, nil
}
return &schema.AgenticMessage{
Role: schema.AgenticRoleTypeAssistant,
ContentBlocks: []*schema.ContentBlock{schema.NewContentBlock(&schema.AssistantGenText{Text: "agentic answer"})},
}, nil
}
func (m *capturingAgenticChatModel) Stream(_ context.Context, input []*schema.AgenticMessage, _ ...model.Option) (*schema.StreamReader[*schema.AgenticMessage], error) {
msg, err := m.Generate(context.Background(), input)
if err != nil {
return nil, err
}
return schema.StreamReaderFromArray([]*schema.AgenticMessage{msg}), nil
}
func (m *capturingAgenticChatModel) snapshotInputs() [][]*schema.AgenticMessage {
m.mu.Lock()
defer m.mu.Unlock()
out := make([][]*schema.AgenticMessage, len(m.inputs))
copy(out, m.inputs)
return out
}
func TestNewEinoAgenticChatModelAgentAdapterRunsThroughClassicAgentBoundary(t *testing.T) {
t.Parallel()
ctx := context.Background()
trace := newModelFacingTraceHolder()
fakeModel := &capturingAgenticChatModel{}
agent, err := newEinoAgenticChatModelAgentAdapter(ctx, einoAgenticChatModelAgentConfig{
Name: "agentic",
Description: "agentic adapter test",
Instruction: "system instruction",
Model: fakeModel,
Handlers: appendEinoAgenticChatModelTailMiddlewares(nil, einoChatModelTailConfig{
phase: "agentic",
trace: trace,
}),
})
if err != nil {
t.Fatalf("newEinoAgenticChatModelAgentAdapter: %v", err)
}
iter := agent.Run(ctx, &adk.AgentInput{
Messages: []*schema.Message{schema.UserMessage("classic input")},
})
var last *adk.AgentEvent
for {
ev, ok := iter.Next()
if !ok {
break
}
if ev.Err != nil {
t.Fatalf("agent event error: %v", ev.Err)
}
last = ev
}
if last == nil || last.Output == nil || last.Output.MessageOutput == nil {
t.Fatalf("last event = %#v, want message output", last)
}
if got := last.Output.MessageOutput.Message.Content; got != "agentic answer" {
t.Fatalf("classic output content = %q, want agentic answer", got)
}
inputs := fakeModel.snapshotInputs()
if len(inputs) != 1 {
t.Fatalf("model calls = %d, want 1", len(inputs))
}
if len(inputs[0]) != 2 {
t.Fatalf("model input messages = %d, want instruction + user", len(inputs[0]))
}
if inputs[0][0].Role != schema.AgenticRoleTypeSystem || agenticMessageText(inputs[0][0]) != "system instruction" {
t.Fatalf("first agentic input = %#v", inputs[0][0])
}
if inputs[0][1].Role != schema.AgenticRoleTypeUser || agenticMessageText(inputs[0][1]) != "classic input" {
t.Fatalf("second agentic input = %#v", inputs[0][1])
}
snapshot := trace.Snapshot()
if len(snapshot) != 2 || snapshot[0].Role != schema.System || snapshot[1].Role != schema.User {
t.Fatalf("trace snapshot = %#v, want classic system + user trace", snapshot)
}
}
func TestNewEinoAgenticChatModelAgentAdapterPreservesTypedToolCallsForToolLayerRecovery(t *testing.T) {
t.Parallel()
ctx := context.Background()
fakeModel := &capturingAgenticChatModel{
output: &schema.AgenticMessage{
Role: schema.AgenticRoleTypeAssistant,
ContentBlocks: []*schema.ContentBlock{schema.NewContentBlock(&schema.FunctionToolCall{
CallID: "call-1",
Name: "exec",
Arguments: `{"command":"` + strings.Repeat("x", 20000) + `"}`,
})},
},
}
agent, err := newEinoAgenticChatModelAgentAdapter(ctx, einoAgenticChatModelAgentConfig{
Name: "agentic",
Description: "agentic adapter test",
Model: fakeModel,
Handlers: appendEinoAgenticChatModelTailMiddlewares(nil, einoChatModelTailConfig{
phase: "agentic",
}),
})
if err != nil {
t.Fatalf("newEinoAgenticChatModelAgentAdapter: %v", err)
}
iter := agent.Run(ctx, &adk.AgentInput{Messages: []*schema.Message{schema.UserMessage("run")}})
var last *adk.AgentEvent
for {
ev, ok := iter.Next()
if !ok {
break
}
if ev.Err != nil {
t.Fatalf("agent event error: %v", ev.Err)
}
last = ev
}
if last == nil || last.Output == nil || last.Output.MessageOutput == nil {
t.Fatalf("last event = %#v, want message output", last)
}
msg := last.Output.MessageOutput.Message
if len(msg.ToolCalls) != 1 {
t.Fatalf("tool calls = %#v, want one tool call", msg.ToolCalls)
}
args := msg.ToolCalls[0].Function.Arguments
if !strings.Contains(args, strings.Repeat("x", 32)) || strings.Contains(args, modelOutputRecoveryKey) {
t.Fatalf("agentic tool args were unexpectedly rewritten: %q", args)
}
}
func TestNewEinoAgenticChatModelAgentAdapterRequiresModel(t *testing.T) {
t.Parallel()
if _, err := newEinoAgenticChatModelAgentAdapter(context.Background(), einoAgenticChatModelAgentConfig{}); err == nil {
t.Fatal("expected missing model error")
}
}
@@ -0,0 +1,209 @@
package multiagent
import (
"context"
"strings"
"github.com/cloudwego/eino/adk"
"github.com/cloudwego/eino/schema"
"go.uber.org/zap"
)
// appendEinoAgenticChatModelTailMiddlewares appends protocol-neutral handlers for
// TypedChatModelAgent[*schema.AgenticMessage]. Classic ReAct history repair
// handlers stay on the schema.Message path because AgenticMessage has native
// content blocks for function calls/results.
func appendEinoAgenticChatModelTailMiddlewares(
handlers []adk.TypedChatModelAgentMiddleware[*schema.AgenticMessage],
cfg einoChatModelTailConfig,
) []adk.TypedChatModelAgentMiddleware[*schema.AgenticMessage] {
handlers = append(handlers, newAgenticSystemMessageNormalizerMiddleware(cfg.logger, cfg.phase))
handlers = append(handlers, newAgenticContinuationUserDedupMiddleware(cfg.logger, cfg.phase))
if cfg.agenticSummarization != nil {
handlers = append(handlers, cfg.agenticSummarization)
}
if !cfg.skipTrace && cfg.trace != nil {
if capMw := newAgenticModelFacingTraceMiddleware(cfg.trace); capMw != nil {
handlers = append(handlers, capMw)
}
}
return handlers
}
type agenticSystemMessageNormalizerMiddleware struct {
*adk.TypedBaseChatModelAgentMiddleware[*schema.AgenticMessage]
logger *zap.Logger
phase string
}
func newAgenticSystemMessageNormalizerMiddleware(logger *zap.Logger, phase string) adk.TypedChatModelAgentMiddleware[*schema.AgenticMessage] {
return &agenticSystemMessageNormalizerMiddleware{
TypedBaseChatModelAgentMiddleware: &adk.TypedBaseChatModelAgentMiddleware[*schema.AgenticMessage]{},
logger: logger,
phase: phase,
}
}
func (m *agenticSystemMessageNormalizerMiddleware) BeforeModelRewriteState(
ctx context.Context,
state *adk.TypedChatModelAgentState[*schema.AgenticMessage],
mc *adk.TypedModelContext[*schema.AgenticMessage],
) (context.Context, *adk.TypedChatModelAgentState[*schema.AgenticMessage], error) {
_ = mc
if m == nil || state == nil || len(state.Messages) == 0 {
return ctx, state, nil
}
before := countAgenticSystemMessages(state.Messages)
if before <= 1 {
return ctx, state, nil
}
normalized := normalizeSingleLeadingAgenticSystemMessage(state.Messages)
if len(normalized) == len(state.Messages) && countAgenticSystemMessages(normalized) >= before {
return ctx, state, nil
}
if m.logger != nil {
m.logger.Info("eino agentic system messages merged",
zap.String("phase", m.phase),
zap.Int("system_before", before),
zap.Int("system_after", countAgenticSystemMessages(normalized)),
zap.Int("messages_before", len(state.Messages)),
zap.Int("messages_after", len(normalized)),
)
}
out := *state
out.Messages = normalized
return ctx, &out, nil
}
type agenticContinuationUserDedupMiddleware struct {
*adk.TypedBaseChatModelAgentMiddleware[*schema.AgenticMessage]
logger *zap.Logger
phase string
}
func newAgenticContinuationUserDedupMiddleware(logger *zap.Logger, phase string) adk.TypedChatModelAgentMiddleware[*schema.AgenticMessage] {
return &agenticContinuationUserDedupMiddleware{
TypedBaseChatModelAgentMiddleware: &adk.TypedBaseChatModelAgentMiddleware[*schema.AgenticMessage]{},
logger: logger,
phase: phase,
}
}
func (m *agenticContinuationUserDedupMiddleware) BeforeModelRewriteState(
ctx context.Context,
state *adk.TypedChatModelAgentState[*schema.AgenticMessage],
mc *adk.TypedModelContext[*schema.AgenticMessage],
) (context.Context, *adk.TypedChatModelAgentState[*schema.AgenticMessage], error) {
_ = mc
if m == nil || state == nil || len(state.Messages) == 0 {
return ctx, state, nil
}
deduped, dropped := dedupAgenticContinuationUserMessages(state.Messages)
if dropped == 0 {
return ctx, state, nil
}
if m.logger != nil {
m.logger.Info("eino agentic continuation user messages deduplicated",
zap.String("phase", m.phase),
zap.Int("dropped", dropped),
zap.Int("messages_before", len(state.Messages)),
zap.Int("messages_after", len(deduped)),
)
}
out := *state
out.Messages = deduped
return ctx, &out, nil
}
func countAgenticSystemMessages(msgs []*schema.AgenticMessage) int {
n := 0
for _, msg := range msgs {
if msg != nil && msg.Role == schema.AgenticRoleTypeSystem {
n++
}
}
return n
}
func normalizeSingleLeadingAgenticSystemMessage(msgs []*schema.AgenticMessage) []*schema.AgenticMessage {
var systemParts []string
out := make([]*schema.AgenticMessage, 0, len(msgs))
for _, msg := range msgs {
if msg == nil {
continue
}
if msg.Role == schema.AgenticRoleTypeSystem {
if text := strings.TrimSpace(agenticMessageText(msg)); text != "" {
systemParts = append(systemParts, text)
}
continue
}
out = append(out, msg)
}
if len(systemParts) == 0 {
return out
}
merged := schema.SystemAgenticMessage(strings.Join(systemParts, "\n\n"))
return append([]*schema.AgenticMessage{merged}, out...)
}
func dedupAgenticContinuationUserMessages(msgs []*schema.AgenticMessage) ([]*schema.AgenticMessage, int) {
lastIdx := -1
contCount := 0
for i, msg := range msgs {
if !isAgenticContinuationUserMessage(msg) {
continue
}
contCount++
lastIdx = i
}
if contCount <= 1 {
return msgs, 0
}
out := make([]*schema.AgenticMessage, 0, len(msgs)-(contCount-1))
dropped := 0
for i, msg := range msgs {
if isAgenticContinuationUserMessage(msg) && i != lastIdx {
dropped++
continue
}
out = append(out, msg)
}
return out, dropped
}
func isAgenticContinuationUserMessage(msg *schema.AgenticMessage) bool {
if msg == nil || msg.Role != schema.AgenticRoleTypeUser {
return false
}
return strings.Contains(agenticMessageText(msg), continuationSessionMarker)
}
func agenticMessageText(msg *schema.AgenticMessage) string {
if msg == nil {
return ""
}
var b strings.Builder
for _, block := range msg.ContentBlocks {
if block == nil {
continue
}
switch {
case block.UserInputText != nil:
if s := strings.TrimSpace(block.UserInputText.Text); s != "" {
if b.Len() > 0 {
b.WriteByte('\n')
}
b.WriteString(s)
}
case block.AssistantGenText != nil:
if s := strings.TrimSpace(block.AssistantGenText.Text); s != "" {
if b.Len() > 0 {
b.WriteByte('\n')
}
b.WriteString(s)
}
}
}
return b.String()
}
@@ -0,0 +1,112 @@
package multiagent
import (
"context"
"strings"
"testing"
"github.com/cloudwego/eino/adk"
"github.com/cloudwego/eino/schema"
)
func TestAgenticSystemMessageNormalizerMiddlewareMergesDuplicates(t *testing.T) {
t.Parallel()
mw := newAgenticSystemMessageNormalizerMiddleware(nil, "test")
state := &adk.TypedChatModelAgentState[*schema.AgenticMessage]{
Messages: []*schema.AgenticMessage{
schema.SystemAgenticMessage("first"),
schema.UserAgenticMessage("hello"),
schema.SystemAgenticMessage("second"),
},
}
_, out, err := mw.BeforeModelRewriteState(context.Background(), state, nil)
if err != nil {
t.Fatalf("BeforeModelRewriteState: %v", err)
}
if out == state {
t.Fatal("expected rewritten state")
}
if got := countAgenticSystemMessages(out.Messages); got != 1 {
t.Fatalf("system messages = %d, want 1", got)
}
if out.Messages[0].Role != schema.AgenticRoleTypeSystem {
t.Fatalf("first role = %s, want system", out.Messages[0].Role)
}
text := agenticMessageText(out.Messages[0])
if !strings.Contains(text, "first") || !strings.Contains(text, "second") {
t.Fatalf("merged system text = %q", text)
}
if len(out.Messages) != 2 || agenticMessageText(out.Messages[1]) != "hello" {
t.Fatalf("normalized messages = %#v", out.Messages)
}
}
func TestAgenticContinuationUserDedupMiddlewareKeepsLatest(t *testing.T) {
t.Parallel()
mw := newAgenticContinuationUserDedupMiddleware(nil, "test")
state := &adk.TypedChatModelAgentState[*schema.AgenticMessage]{
Messages: []*schema.AgenticMessage{
schema.UserAgenticMessage(continuationSessionMarker + "\nold"),
schema.UserAgenticMessage("real user request"),
schema.UserAgenticMessage(continuationSessionMarker + "\nnew"),
},
}
_, out, err := mw.BeforeModelRewriteState(context.Background(), state, nil)
if err != nil {
t.Fatalf("BeforeModelRewriteState: %v", err)
}
if out == state {
t.Fatal("expected rewritten state")
}
if len(out.Messages) != 2 {
t.Fatalf("messages = %d, want 2", len(out.Messages))
}
if strings.Contains(agenticMessageText(out.Messages[0]), continuationSessionMarker) {
t.Fatalf("old continuation was not dropped: %#v", out.Messages)
}
if !strings.Contains(agenticMessageText(out.Messages[1]), "new") {
t.Fatalf("latest continuation not retained: %#v", out.Messages)
}
}
func TestAgenticModelFacingTraceMiddlewareStoresClassicTrace(t *testing.T) {
t.Parallel()
holder := newModelFacingTraceHolder()
mw := newAgenticModelFacingTraceMiddleware(holder)
state := &adk.TypedChatModelAgentState[*schema.AgenticMessage]{
Messages: []*schema.AgenticMessage{
schema.SystemAgenticMessage("instruction"),
{
Role: schema.AgenticRoleTypeAssistant,
ContentBlocks: []*schema.ContentBlock{
schema.NewContentBlock(&schema.AssistantGenText{Text: "answer"}),
},
},
},
}
if _, _, err := mw.BeforeModelRewriteState(context.Background(), state, nil); err != nil {
t.Fatalf("BeforeModelRewriteState: %v", err)
}
got := holder.Snapshot()
if len(got) != 2 {
t.Fatalf("trace len = %d, want 2", len(got))
}
if got[0].Role != schema.System || got[0].Content != "instruction" {
t.Fatalf("system trace = %#v", got[0])
}
if got[1].Role != schema.Assistant || got[1].Content != "answer" {
t.Fatalf("assistant trace = %#v", got[1])
}
}
func TestAppendEinoAgenticChatModelTailMiddlewares(t *testing.T) {
t.Parallel()
holder := newModelFacingTraceHolder()
handlers := appendEinoAgenticChatModelTailMiddlewares(nil, einoChatModelTailConfig{
phase: "agentic",
trace: holder,
})
if len(handlers) != 3 {
t.Fatalf("handlers = %d, want system + continuation + trace", len(handlers))
}
}
@@ -0,0 +1,106 @@
package multiagent
import (
"io"
"github.com/cloudwego/eino/adk"
"github.com/cloudwego/eino/schema"
)
// adaptAgenticEventToEinoEvents converts typed AgenticMessage ADK events into
// the classic schema.Message events consumed by the existing SSE/MCP drain.
func adaptAgenticEventToEinoEvents(ev *adk.TypedAgentEvent[*schema.AgenticMessage]) []*adk.AgentEvent {
if ev == nil {
return nil
}
base := func(output *adk.AgentOutput) *adk.AgentEvent {
return &adk.AgentEvent{
AgentName: ev.AgentName,
RunPath: append([]adk.RunStep(nil), ev.RunPath...),
Output: output,
Action: ev.Action,
Err: ev.Err,
}
}
if ev.Output == nil {
return []*adk.AgentEvent{base(nil)}
}
customized := ev.Output.CustomizedOutput
mv := ev.Output.MessageOutput
if mv == nil {
return []*adk.AgentEvent{base(&adk.AgentOutput{CustomizedOutput: customized})}
}
if mv.IsStreaming {
return []*adk.AgentEvent{base(&adk.AgentOutput{
MessageOutput: &adk.MessageVariant{
IsStreaming: true,
MessageStream: agenticStreamToEinoStream(mv.MessageStream),
Role: agenticVariantRole(mv),
},
CustomizedOutput: customized,
})}
}
msgs := AgenticMessageToEino(mv.Message)
if len(msgs) == 0 {
return []*adk.AgentEvent{base(&adk.AgentOutput{CustomizedOutput: customized})}
}
out := make([]*adk.AgentEvent, 0, len(msgs))
for i, msg := range msgs {
eventCustomized := any(nil)
if i == 0 {
eventCustomized = customized
}
out = append(out, base(&adk.AgentOutput{
MessageOutput: &adk.MessageVariant{
Message: msg,
Role: msg.Role,
ToolName: msg.ToolName,
},
CustomizedOutput: eventCustomized,
}))
}
return out
}
func agenticStreamToEinoStream(sr *schema.StreamReader[*schema.AgenticMessage]) *schema.StreamReader[*schema.Message] {
out, writer := schema.Pipe[*schema.Message](8)
go func() {
defer writer.Close()
if sr == nil {
return
}
defer sr.Close()
for {
chunk, err := sr.Recv()
if err != nil {
if err != io.EOF {
writer.Send(nil, err)
}
return
}
for _, msg := range AgenticMessageToEino(chunk) {
if msg != nil && writer.Send(msg, nil) {
return
}
}
}
}()
return out
}
func agenticVariantRole(mv *adk.TypedMessageVariant[*schema.AgenticMessage]) schema.RoleType {
if mv == nil {
return schema.Assistant
}
switch mv.AgenticRole {
case schema.AgenticRoleTypeSystem:
return schema.System
case schema.AgenticRoleTypeUser:
// In Agentic ReAct output, user-role events from the graph are local
// FunctionToolResult messages emitted by AgenticToolsNode.
return schema.Tool
default:
return schema.Assistant
}
}
@@ -0,0 +1,249 @@
package multiagent
import (
"errors"
"io"
"testing"
"github.com/cloudwego/eino/adk"
"github.com/cloudwego/eino/schema"
)
func TestAdaptAgenticEventToEinoEventsAssistantMessage(t *testing.T) {
usage := &schema.TokenUsage{PromptTokens: 10, CompletionTokens: 5, TotalTokens: 15}
ev := &adk.TypedAgentEvent[*schema.AgenticMessage]{
AgentName: "agentic",
Output: &adk.TypedAgentOutput[*schema.AgenticMessage]{
MessageOutput: &adk.TypedMessageVariant[*schema.AgenticMessage]{
Message: &schema.AgenticMessage{
Role: schema.AgenticRoleTypeAssistant,
ResponseMeta: &schema.AgenticResponseMeta{TokenUsage: usage},
ContentBlocks: []*schema.ContentBlock{
schema.NewContentBlock(&schema.Reasoning{Text: "think"}),
schema.NewContentBlock(&schema.AssistantGenText{Text: "calling"}),
schema.NewContentBlock(&schema.FunctionToolCall{CallID: "call-1", Name: "scan", Arguments: `{"host":"127.0.0.1"}`}),
},
},
},
CustomizedOutput: "custom",
},
}
got := adaptAgenticEventToEinoEvents(ev)
if len(got) != 1 {
t.Fatalf("events = %d, want 1", len(got))
}
mv := got[0].Output.MessageOutput
if got[0].AgentName != "agentic" || got[0].Output.CustomizedOutput != "custom" {
t.Fatalf("event metadata = %#v", got[0])
}
if mv.Role != schema.Assistant || mv.Message.Role != schema.Assistant {
t.Fatalf("role = %q/%q, want assistant", mv.Role, mv.Message.Role)
}
if mv.Message.Content != "calling" || mv.Message.ReasoningContent != "think" {
t.Fatalf("message text = %#v", mv.Message)
}
if len(mv.Message.ToolCalls) != 1 || mv.Message.ToolCalls[0].ID != "call-1" || mv.Message.ToolCalls[0].Function.Name != "scan" {
t.Fatalf("tool calls = %#v", mv.Message.ToolCalls)
}
if mv.Message.ResponseMeta == nil || mv.Message.ResponseMeta.Usage != usage {
t.Fatalf("usage = %#v, want original usage", mv.Message.ResponseMeta)
}
}
func TestAdaptAgenticEventToEinoEventsPureToolResult(t *testing.T) {
ev := &adk.TypedAgentEvent[*schema.AgenticMessage]{
Output: &adk.TypedAgentOutput[*schema.AgenticMessage]{
MessageOutput: &adk.TypedMessageVariant[*schema.AgenticMessage]{
Message: &schema.AgenticMessage{
Role: schema.AgenticRoleTypeUser,
ContentBlocks: []*schema.ContentBlock{
schema.NewContentBlock(&schema.FunctionToolResult{
CallID: "call-2",
Name: "execute",
Content: []*schema.FunctionToolResultContentBlock{{
Type: schema.FunctionToolResultContentBlockTypeText,
Text: &schema.UserInputText{Text: "done"},
}},
}),
},
},
},
},
}
got := adaptAgenticEventToEinoEvents(ev)
if len(got) != 1 {
t.Fatalf("events = %d, want 1", len(got))
}
msg := got[0].Output.MessageOutput.Message
if got[0].Output.MessageOutput.Role != schema.Tool || msg.Role != schema.Tool || msg.ToolName != "execute" || msg.ToolCallID != "call-2" || msg.Content != "done" {
t.Fatalf("tool event = %#v message=%#v", got[0].Output.MessageOutput, msg)
}
}
func TestAdaptAgenticEventToEinoEventsSplitsMixedToolResult(t *testing.T) {
ev := &adk.TypedAgentEvent[*schema.AgenticMessage]{
Output: &adk.TypedAgentOutput[*schema.AgenticMessage]{
MessageOutput: &adk.TypedMessageVariant[*schema.AgenticMessage]{
Message: &schema.AgenticMessage{
Role: schema.AgenticRoleTypeAssistant,
ContentBlocks: []*schema.ContentBlock{
schema.NewContentBlock(&schema.AssistantGenText{Text: "text"}),
schema.NewContentBlock(&schema.FunctionToolResult{
CallID: "call-3",
Name: "grep",
Content: []*schema.FunctionToolResultContentBlock{{
Type: schema.FunctionToolResultContentBlockTypeText,
Text: &schema.UserInputText{Text: "match"},
}},
}),
},
},
},
},
}
got := adaptAgenticEventToEinoEvents(ev)
if len(got) != 2 {
t.Fatalf("events = %d, want assistant + tool", len(got))
}
if got[0].Output.MessageOutput.Role != schema.Assistant || got[0].Output.MessageOutput.Message.Content != "text" {
t.Fatalf("assistant event = %#v", got[0].Output.MessageOutput)
}
if got[1].Output.MessageOutput.Role != schema.Tool || got[1].Output.MessageOutput.Message.ToolName != "grep" {
t.Fatalf("tool event = %#v", got[1].Output.MessageOutput)
}
}
func TestAdaptAgenticEventToEinoEventsStreamingAssistant(t *testing.T) {
stream := schema.StreamReaderFromArray([]*schema.AgenticMessage{
{
Role: schema.AgenticRoleTypeAssistant,
ContentBlocks: []*schema.ContentBlock{
schema.NewContentBlock(&schema.AssistantGenText{Text: "hel"}),
},
},
{
Role: schema.AgenticRoleTypeAssistant,
ContentBlocks: []*schema.ContentBlock{
schema.NewContentBlock(&schema.AssistantGenText{Text: "lo"}),
},
},
})
ev := &adk.TypedAgentEvent[*schema.AgenticMessage]{
Output: &adk.TypedAgentOutput[*schema.AgenticMessage]{
MessageOutput: &adk.TypedMessageVariant[*schema.AgenticMessage]{
IsStreaming: true,
MessageStream: stream,
AgenticRole: schema.AgenticRoleTypeAssistant,
},
},
}
got := adaptAgenticEventToEinoEvents(ev)
if len(got) != 1 {
t.Fatalf("events = %d, want 1", len(got))
}
mv := got[0].Output.MessageOutput
if !mv.IsStreaming || mv.Role != schema.Assistant {
t.Fatalf("stream variant = %#v", mv)
}
first, err := mv.MessageStream.Recv()
if err != nil || first.Content != "hel" {
t.Fatalf("first = %#v err=%v", first, err)
}
second, err := mv.MessageStream.Recv()
if err != nil || second.Content != "lo" {
t.Fatalf("second = %#v err=%v", second, err)
}
_, err = mv.MessageStream.Recv()
if !errors.Is(err, io.EOF) {
t.Fatalf("final err = %v, want EOF", err)
}
}
func TestAdaptAgenticStreamingToolResultFeedsClassicToolResultHandler(t *testing.T) {
stream := schema.StreamReaderFromArray([]*schema.AgenticMessage{
{
Role: schema.AgenticRoleTypeUser,
ContentBlocks: []*schema.ContentBlock{
schema.NewContentBlock(&schema.FunctionToolResult{
CallID: "call-agentic-stream",
Name: "execute",
Content: []*schema.FunctionToolResultContentBlock{{
Type: schema.FunctionToolResultContentBlockTypeText,
Text: &schema.UserInputText{Text: "partial "},
}},
}),
},
},
{
Role: schema.AgenticRoleTypeUser,
ContentBlocks: []*schema.ContentBlock{
schema.NewContentBlock(&schema.FunctionToolResult{
CallID: "call-agentic-stream",
Name: "execute",
Content: []*schema.FunctionToolResultContentBlock{{
Type: schema.FunctionToolResultContentBlockTypeText,
Text: &schema.UserInputText{Text: "done"},
}},
}),
},
},
})
ev := &adk.TypedAgentEvent[*schema.AgenticMessage]{
AgentName: "agentic",
Output: &adk.TypedAgentOutput[*schema.AgenticMessage]{
MessageOutput: &adk.TypedMessageVariant[*schema.AgenticMessage]{
IsStreaming: true,
MessageStream: stream,
AgenticRole: schema.AgenticRoleTypeUser,
},
},
}
got := adaptAgenticEventToEinoEvents(ev)
if len(got) != 1 || got[0].Output == nil || got[0].Output.MessageOutput == nil {
t.Fatalf("events = %#v", got)
}
mv := got[0].Output.MessageOutput
if !mv.IsStreaming || mv.Role != schema.Tool {
t.Fatalf("streaming variant = %#v, want tool stream", mv)
}
var event map[string]interface{}
runMessages := newEinoRunMessageAccumulator(nil)
emitter := newEinoToolResultProgressEmitter(einoToolResultProgressEmitterConfig{
ConversationID: "conv-agentic",
Progress: func(eventType, _ string, data interface{}) {
if eventType == "tool_result" {
event, _ = data.(map[string]interface{})
}
},
})
handler := newEinoToolResultEventHandler(einoToolResultEventHandlerConfig{
RunMessages: runMessages,
Emitter: emitter,
})
if !handler.HandleStreaming(mv, "agentic") {
t.Fatal("agentic streaming tool result was not handled")
}
if event["toolName"] != "execute" || event["toolCallId"] != "call-agentic-stream" || event["result"] != "partial done" {
t.Fatalf("tool result event = %#v", event)
}
msgs := runMessages.Messages()
if len(msgs) != 1 || msgs[0].ToolName != "execute" || msgs[0].ToolCallID != "call-agentic-stream" || msgs[0].Content != "partial done" {
t.Fatalf("run messages = %#v", msgs)
}
}
func TestAdaptAgenticEventToEinoEventsPreservesErrorOnlyEvent(t *testing.T) {
wantErr := errors.New("boom")
ev := &adk.TypedAgentEvent[*schema.AgenticMessage]{AgentName: "agentic", Err: wantErr}
got := adaptAgenticEventToEinoEvents(ev)
if len(got) != 1 || got[0].AgentName != "agentic" || !errors.Is(got[0].Err, wantErr) {
t.Fatalf("events = %#v", got)
}
}
+184
View File
@@ -0,0 +1,184 @@
package multiagent
import (
"strings"
"github.com/cloudwego/eino/schema"
)
// EinoMessagesToAgentic converts the project's current ADK message history to
// Eino's native AgenticMessage shape. It intentionally covers the text,
// reasoning, function tool-call, and function tool-result channels used by the
// agent runtime today; unsupported multimodal/provider-specific fields stay in
// schema.Message until a real AgenticModel backend is wired.
func EinoMessagesToAgentic(msgs []*schema.Message) []*schema.AgenticMessage {
if len(msgs) == 0 {
return nil
}
out := make([]*schema.AgenticMessage, 0, len(msgs))
for _, msg := range msgs {
if msg == nil {
continue
}
out = append(out, EinoMessageToAgentic(msg))
}
return out
}
func EinoMessageToAgentic(msg *schema.Message) *schema.AgenticMessage {
if msg == nil {
return nil
}
out := &schema.AgenticMessage{
Role: messageRoleToAgentic(msg.Role),
Extra: cloneAnyMap(msg.Extra),
}
if msg.ResponseMeta != nil {
out.ResponseMeta = &schema.AgenticResponseMeta{TokenUsage: msg.ResponseMeta.Usage}
}
if text := strings.TrimSpace(msg.ReasoningContent); text != "" {
out.ContentBlocks = append(out.ContentBlocks, schema.NewContentBlock(&schema.Reasoning{Text: msg.ReasoningContent}))
}
switch msg.Role {
case schema.Assistant:
if msg.Content != "" {
out.ContentBlocks = append(out.ContentBlocks, schema.NewContentBlock(&schema.AssistantGenText{Text: msg.Content}))
}
for _, tc := range msg.ToolCalls {
out.ContentBlocks = append(out.ContentBlocks, schema.NewContentBlock(&schema.FunctionToolCall{
CallID: tc.ID,
Name: tc.Function.Name,
Arguments: tc.Function.Arguments,
}))
}
case schema.Tool:
out.Role = schema.AgenticRoleTypeUser
out.ContentBlocks = append(out.ContentBlocks, schema.NewContentBlock(&schema.FunctionToolResult{
CallID: msg.ToolCallID,
Name: msg.ToolName,
Content: []*schema.FunctionToolResultContentBlock{{
Type: schema.FunctionToolResultContentBlockTypeText,
Text: &schema.UserInputText{Text: msg.Content},
}},
}))
default:
if msg.Content != "" {
out.ContentBlocks = append(out.ContentBlocks, schema.NewContentBlock(&schema.UserInputText{Text: msg.Content}))
}
}
return out
}
// AgenticMessagesToEino converts AgenticMessage values back into the classic
// schema.Message form used by the existing ADK event drain and persistence code.
func AgenticMessagesToEino(msgs []*schema.AgenticMessage) []*schema.Message {
if len(msgs) == 0 {
return nil
}
out := make([]*schema.Message, 0, len(msgs))
for _, msg := range msgs {
if msg == nil {
continue
}
out = append(out, AgenticMessageToEino(msg)...)
}
return out
}
func AgenticMessageToEino(msg *schema.AgenticMessage) []*schema.Message {
if msg == nil {
return nil
}
base := &schema.Message{
Role: agenticRoleToMessage(msg.Role),
Extra: cloneAnyMap(msg.Extra),
}
if msg.ResponseMeta != nil {
base.ResponseMeta = &schema.ResponseMeta{Usage: msg.ResponseMeta.TokenUsage}
}
var toolResults []*schema.Message
for _, block := range msg.ContentBlocks {
if block == nil {
continue
}
switch {
case block.Reasoning != nil:
base.ReasoningContent += block.Reasoning.Text
case block.UserInputText != nil:
base.Content += block.UserInputText.Text
case block.AssistantGenText != nil:
base.Role = schema.Assistant
base.Content += block.AssistantGenText.Text
case block.FunctionToolCall != nil:
base.Role = schema.Assistant
base.ToolCalls = append(base.ToolCalls, schema.ToolCall{
ID: block.FunctionToolCall.CallID,
Type: "function",
Function: schema.FunctionCall{
Name: block.FunctionToolCall.Name,
Arguments: block.FunctionToolCall.Arguments,
},
})
case block.FunctionToolResult != nil:
toolResults = append(toolResults, functionToolResultToMessage(block.FunctionToolResult))
}
}
if len(toolResults) > 0 && base.Content == "" && base.ReasoningContent == "" && len(base.ToolCalls) == 0 {
return toolResults
}
out := []*schema.Message{base}
out = append(out, toolResults...)
return out
}
func messageRoleToAgentic(role schema.RoleType) schema.AgenticRoleType {
switch role {
case schema.System:
return schema.AgenticRoleTypeSystem
case schema.Assistant:
return schema.AgenticRoleTypeAssistant
default:
return schema.AgenticRoleTypeUser
}
}
func agenticRoleToMessage(role schema.AgenticRoleType) schema.RoleType {
switch role {
case schema.AgenticRoleTypeSystem:
return schema.System
case schema.AgenticRoleTypeAssistant:
return schema.Assistant
default:
return schema.User
}
}
func functionToolResultToMessage(result *schema.FunctionToolResult) *schema.Message {
if result == nil {
return nil
}
parts := make([]string, 0, len(result.Content))
for _, block := range result.Content {
if block == nil || block.Text == nil {
continue
}
parts = append(parts, block.Text.Text)
}
return &schema.Message{
Role: schema.Tool,
Content: strings.Join(parts, ""),
ToolCallID: result.CallID,
ToolName: result.Name,
}
}
func cloneAnyMap(in map[string]any) map[string]any {
if len(in) == 0 {
return nil
}
out := make(map[string]any, len(in))
for k, v := range in {
out[k] = v
}
return out
}
@@ -0,0 +1,154 @@
package multiagent
import (
"testing"
"github.com/cloudwego/eino/schema"
)
func TestEinoMessageToAgenticPreservesAssistantToolCalls(t *testing.T) {
msg := &schema.Message{
Role: schema.Assistant,
Content: "I will scan it.",
ReasoningContent: "Need enumerate first.",
ToolCalls: []schema.ToolCall{{
ID: "call-1",
Type: "function",
Function: schema.FunctionCall{
Name: "nmap",
Arguments: `{"target":"127.0.0.1"}`,
},
}},
Extra: map[string]any{"trace": "kept"},
}
got := EinoMessageToAgentic(msg)
if got.Role != schema.AgenticRoleTypeAssistant {
t.Fatalf("role = %q, want assistant", got.Role)
}
if len(got.ContentBlocks) != 3 {
t.Fatalf("blocks = %d, want 3", len(got.ContentBlocks))
}
if got.ContentBlocks[0].Reasoning == nil || got.ContentBlocks[0].Reasoning.Text != msg.ReasoningContent {
t.Fatalf("reasoning block = %#v", got.ContentBlocks[0])
}
if got.ContentBlocks[1].AssistantGenText == nil || got.ContentBlocks[1].AssistantGenText.Text != msg.Content {
t.Fatalf("assistant text block = %#v", got.ContentBlocks[1])
}
call := got.ContentBlocks[2].FunctionToolCall
if call == nil || call.CallID != "call-1" || call.Name != "nmap" || call.Arguments != `{"target":"127.0.0.1"}` {
t.Fatalf("tool call block = %#v", got.ContentBlocks[2])
}
if got.Extra["trace"] != "kept" {
t.Fatalf("extra = %#v", got.Extra)
}
}
func TestEinoMessageToAgenticMapsToolResultAsUserFunctionResult(t *testing.T) {
msg := &schema.Message{
Role: schema.Tool,
Content: "22/tcp open ssh",
ToolCallID: "call-ssh",
ToolName: "nmap",
}
got := EinoMessageToAgentic(msg)
if got.Role != schema.AgenticRoleTypeUser {
t.Fatalf("role = %q, want user", got.Role)
}
if len(got.ContentBlocks) != 1 || got.ContentBlocks[0].FunctionToolResult == nil {
t.Fatalf("blocks = %#v", got.ContentBlocks)
}
result := got.ContentBlocks[0].FunctionToolResult
if result.CallID != "call-ssh" || result.Name != "nmap" {
t.Fatalf("tool result metadata = %#v", result)
}
if len(result.Content) != 1 || result.Content[0].Text == nil || result.Content[0].Text.Text != "22/tcp open ssh" {
t.Fatalf("tool result content = %#v", result.Content)
}
}
func TestAgenticMessageToEinoPreservesAssistantBlocks(t *testing.T) {
msg := &schema.AgenticMessage{
Role: schema.AgenticRoleTypeAssistant,
ContentBlocks: []*schema.ContentBlock{
schema.NewContentBlock(&schema.Reasoning{Text: "Think first."}),
schema.NewContentBlock(&schema.AssistantGenText{Text: "Calling scanner."}),
schema.NewContentBlock(&schema.FunctionToolCall{
CallID: "call-2",
Name: "scan",
Arguments: `{"host":"example.com"}`,
}),
},
}
got := AgenticMessageToEino(msg)
if len(got) != 1 {
t.Fatalf("messages = %d, want 1", len(got))
}
if got[0].Role != schema.Assistant || got[0].Content != "Calling scanner." || got[0].ReasoningContent != "Think first." {
t.Fatalf("assistant message = %#v", got[0])
}
if len(got[0].ToolCalls) != 1 || got[0].ToolCalls[0].ID != "call-2" || got[0].ToolCalls[0].Function.Name != "scan" {
t.Fatalf("tool calls = %#v", got[0].ToolCalls)
}
}
func TestAgenticMessageToEinoSplitsPureToolResult(t *testing.T) {
msg := &schema.AgenticMessage{
Role: schema.AgenticRoleTypeUser,
ContentBlocks: []*schema.ContentBlock{
schema.NewContentBlock(&schema.FunctionToolResult{
CallID: "call-3",
Name: "execute",
Content: []*schema.FunctionToolResultContentBlock{{
Type: schema.FunctionToolResultContentBlockTypeText,
Text: &schema.UserInputText{Text: "done"},
}},
}),
},
}
got := AgenticMessageToEino(msg)
if len(got) != 1 {
t.Fatalf("messages = %d, want 1", len(got))
}
if got[0].Role != schema.Tool || got[0].ToolCallID != "call-3" || got[0].ToolName != "execute" || got[0].Content != "done" {
t.Fatalf("tool message = %#v", got[0])
}
}
func TestEinoAgenticRoundTripForSupportedFields(t *testing.T) {
msgs := []*schema.Message{
schema.SystemMessage("system"),
schema.UserMessage("user"),
{
Role: schema.Assistant,
Content: "assistant",
ToolCalls: []schema.ToolCall{{
ID: "call-4",
Type: "function",
Function: schema.FunctionCall{Name: "grep", Arguments: `{"q":"token"}`},
}},
},
{
Role: schema.Tool,
Content: "match",
ToolCallID: "call-4",
ToolName: "grep",
},
}
got := AgenticMessagesToEino(EinoMessagesToAgentic(msgs))
if len(got) != len(msgs) {
t.Fatalf("round trip messages = %d, want %d: %#v", len(got), len(msgs), got)
}
for i := range msgs {
if got[i].Role != msgs[i].Role || got[i].Content != msgs[i].Content || got[i].ToolCallID != msgs[i].ToolCallID || got[i].ToolName != msgs[i].ToolName {
t.Fatalf("message[%d] = %#v, want %#v", i, got[i], msgs[i])
}
if len(got[i].ToolCalls) != len(msgs[i].ToolCalls) {
t.Fatalf("message[%d] tool calls = %#v, want %#v", i, got[i].ToolCalls, msgs[i].ToolCalls)
}
}
}
@@ -0,0 +1,109 @@
package multiagent
import (
"context"
"strings"
"github.com/cloudwego/eino/components/model"
"go.uber.org/zap"
)
type einoAgenticModelFactory func(context.Context) (model.AgenticModel, error)
type einoAgenticRuntimeSupport struct {
TypedRunner bool
Streaming bool
CancelMonitoring bool
ModelRetry bool
ModelFailover bool
ToolResultObservation bool
MCPExecutionAudit bool
}
type einoAgenticModelGate struct {
Ready bool
Reason string
Missing []string
}
// Eino v0.9.14 wires AgenticMessage through the same generic TypedRunner,
// stream cancel monitoring, model retry, and model failover wrappers used by
// schema.Message. Keep this matrix explicit so future upgrades are audited
// deliberately instead of flipping the AgenticModel path by accident.
func einoAgenticRuntimeSupportV0914() einoAgenticRuntimeSupport {
return einoAgenticRuntimeSupport{
TypedRunner: true,
Streaming: true,
CancelMonitoring: true,
ModelRetry: true,
ModelFailover: true,
ToolResultObservation: true,
MCPExecutionAudit: true,
}
}
func evaluateEinoAgenticModelGate(factory einoAgenticModelFactory, support einoAgenticRuntimeSupport) einoAgenticModelGate {
missing := make([]string, 0, 8)
if factory == nil {
missing = append(missing, "model.AgenticModel backend")
} else {
if m, err := factory(context.Background()); err != nil || m == nil {
missing = append(missing, "model.AgenticModel backend")
}
}
if !support.TypedRunner {
missing = append(missing, "adk.TypedRunner[*schema.AgenticMessage]")
}
if !support.Streaming {
missing = append(missing, "AgenticMessage streaming")
}
if !support.CancelMonitoring {
missing = append(missing, "AgenticMessage model-stream cancel monitoring")
}
if !support.ModelRetry {
missing = append(missing, "AgenticMessage ModelRetry")
}
if !support.ModelFailover {
missing = append(missing, "AgenticMessage ModelFailover")
}
if !support.ToolResultObservation {
missing = append(missing, "AgenticMessage tool-result observation")
}
if !support.MCPExecutionAudit {
missing = append(missing, "AgenticMessage MCP execution audit")
}
if len(missing) == 0 {
return einoAgenticModelGate{Ready: true, Reason: "ready"}
}
return einoAgenticModelGate{
Reason: "agentic_model_not_ready: " + strings.Join(missing, ", "),
Missing: missing,
}
}
func logEinoAgenticModelGate(logger *zap.Logger, scope, orchestration string, gate einoAgenticModelGate) {
if logger == nil {
return
}
fields := []zap.Field{
zap.String("scope", scope),
zap.String("orchestration", orchestration),
zap.Bool("ready", gate.Ready),
zap.String("reason", gate.Reason),
zap.Strings("missing", gate.Missing),
}
if gate.Ready {
logger.Info("eino agentic model gate ready", fields...)
return
}
logger.Info("eino agentic model gate disabled", fields...)
}
func agenticTextModelFactory(m model.AgenticModel) einoAgenticModelFactory {
if m == nil {
return nil
}
return func(context.Context) (model.AgenticModel, error) {
return m, nil
}
}
@@ -0,0 +1,93 @@
package multiagent
import (
"context"
"errors"
"testing"
"github.com/cloudwego/eino/components/model"
"github.com/cloudwego/eino/schema"
)
type fakeAgenticGateModel struct{}
func (m *fakeAgenticGateModel) Generate(context.Context, []*schema.AgenticMessage, ...model.Option) (*schema.AgenticMessage, error) {
return &schema.AgenticMessage{Role: schema.AgenticRoleTypeAssistant}, nil
}
func (m *fakeAgenticGateModel) Stream(context.Context, []*schema.AgenticMessage, ...model.Option) (*schema.StreamReader[*schema.AgenticMessage], error) {
return schema.StreamReaderFromArray([]*schema.AgenticMessage{{Role: schema.AgenticRoleTypeAssistant}}), nil
}
func TestEinoAgenticModelGateV0914WaitsOnlyForBackend(t *testing.T) {
gate := evaluateEinoAgenticModelGate(nil, einoAgenticRuntimeSupportV0914())
if gate.Ready {
t.Fatal("v0.9.14 gate should stay disabled without an AgenticModel backend")
}
if !containsString(gate.Missing, "model.AgenticModel backend") {
t.Fatalf("missing = %#v, want backend reason", gate.Missing)
}
for _, unexpected := range []string{
"AgenticMessage model-stream cancel monitoring",
"AgenticMessage ModelRetry",
"AgenticMessage ModelFailover",
"AgenticMessage tool-result observation",
"AgenticMessage MCP execution audit",
} {
if containsString(gate.Missing, unexpected) {
t.Fatalf("missing = %#v, should not include %q for v0.9.14 runtime support", gate.Missing, unexpected)
}
}
}
func TestEinoAgenticModelGateV0914ReadyWithBackend(t *testing.T) {
gate := evaluateEinoAgenticModelGate(agenticTextModelFactory(&fakeAgenticGateModel{}), einoAgenticRuntimeSupportV0914())
if !gate.Ready {
t.Fatalf("gate = %#v, want ready when v0.9.14 runtime support has a backend", gate)
}
if gate.Reason != "ready" || len(gate.Missing) != 0 {
t.Fatalf("gate details = %#v", gate)
}
}
func TestEinoAgenticModelGateReadyWhenBackendAndRuntimeParityExist(t *testing.T) {
gate := evaluateEinoAgenticModelGate(agenticTextModelFactory(&fakeAgenticGateModel{}), einoAgenticRuntimeSupport{
TypedRunner: true,
Streaming: true,
CancelMonitoring: true,
ModelRetry: true,
ModelFailover: true,
ToolResultObservation: true,
MCPExecutionAudit: true,
})
if !gate.Ready {
t.Fatalf("gate = %#v, want ready", gate)
}
if gate.Reason != "ready" || len(gate.Missing) != 0 {
t.Fatalf("gate details = %#v", gate)
}
}
func TestEinoAgenticModelGateTreatsFactoryErrorAsMissingBackend(t *testing.T) {
gate := evaluateEinoAgenticModelGate(func(context.Context) (model.AgenticModel, error) {
return nil, errors.New("not implemented")
}, einoAgenticRuntimeSupport{
TypedRunner: true,
Streaming: true,
CancelMonitoring: true,
ModelRetry: true,
ModelFailover: true,
ToolResultObservation: true,
MCPExecutionAudit: true,
})
if gate.Ready {
t.Fatal("factory error should disable gate")
}
if !containsString(gate.Missing, "model.AgenticModel backend") {
t.Fatalf("missing = %#v, want backend reason", gate.Missing)
}
}
@@ -0,0 +1,278 @@
package multiagent
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"cyberstrike-ai/internal/config"
"cyberstrike-ai/internal/database"
einoopenai "github.com/cloudwego/eino-ext/components/model/openai"
"github.com/cloudwego/eino/adk"
"github.com/cloudwego/eino/adk/middlewares/summarization"
"github.com/cloudwego/eino/components/model"
"github.com/cloudwego/eino/schema"
"go.uber.org/zap"
)
// newEinoAgenticSummarizationMiddleware wires the project's domain-specific
// compaction policy into Eino's native typed AgenticMessage summarization.
func newEinoAgenticSummarizationMiddleware(
ctx context.Context,
summaryModel model.BaseModel[*schema.AgenticMessage],
appCfg *config.Config,
mwCfg *config.MultiAgentEinoMiddlewareConfig,
conversationID string,
db *database.DB,
projectID string,
logger *zap.Logger,
) (adk.TypedChatModelAgentMiddleware[*schema.AgenticMessage], error) {
if summaryModel == nil || appCfg == nil {
return nil, fmt.Errorf("multiagent: agentic summarization 需要 model 与配置")
}
maxTotal := appCfg.OpenAI.MaxTotalTokens
if maxTotal <= 0 {
maxTotal = 120000
}
triggerRatio := 0.8
emitInternalEvents := true
outputReserve := config.DefaultSummarizationOutputReserveTokens
userLedgerMaxRunes := config.DefaultSummarizationUserIntentLedgerMaxRunes
userLedgerEntryMaxRunes := config.DefaultSummarizationUserIntentLedgerEntryMaxRunes
toolMaxBytes := config.MultiAgentEinoMiddlewareConfig{}.ReductionMaxLengthForTruncEffective()
if mwCfg != nil {
triggerRatio = mwCfg.SummarizationTriggerRatioEffective()
emitInternalEvents = mwCfg.SummarizationEmitInternalEventsEffective()
outputReserve = mwCfg.SummarizationOutputReserveTokensEffective()
userLedgerMaxRunes = mwCfg.SummarizationUserIntentLedgerMaxRunesEffective()
userLedgerEntryMaxRunes = mwCfg.SummarizationUserIntentLedgerEntryMaxRunesEffective()
toolMaxBytes = mwCfg.ReductionMaxLengthForTruncEffective()
}
ledgerWindowCap := modelFacingRuneBudget(maxTotal, 0.20)
userLedgerMaxRunes = minPositiveInt(userLedgerMaxRunes, ledgerWindowCap)
userLedgerEntryMaxRunes = minPositiveInt(userLedgerEntryMaxRunes, userLedgerMaxRunes)
trigger := int(float64(maxTotal) * triggerRatio)
if trigger < 4096 {
trigger = maxTotal
if trigger < 4096 {
trigger = 4096
}
}
modelName := strings.TrimSpace(appCfg.OpenAI.Model)
if modelName == "" {
modelName = "gpt-4o"
}
classicTokenCounter := einoSummarizationTokenCounter(modelName)
agenticTokenCounter := func(ctx context.Context, input *summarization.TypedTokenCounterInput[*schema.AgenticMessage]) (int, error) {
if input == nil {
return 0, nil
}
return classicTokenCounter(ctx, &summarization.TokenCounterInput{
Messages: AgenticMessagesToEino(input.Messages),
Tools: input.Tools,
})
}
recentTrailMax := trigger / 4
if recentTrailMax < 2048 {
recentTrailMax = 2048
}
if recentTrailMax > trigger/2 {
recentTrailMax = trigger / 2
}
summaryInputMax := trigger - outputReserve
if summaryInputMax < 4096 {
summaryInputMax = trigger * 80 / 100
}
if summaryInputMax < 4096 {
summaryInputMax = 4096
}
transcriptPath := ""
if conv := strings.TrimSpace(conversationID); conv != "" {
baseRoot := filepath.Join(os.TempDir(), "cyberstrike-summarization")
if dbPath := strings.TrimSpace(appCfg.Database.Path); dbPath != "" {
baseRoot = filepath.Join(filepath.Dir(dbPath), "conversation_artifacts", sanitizeEinoPathSegment(conv), "summarization")
}
base := baseRoot
if abs, err := filepath.Abs(base); err == nil {
base = abs
}
if mkErr := os.MkdirAll(base, 0o755); mkErr == nil {
transcriptPath = filepath.Join(base, "transcript.txt")
}
}
retryPolicy := einoTransientRunRetryPolicyFromMW(mwCfg)
retryMax := retryPolicy.maxAttempts
var summaryOverflowRetries int
summaryModelOpts := []model.Option{
einoopenai.WithMaxCompletionTokens(outputReserve),
}
mw, err := summarization.NewTyped[*schema.AgenticMessage](ctx, &summarization.TypedConfig[*schema.AgenticMessage]{
Model: summaryModel,
ModelOptions: summaryModelOpts,
GenModelInput: func(ctx context.Context, sysInstruction, userInstruction *schema.AgenticMessage, originalMsgs []*schema.AgenticMessage) ([]*schema.AgenticMessage, error) {
classicOriginal := AgenticMessagesToEino(originalMsgs)
if transcriptPath != "" && len(classicOriginal) > 0 {
if werr := writeSummarizationTranscript(transcriptPath, classicOriginal); werr != nil && logger != nil {
logger.Warn("eino agentic summarization transcript preflight 写入失败",
zap.String("path", transcriptPath), zap.Error(werr))
}
}
budget := summaryInputMax
aggressive := summaryOverflowRetries > 0
if aggressive {
budget = summaryInputMax * 70 / 100
if budget < 4096 {
budget = 4096
}
}
input, dropped, berr := buildBudgetedSummarizationModelInput(
ctx,
agenticInstructionToClassic(sysInstruction, schema.System),
agenticInstructionToClassic(userInstruction, schema.User),
classicOriginal,
classicTokenCounter,
budget,
summarizationInputBudgetOpts{
toolMaxBytes: toolMaxBytes,
spillRef: transcriptPath,
aggressive: aggressive,
},
)
if logger != nil && (berr != nil || dropped > 0 || aggressive) {
fields := []zap.Field{
zap.Int("max_input_tokens", budget),
zap.Int("trigger_context_tokens", trigger),
zap.Int("output_reserve_tokens", outputReserve),
zap.Int("dropped_rounds", dropped),
zap.Bool("aggressive", aggressive),
}
if berr != nil {
fields = append(fields, zap.Error(berr))
logger.Warn("eino agentic summarization input budget failed", fields...)
} else {
logger.Info("eino agentic summarization input bounded", fields...)
}
}
return EinoMessagesToAgentic(input), berr
},
Trigger: &summarization.TriggerCondition{
ContextTokens: trigger,
},
TokenCounter: agenticTokenCounter,
UserInstruction: einoSummarizeUserInstruction,
EmitInternalEvents: emitInternalEvents,
TranscriptFilePath: transcriptPath,
Retry: &summarization.TypedRetryConfig[*schema.AgenticMessage]{
MaxRetries: &retryMax,
ShouldRetry: func(_ context.Context, _ *schema.AgenticMessage, err error) bool {
if isEinoContextOverflowError(err) && summaryOverflowRetries < 1 {
summaryOverflowRetries++
if logger != nil {
logger.Warn("eino agentic summarization context overflow, retrying with aggressive compaction",
zap.Error(err),
)
}
return true
}
retry := isEinoTransientRunError(err)
if retry && logger != nil {
logger.Warn("eino agentic summarization generate transient error, will retry if attempts remain",
zap.Error(err),
zap.Int("max_retries", retryMax),
)
}
return retry
},
},
Finalize: func(ctx context.Context, originalMessages []*schema.AgenticMessage, summary *schema.AgenticMessage) ([]*schema.AgenticMessage, error) {
classicOriginal := AgenticMessagesToEino(originalMessages)
classicSummary := agenticSummaryToClassicMessage(summary)
if classicSummary == nil {
return nil, fmt.Errorf("agentic summarization returned empty summary")
}
compactionMessages := stripOriginalUserIntentLedgerFromMessages(classicOriginal)
defaultFinalized, derr := summarization.DefaultFinalize(ctx, compactionMessages, classicSummary)
if derr != nil {
return nil, derr
}
if len(defaultFinalized) == 0 {
return nil, fmt.Errorf("agentic summarization default finalize returned no messages")
}
summaryMsg := appendTranscriptPathToSummarizationMessage(defaultFinalized[len(defaultFinalized)-1], transcriptPath)
summaryMsg = stripAnalysisFromSummarizationMessage(summaryMsg)
userLedger := buildOriginalUserIntentLedgerMessage(classicOriginal, userLedgerMaxRunes, userLedgerEntryMaxRunes)
out, ferr := summarizeFinalizeWithRecentAssistantToolTrail(ctx, compactionMessages, summaryMsg, classicTokenCounter, recentTrailMax)
if ferr != nil {
return nil, ferr
}
out = mergeMessageIntoLeadingSystem(out, userLedger)
if appCfg != nil {
out = refreshFactIndexInMessages(out, db, projectID, appCfg.Project, logger)
}
return EinoMessagesToAgentic(out), nil
},
Callback: func(ctx context.Context, before, after adk.TypedChatModelAgentState[*schema.AgenticMessage]) error {
classicBefore := AgenticMessagesToEino(before.Messages)
classicAfter := AgenticMessagesToEino(after.Messages)
if transcriptPath != "" && len(classicBefore) > 0 {
if werr := writeSummarizationTranscript(transcriptPath, classicBefore); werr != nil && logger != nil {
logger.Warn("eino agentic summarization transcript 写入失败",
zap.String("path", transcriptPath),
zap.Error(werr),
)
}
}
if logger != nil {
beforeTokens, _ := classicTokenCounter(ctx, &summarization.TokenCounterInput{Messages: classicBefore})
afterTokens, _ := classicTokenCounter(ctx, &summarization.TokenCounterInput{Messages: classicAfter})
logger.Info("eino agentic summarization 已压缩上下文",
zap.Int("messages_before", len(before.Messages)),
zap.Int("messages_after", len(after.Messages)),
zap.Int("tokens_before_estimated", beforeTokens),
zap.Int("tokens_after_estimated", afterTokens),
zap.Int("max_total_tokens", maxTotal),
zap.Int("trigger_context_tokens", trigger),
zap.String("transcript_file", transcriptPath),
)
}
return nil
},
})
if err != nil {
return nil, fmt.Errorf("summarization.NewTyped[AgenticMessage]: %w", err)
}
return mw, nil
}
func agenticInstructionToClassic(msg *schema.AgenticMessage, fallbackRole schema.RoleType) *schema.Message {
msgs := AgenticMessageToEino(msg)
if len(msgs) > 0 && msgs[0] != nil {
return msgs[0]
}
return &schema.Message{Role: fallbackRole}
}
func agenticSummaryToClassicMessage(msg *schema.AgenticMessage) *schema.Message {
msgs := AgenticMessageToEino(msg)
for _, m := range msgs {
if m == nil {
continue
}
if m.Role == schema.Assistant || strings.TrimSpace(m.Content) != "" || m.ReasoningContent != "" {
if m.Role != schema.Assistant {
cp := *m
cp.Role = schema.Assistant
return &cp
}
return m
}
}
return nil
}
@@ -0,0 +1,210 @@
package multiagent
import (
"context"
"path/filepath"
"strings"
"testing"
"cyberstrike-ai/internal/config"
"github.com/cloudwego/eino/adk"
"github.com/cloudwego/eino/schema"
)
func TestNewEinoAgenticSummarizationMiddlewareCompactsWithNativeTypedMiddleware(t *testing.T) {
t.Parallel()
ctx := context.Background()
emit := false
summaryModel := &capturingAgenticChatModel{
output: &schema.AgenticMessage{
Role: schema.AgenticRoleTypeAssistant,
ContentBlocks: []*schema.ContentBlock{schema.NewContentBlock(&schema.AssistantGenText{Text: `<analysis>检查历史</analysis>
<summary>
## 1. 授权范围与约束
- 仅测试 example.com
## 7. 当前进度策略决策与下一步
- 继续验证 SQL 注入路径
</summary>`})},
},
}
appCfg := &config.Config{}
appCfg.OpenAI.Model = "gpt-4o"
appCfg.OpenAI.MaxTotalTokens = 5000
appCfg.Database.Path = filepath.Join(t.TempDir(), "cyberstrike.db")
mwCfg := &config.MultiAgentEinoMiddlewareConfig{
SummarizationEmitInternalEvents: &emit,
SummarizationOutputReserveTokens: 1024,
}
mw, err := newEinoAgenticSummarizationMiddleware(ctx, summaryModel, appCfg, mwCfg, "conv-agentic", nil, "", nil)
if err != nil {
t.Fatalf("newEinoAgenticSummarizationMiddleware: %v", err)
}
state := &adk.TypedChatModelAgentState[*schema.AgenticMessage]{
Messages: []*schema.AgenticMessage{
schema.SystemAgenticMessage("system root"),
schema.UserAgenticMessage("授权范围 example.com\n" + strings.Repeat("历史扫描输出 ", 12000)),
agenticAssistantTextMessage("已记录范围"),
schema.UserAgenticMessage("继续验证 SQL 注入路径"),
},
}
_, after, err := mw.BeforeModelRewriteState(ctx, state, nil)
if err != nil {
t.Fatalf("BeforeModelRewriteState: %v", err)
}
inputs := summaryModel.snapshotInputs()
if len(inputs) != 1 || len(inputs[0]) == 0 {
t.Fatalf("summary model inputs = %#v, want one typed AgenticMessage call", inputs)
}
if after == nil {
t.Fatal("after state is nil")
}
classicAfter := AgenticMessagesToEino(after.Messages)
joined := joinClassicMessageContent(classicAfter)
if strings.Contains(joined, "<analysis>") {
t.Fatalf("analysis block leaked into compacted context: %s", joined)
}
for _, want := range []string{"继续验证 SQL 注入路径", "原始用户输入与约束账本", "完整的对话记录位于"} {
if !strings.Contains(joined, want) {
t.Fatalf("compacted context missing %q:\n%s", want, joined)
}
}
}
func TestEinoAgenticChatModelAgentCompactsContextBeforeBusinessModel(t *testing.T) {
t.Parallel()
ctx := context.Background()
emit := false
summaryModel := &capturingAgenticChatModel{
output: agenticAssistantTextMessage(`<analysis>internal scratchpad</analysis>
<summary>
## 1. 授权范围与约束
- 仅测试 example.com
## 7. 当前进度策略决策与下一步
- 继续验证 SQL 注入路径
</summary>`),
}
businessModel := &capturingAgenticChatModel{
output: agenticAssistantTextMessage("business answer after compaction"),
}
appCfg := &config.Config{}
appCfg.OpenAI.Model = "gpt-4o"
appCfg.OpenAI.MaxTotalTokens = 5000
appCfg.Database.Path = filepath.Join(t.TempDir(), "cyberstrike.db")
mwCfg := &config.MultiAgentEinoMiddlewareConfig{
SummarizationEmitInternalEvents: &emit,
SummarizationOutputReserveTokens: 1024,
}
sumMw, err := newEinoAgenticSummarizationMiddleware(ctx, summaryModel, appCfg, mwCfg, "conv-agentic-e2e", nil, "", nil)
if err != nil {
t.Fatalf("newEinoAgenticSummarizationMiddleware: %v", err)
}
trace := newModelFacingTraceHolder()
agent, err := newEinoAgenticChatModelAgentAdapter(ctx, einoAgenticChatModelAgentConfig{
Name: "agentic",
Description: "agentic compaction e2e test",
Instruction: "system root",
Model: businessModel,
Handlers: appendEinoAgenticChatModelTailMiddlewares(nil, einoChatModelTailConfig{
phase: "agentic",
agenticSummarization: sumMw,
trace: trace,
}),
})
if err != nil {
t.Fatalf("newEinoAgenticChatModelAgentAdapter: %v", err)
}
rawHistory := "授权范围 example.com\n" + strings.Repeat("原始扫描输出SHOULD_NOT_REACH_BUSINESS_MODEL ", 12000)
iter := agent.Run(ctx, &adk.AgentInput{
Messages: []*schema.Message{
schema.UserMessage(rawHistory),
schema.AssistantMessage("已记录范围", nil),
schema.UserMessage("继续验证 SQL 注入路径"),
},
})
var last *adk.AgentEvent
for {
ev, ok := iter.Next()
if !ok {
break
}
if ev.Err != nil {
t.Fatalf("agent event error: %v", ev.Err)
}
last = ev
}
if last == nil || last.Output == nil || last.Output.MessageOutput == nil {
t.Fatalf("last event = %#v, want message output", last)
}
if got := last.Output.MessageOutput.Message.Content; got != "business answer after compaction" {
t.Fatalf("business output = %q", got)
}
if inputs := summaryModel.snapshotInputs(); len(inputs) != 1 {
t.Fatalf("summary model calls = %d, want 1", len(inputs))
}
businessInputs := businessModel.snapshotInputs()
if len(businessInputs) != 1 {
t.Fatalf("business model calls = %d, want 1", len(businessInputs))
}
finalClassicInput := AgenticMessagesToEino(businessInputs[0])
joined := joinClassicMessageContent(finalClassicInput)
for _, want := range []string{"继续验证 SQL 注入路径", "原始用户输入与约束账本", "完整的对话记录位于"} {
if !strings.Contains(joined, want) {
t.Fatalf("business model input missing %q:\n%s", want, joined)
}
}
if strings.Contains(joined, "<analysis>") {
t.Fatalf("analysis leaked to business model input:\n%s", joined)
}
if strings.Count(joined, "原始扫描输出SHOULD_NOT_REACH_BUSINESS_MODEL") > 3 {
t.Fatalf("raw oversized history leaked to business model input, count=%d", strings.Count(joined, "原始扫描输出SHOULD_NOT_REACH_BUSINESS_MODEL"))
}
traceJoined := joinClassicMessageContent(trace.Snapshot())
if !strings.Contains(traceJoined, "继续验证 SQL 注入路径") || strings.Count(traceJoined, "原始扫描输出SHOULD_NOT_REACH_BUSINESS_MODEL") > 3 {
t.Fatalf("model-facing trace not compacted:\n%s", traceJoined)
}
}
func TestAppendEinoAgenticChatModelTailMiddlewaresIncludesTypedSummarization(t *testing.T) {
t.Parallel()
mw := newAgenticSystemMessageNormalizerMiddleware(nil, "summary")
handlers := appendEinoAgenticChatModelTailMiddlewares(nil, einoChatModelTailConfig{
agenticSummarization: mw,
skipTrace: true,
})
found := false
for _, h := range handlers {
if h == mw {
found = true
break
}
}
if !found {
t.Fatal("agentic summarization middleware was not appended")
}
}
func agenticAssistantTextMessage(text string) *schema.AgenticMessage {
return &schema.AgenticMessage{
Role: schema.AgenticRoleTypeAssistant,
ContentBlocks: []*schema.ContentBlock{schema.NewContentBlock(&schema.AssistantGenText{Text: text})},
}
}
func joinClassicMessageContent(msgs []*schema.Message) string {
var b strings.Builder
for _, msg := range msgs {
if msg == nil {
continue
}
b.WriteString(msg.Content)
b.WriteByte('\n')
}
return b.String()
}
@@ -0,0 +1,42 @@
package multiagent
import "strings"
type einoAssistantOutputAccumulator struct {
orchMode string
lastAssistant string
lastPlanExecuteExecutor string
}
func newEinoAssistantOutputAccumulator(orchMode string) *einoAssistantOutputAccumulator {
return &einoAssistantOutputAccumulator{orchMode: orchMode}
}
func (a *einoAssistantOutputAccumulator) RecordMainAssistant(agentName, content string) bool {
if a == nil {
return false
}
content = strings.TrimSpace(content)
if content == "" {
return false
}
a.lastAssistant = content
if a.orchMode == "plan_execute" && strings.EqualFold(strings.TrimSpace(agentName), "executor") {
a.lastPlanExecuteExecutor = UnwrapPlanExecuteUserText(content)
}
return true
}
func (a *einoAssistantOutputAccumulator) LastAssistant() string {
if a == nil {
return ""
}
return a.lastAssistant
}
func (a *einoAssistantOutputAccumulator) LastPlanExecuteExecutor() string {
if a == nil {
return ""
}
return a.lastPlanExecuteExecutor
}
@@ -0,0 +1,52 @@
package multiagent
import "testing"
func TestEinoAssistantOutputAccumulatorRecordsMainAssistant(t *testing.T) {
acc := newEinoAssistantOutputAccumulator("deep")
if acc.RecordMainAssistant("lead", " hello ") != true {
t.Fatal("expected record")
}
if got := acc.LastAssistant(); got != "hello" {
t.Fatalf("last assistant = %q, want hello", got)
}
if got := acc.LastPlanExecuteExecutor(); got != "" {
t.Fatalf("plan execute executor = %q, want empty", got)
}
if acc.RecordMainAssistant("lead", " ") {
t.Fatal("blank content should not record")
}
if got := acc.LastAssistant(); got != "hello" {
t.Fatalf("blank content changed last assistant to %q", got)
}
}
func TestEinoAssistantOutputAccumulatorPlanExecuteExecutor(t *testing.T) {
acc := newEinoAssistantOutputAccumulator("plan_execute")
raw := `{"response":"给用户看的正文","scratchpad":"internal"}`
acc.RecordMainAssistant("executor", raw)
if got := acc.LastAssistant(); got != raw {
t.Fatalf("last assistant = %q, want raw", got)
}
if got := acc.LastPlanExecuteExecutor(); got != "给用户看的正文" {
t.Fatalf("executor output = %q", got)
}
acc.RecordMainAssistant("planner", "planner note")
if got := acc.LastAssistant(); got != "planner note" {
t.Fatalf("last assistant after planner = %q", got)
}
if got := acc.LastPlanExecuteExecutor(); got != "给用户看的正文" {
t.Fatalf("planner should not overwrite executor output, got %q", got)
}
}
func TestEinoAssistantOutputAccumulatorNilSafe(t *testing.T) {
var acc *einoAssistantOutputAccumulator
if acc.RecordMainAssistant("agent", "hello") {
t.Fatal("nil accumulator should not record")
}
if acc.LastAssistant() != "" || acc.LastPlanExecuteExecutor() != "" {
t.Fatal("nil accumulator should return empty values")
}
}
@@ -0,0 +1,167 @@
package multiagent
import (
"context"
"errors"
"github.com/cloudwego/eino/adk"
"github.com/cloudwego/eino/schema"
"go.uber.org/zap"
)
type einoAssistantStreamEventHandlerConfig struct {
Context context.Context
ConversationID string
OrchMode string
Progress func(eventType, message string, data interface{})
Logger *zap.Logger
SnapshotMCPIDs func() []string
StreamsMainAssistant func(agent string) bool
EinoRoleTag func(agent string) string
RunProgress *einoRunProgressTracker
StdoutSuppressor *einoExecuteStdoutSuppressor
AssistantOutput *einoAssistantOutputAccumulator
RunMessages *einoRunMessageAccumulator
Usage *einoRunUsageAccumulator
ToolCallCompletion *einoStreamToolCallCompletionHandler
NextMainStreamID func() string
NextReasoningStreamID func() string
NextSubAgentReplyStreamID func() string
}
type einoAssistantStreamEventHandler struct {
ctx context.Context
conversationID string
orchMode string
progress func(eventType, message string, data interface{})
logger *zap.Logger
snapshotMCPIDs func() []string
streamsMainAssistant func(agent string) bool
einoRoleTag func(agent string) string
runProgress *einoRunProgressTracker
stdoutSuppressor *einoExecuteStdoutSuppressor
assistantOutput *einoAssistantOutputAccumulator
runMessages *einoRunMessageAccumulator
usage *einoRunUsageAccumulator
toolCallCompletion *einoStreamToolCallCompletionHandler
nextMainStreamID func() string
nextReasoningStreamID func() string
nextSubAgentReplyStreamID func() string
}
func newEinoAssistantStreamEventHandler(cfg einoAssistantStreamEventHandlerConfig) *einoAssistantStreamEventHandler {
if cfg.Context == nil {
cfg.Context = context.Background()
}
if cfg.SnapshotMCPIDs == nil {
cfg.SnapshotMCPIDs = func() []string { return nil }
}
if cfg.StreamsMainAssistant == nil {
cfg.StreamsMainAssistant = func(string) bool { return true }
}
if cfg.EinoRoleTag == nil {
cfg.EinoRoleTag = func(string) string { return "" }
}
if cfg.NextMainStreamID == nil {
cfg.NextMainStreamID = func() string { return "eino-main" }
}
if cfg.NextReasoningStreamID == nil {
cfg.NextReasoningStreamID = func() string { return "eino-reasoning" }
}
if cfg.NextSubAgentReplyStreamID == nil {
cfg.NextSubAgentReplyStreamID = func() string { return "eino-sub-reply" }
}
return &einoAssistantStreamEventHandler{
ctx: cfg.Context,
conversationID: cfg.ConversationID,
orchMode: cfg.OrchMode,
progress: cfg.Progress,
logger: cfg.Logger,
snapshotMCPIDs: cfg.SnapshotMCPIDs,
streamsMainAssistant: cfg.StreamsMainAssistant,
einoRoleTag: cfg.EinoRoleTag,
runProgress: cfg.RunProgress,
stdoutSuppressor: cfg.StdoutSuppressor,
assistantOutput: cfg.AssistantOutput,
runMessages: cfg.RunMessages,
usage: cfg.Usage,
toolCallCompletion: cfg.ToolCallCompletion,
nextMainStreamID: cfg.NextMainStreamID,
nextReasoningStreamID: cfg.NextReasoningStreamID,
nextSubAgentReplyStreamID: cfg.NextSubAgentReplyStreamID,
}
}
func (h *einoAssistantStreamEventHandler) Handle(mv *adk.MessageVariant, agentName string) (handled bool, recvErr error) {
if h == nil || mv == nil || !mv.IsStreaming || mv.MessageStream == nil || mv.Role == schema.Tool {
return false, nil
}
mainStreamID := h.nextMainStreamID()
mainEmitter := newEinoMainResponseStreamEmitter(
h.conversationID, h.orchMode, agentName, mainStreamID, h.mainIteration(agentName), h.progress, h.snapshotMCPIDs,
)
reasoningEmitter := newEinoReasoningStreamEmitter(
h.conversationID,
h.orchMode,
agentName,
h.einoRoleTag(agentName),
h.progress,
h.nextReasoningStreamID,
)
var toolStreamFragments []schema.ToolCall
var streamUsage *schema.TokenUsage
subReplyEmitter := newEinoSubAgentReplyEmitter(
h.conversationID,
agentName,
h.progress,
h.nextSubAgentReplyStreamID,
)
mainAssistantStream := newEinoMainAssistantStreamHandler(einoMainAssistantStreamHandlerConfig{
AgentName: agentName,
Emitter: mainEmitter,
StdoutSuppressor: h.stdoutSuppressor,
AssistantOutput: h.assistantOutput,
RunMessages: h.runMessages,
})
recvErr = recvEinoSchemaMessageStreamWithContext(h.ctx, mv.MessageStream, 8, func(chunk *schema.Message) {
reasoningEmitter.EmitDelta(chunk.ReasoningContent)
if chunk.Content != "" {
if h.streamsMainAssistant(agentName) {
mainAssistantStream.EmitDelta(chunk.Content)
} else if !h.streamsMainAssistant(agentName) {
subReplyEmitter.EmitDelta(chunk.Content)
}
}
if len(chunk.ToolCalls) > 0 {
toolStreamFragments = append(toolStreamFragments, chunk.ToolCalls...)
}
if chunk.ResponseMeta != nil && chunk.ResponseMeta.Usage != nil {
streamUsage = maxEinoTokenUsage(streamUsage, chunk.ResponseMeta.Usage)
}
})
if recvErr != nil && !errors.Is(recvErr, context.Canceled) && h.logger != nil {
h.logger.Warn("eino stream recv error, flushing incomplete stream",
zap.Error(recvErr),
zap.String("agent", agentName),
zap.Int("toolFragments", len(toolStreamFragments)))
}
reasoningEmitter.Finish()
if h.streamsMainAssistant(agentName) {
mainAssistantStream.Finish()
}
subReplyEmitter.Finish()
if h.toolCallCompletion != nil {
h.toolCallCompletion.Complete(toolStreamFragments, agentName)
}
if h.usage != nil {
h.usage.AddUsage(streamUsage)
}
return true, recvErr
}
func (h *einoAssistantStreamEventHandler) mainIteration(agentName string) int {
if h == nil || h.runProgress == nil {
return 0
}
return h.runProgress.MainIteration(agentName)
}
@@ -0,0 +1,148 @@
package multiagent
import (
"testing"
"github.com/cloudwego/eino/adk"
"github.com/cloudwego/eino/schema"
)
func TestEinoAssistantStreamEventHandlerHandlesMainAssistantStream(t *testing.T) {
var events []string
runMessages := newEinoRunMessageAccumulator(nil)
assistantOutput := newEinoAssistantOutputAccumulator("deep")
usage := newEinoRunUsageAccumulator()
handler := newEinoAssistantStreamEventHandler(einoAssistantStreamEventHandlerConfig{
ConversationID: "conv-1",
OrchMode: "deep",
RunMessages: runMessages,
Usage: usage,
AssistantOutput: assistantOutput,
StreamsMainAssistant: func(agent string) bool { return agent == "lead" },
EinoRoleTag: func(string) string { return "orchestrator" },
NextMainStreamID: func() string { return "main-stream-1" },
Progress: func(eventType, _ string, _ interface{}) {
events = append(events, eventType)
},
})
mv := &adk.MessageVariant{
IsStreaming: true,
Role: schema.Assistant,
MessageStream: schema.StreamReaderFromArray([]*schema.Message{
{Role: schema.Assistant, Content: "he", ResponseMeta: &schema.ResponseMeta{Usage: &schema.TokenUsage{PromptTokens: 10, CompletionTokens: 1, TotalTokens: 11}}},
{Role: schema.Assistant, Content: "hello", ResponseMeta: &schema.ResponseMeta{Usage: &schema.TokenUsage{PromptTokens: 10, CompletionTokens: 5, TotalTokens: 15}}},
}),
}
handled, err := handler.Handle(mv, "lead")
if !handled || err != nil {
t.Fatalf("handled=%v err=%v", handled, err)
}
if assistantOutput.LastAssistant() != "hello" {
t.Fatalf("last assistant = %q", assistantOutput.LastAssistant())
}
if msgs := runMessages.Messages(); len(msgs) != 1 || msgs[0].Content != "hello" {
t.Fatalf("run messages = %#v", msgs)
}
if got := usage.Summary(); got.ModelCalls != 1 || got.PromptTokens != 10 || got.CompletionTokens != 5 || got.TotalTokens != 15 {
t.Fatalf("usage = %#v, want one stream model call", got)
}
if !containsString(events, "response_start") || !containsString(events, "response_delta") {
t.Fatalf("events = %#v, want response stream events", events)
}
}
func TestEinoAssistantStreamEventHandlerHandlesSubAgentStream(t *testing.T) {
var events []string
runMessages := newEinoRunMessageAccumulator(nil)
assistantOutput := newEinoAssistantOutputAccumulator("deep")
handler := newEinoAssistantStreamEventHandler(einoAssistantStreamEventHandlerConfig{
ConversationID: "conv-1",
OrchMode: "deep",
RunMessages: runMessages,
AssistantOutput: assistantOutput,
StreamsMainAssistant: func(agent string) bool { return agent == "lead" },
EinoRoleTag: func(string) string { return "sub" },
NextSubAgentReplyStreamID: func() string {
return "sub-stream-1"
},
Progress: func(eventType, _ string, _ interface{}) {
events = append(events, eventType)
},
})
mv := &adk.MessageVariant{
IsStreaming: true,
Role: schema.Assistant,
MessageStream: schema.StreamReaderFromArray([]*schema.Message{{Role: schema.Assistant, Content: "sub reply"}}),
}
handled, err := handler.Handle(mv, "worker")
if !handled || err != nil {
t.Fatalf("handled=%v err=%v", handled, err)
}
if len(runMessages.Messages()) != 0 {
t.Fatalf("sub stream should not append main run text, got %#v", runMessages.Messages())
}
if assistantOutput.LastAssistant() != "" {
t.Fatalf("sub stream should not record main assistant, got %q", assistantOutput.LastAssistant())
}
if !containsString(events, "eino_agent_reply_stream_start") ||
!containsString(events, "eino_agent_reply_stream_delta") ||
!containsString(events, "eino_agent_reply_stream_end") {
t.Fatalf("events = %#v, want sub reply stream events", events)
}
}
func TestEinoAssistantStreamEventHandlerCompletesToolFragments(t *testing.T) {
idx := 0
var events []string
runMessages := newEinoRunMessageAccumulator(nil)
runProgress := newEinoRunProgressTracker(
"deep", "lead", "conv-1",
func(eventType, _ string, _ interface{}) { events = append(events, eventType) },
func(agent string) bool { return agent == "lead" },
nil,
)
completion := newEinoStreamToolCallCompletionHandler(einoStreamToolCallCompletionHandlerConfig{
ConversationID: "conv-1",
OrchMode: "deep",
Progress: func(eventType, _ string, _ interface{}) { events = append(events, eventType) },
RunProgress: runProgress,
RunMessages: runMessages,
})
handler := newEinoAssistantStreamEventHandler(einoAssistantStreamEventHandlerConfig{
ConversationID: "conv-1",
OrchMode: "deep",
RunMessages: runMessages,
StreamsMainAssistant: func(string) bool { return true },
ToolCallCompletion: completion,
})
mv := &adk.MessageVariant{
IsStreaming: true,
Role: schema.Assistant,
MessageStream: schema.StreamReaderFromArray([]*schema.Message{
{Role: schema.Assistant, ToolCalls: []schema.ToolCall{{ID: "call-1", Index: &idx, Type: "function", Function: schema.FunctionCall{Name: "execute", Arguments: `{"command":`}}}},
{Role: schema.Assistant, ToolCalls: []schema.ToolCall{{Index: &idx, Function: schema.FunctionCall{Arguments: `"pwd"}`}}}},
}),
}
handled, err := handler.Handle(mv, "lead")
if !handled || err != nil {
t.Fatalf("handled=%v err=%v", handled, err)
}
msgs := runMessages.Messages()
if len(msgs) != 1 || len(msgs[0].ToolCalls) != 1 || msgs[0].ToolCalls[0].Function.Arguments != `{"command":"pwd"}` {
t.Fatalf("run messages = %#v, want merged tool call", msgs)
}
if !containsString(events, "tool_call") {
t.Fatalf("events = %#v, want tool_call", events)
}
}
func TestEinoAssistantStreamEventHandlerIgnoresToolStream(t *testing.T) {
handler := newEinoAssistantStreamEventHandler(einoAssistantStreamEventHandlerConfig{})
handled, err := handler.Handle(&adk.MessageVariant{IsStreaming: true, Role: schema.Tool, MessageStream: schema.StreamReaderFromArray([]*schema.Message{})}, "lead")
if handled || err != nil {
t.Fatalf("handled=%v err=%v, want ignored", handled, err)
}
}
@@ -4,6 +4,7 @@ import (
"cyberstrike-ai/internal/config"
"github.com/cloudwego/eino/adk"
"github.com/cloudwego/eino/schema"
"go.uber.org/zap"
)
@@ -27,6 +28,7 @@ type einoChatModelTailConfig struct {
logger *zap.Logger
phase string
summarization adk.ChatModelAgentMiddleware
agenticSummarization adk.TypedChatModelAgentMiddleware[*schema.AgenticMessage]
modelName string
maxTotalTokens int
toolMaxBytes int
@@ -65,7 +67,6 @@ func appendEinoChatModelTailMiddlewares(handlers []adk.ChatModelAgentMiddleware,
handlers = append(handlers, capMw)
}
}
handlers = append(handlers, newModelOutputGuardMiddleware(cfg.middlewareConfig, cfg.logger, cfg.phase))
return handlers
}
@@ -0,0 +1,71 @@
package multiagent
import (
"context"
"github.com/cloudwego/eino/adk"
"go.uber.org/zap"
)
type einoCheckpointResumeHandlerConfig struct {
Context context.Context
ConversationID string
OrchMode string
Progress func(eventType, message string, data interface{})
Logger *zap.Logger
Store *fileCheckPointStore
CheckPointID string
Resume func(checkPointID string) (*adk.AsyncIterator[*adk.AgentEvent], error)
}
type einoCheckpointResumeHandler struct {
cfg einoCheckpointResumeHandlerConfig
}
func newEinoCheckpointResumeHandler(cfg einoCheckpointResumeHandlerConfig) *einoCheckpointResumeHandler {
if cfg.Context == nil {
cfg.Context = context.Background()
}
return &einoCheckpointResumeHandler{cfg: cfg}
}
func (h *einoCheckpointResumeHandler) TryResume() *adk.AsyncIterator[*adk.AgentEvent] {
if h == nil || h.cfg.Store == nil || h.cfg.CheckPointID == "" || h.cfg.Resume == nil {
return nil
}
if _, existed, err := h.cfg.Store.Get(h.cfg.Context, h.cfg.CheckPointID); err != nil {
if h.cfg.Logger != nil {
h.cfg.Logger.Warn("eino checkpoint preflight get failed", zap.String("checkPointID", h.cfg.CheckPointID), zap.Error(err))
}
return nil
} else if !existed {
return nil
}
h.emitProgress("检测到断点,正在从中断节点恢复执行...")
if h.cfg.Logger != nil {
h.cfg.Logger.Info("eino runner: resume from checkpoint", zap.String("checkPointID", h.cfg.CheckPointID))
}
iter, err := h.cfg.Resume(h.cfg.CheckPointID)
if err == nil {
return iter
}
if h.cfg.Logger != nil {
h.cfg.Logger.Warn("eino runner: resume failed, fallback to fresh run",
zap.String("checkPointID", h.cfg.CheckPointID),
zap.Error(err))
}
h.emitProgress("断点恢复失败,已回退为全新执行。")
return nil
}
func (h *einoCheckpointResumeHandler) emitProgress(message string) {
if h == nil || h.cfg.Progress == nil {
return
}
h.cfg.Progress("progress", message, map[string]interface{}{
"conversationId": h.cfg.ConversationID,
"source": "eino",
"orchestration": h.cfg.OrchMode,
"checkPointID": h.cfg.CheckPointID,
})
}
@@ -0,0 +1,138 @@
package multiagent
import (
"context"
"errors"
"testing"
"github.com/cloudwego/eino/adk"
"go.uber.org/zap"
"go.uber.org/zap/zaptest/observer"
)
func TestEinoCheckpointResumeHandlerSkipsWithoutCheckpoint(t *testing.T) {
called := false
handler := newEinoCheckpointResumeHandler(einoCheckpointResumeHandlerConfig{
Resume: func(string) (*adk.AsyncIterator[*adk.AgentEvent], error) {
called = true
return nil, nil
},
})
if iter := handler.TryResume(); iter != nil {
t.Fatalf("iter = %#v, want nil", iter)
}
if called {
t.Fatal("resume should not be called without checkpoint state")
}
}
func TestEinoCheckpointResumeHandlerResumesExistingCheckpoint(t *testing.T) {
store, err := newFileCheckPointStore(t.TempDir())
if err != nil {
t.Fatal(err)
}
if err := store.Set(context.Background(), "cp-1", []byte("checkpoint")); err != nil {
t.Fatal(err)
}
var progressMessages []string
var resumedID string
core, logs := observer.New(zap.InfoLevel)
wantIter, gen := adk.NewAsyncIteratorPair[*adk.AgentEvent]()
defer gen.Close()
handler := newEinoCheckpointResumeHandler(einoCheckpointResumeHandlerConfig{
Context: context.Background(),
ConversationID: "conv-1",
OrchMode: "deep",
Store: store,
CheckPointID: "cp-1",
Logger: zap.New(core),
Progress: func(eventType, message string, data interface{}) {
if eventType != "progress" {
return
}
progressMessages = append(progressMessages, message)
m, _ := data.(map[string]interface{})
if m["conversationId"] != "conv-1" || m["orchestration"] != "deep" || m["checkPointID"] != "cp-1" {
t.Fatalf("progress data = %#v", m)
}
},
Resume: func(checkPointID string) (*adk.AsyncIterator[*adk.AgentEvent], error) {
resumedID = checkPointID
return wantIter, nil
},
})
got := handler.TryResume()
if got != wantIter {
t.Fatalf("iter = %#v, want resume iterator", got)
}
if resumedID != "cp-1" {
t.Fatalf("resumed id = %q", resumedID)
}
if len(progressMessages) != 1 || progressMessages[0] != "检测到断点,正在从中断节点恢复执行..." {
t.Fatalf("progress messages = %#v", progressMessages)
}
if logs.FilterMessage("eino runner: resume from checkpoint").Len() != 1 {
t.Fatalf("expected resume log, got %d", logs.Len())
}
}
func TestEinoCheckpointResumeHandlerFallsBackOnResumeError(t *testing.T) {
store, err := newFileCheckPointStore(t.TempDir())
if err != nil {
t.Fatal(err)
}
if err := store.Set(context.Background(), "cp-1", []byte("checkpoint")); err != nil {
t.Fatal(err)
}
var progressMessages []string
core, logs := observer.New(zap.WarnLevel)
handler := newEinoCheckpointResumeHandler(einoCheckpointResumeHandlerConfig{
Context: context.Background(),
Store: store,
CheckPointID: "cp-1",
Logger: zap.New(core),
Progress: func(eventType, message string, _ interface{}) {
if eventType == "progress" {
progressMessages = append(progressMessages, message)
}
},
Resume: func(string) (*adk.AsyncIterator[*adk.AgentEvent], error) {
return nil, errors.New("resume failed")
},
})
if iter := handler.TryResume(); iter != nil {
t.Fatalf("iter = %#v, want nil fallback", iter)
}
if len(progressMessages) != 2 || progressMessages[1] != "断点恢复失败,已回退为全新执行。" {
t.Fatalf("progress messages = %#v", progressMessages)
}
if logs.FilterMessage("eino runner: resume failed, fallback to fresh run").Len() != 1 {
t.Fatalf("expected fallback log, got %d", logs.Len())
}
}
func TestEinoCheckpointResumeHandlerLogsPreflightError(t *testing.T) {
store, err := newFileCheckPointStore(t.TempDir())
if err != nil {
t.Fatal(err)
}
core, logs := observer.New(zap.WarnLevel)
handler := newEinoCheckpointResumeHandler(einoCheckpointResumeHandlerConfig{
Context: context.Background(),
Store: store,
CheckPointID: "bad/id",
Logger: zap.New(core),
Resume: func(string) (*adk.AsyncIterator[*adk.AgentEvent], error) {
t.Fatal("resume should not be called after preflight error")
return nil, nil
},
})
if iter := handler.TryResume(); iter != nil {
t.Fatalf("iter = %#v, want nil", iter)
}
if logs.FilterMessage("eino checkpoint preflight get failed").Len() != 1 {
t.Fatalf("expected preflight warning, got %d", logs.Len())
}
}
@@ -0,0 +1,38 @@
package multiagent
import (
"path/filepath"
"strings"
"go.uber.org/zap"
)
type einoCheckpointRuntime struct {
Store *fileCheckPointStore
CheckPointID string
}
func newEinoCheckpointRuntime(checkpointDir, conversationID, orchMode string, logger *zap.Logger) *einoCheckpointRuntime {
checkpointDir = strings.TrimSpace(checkpointDir)
if checkpointDir == "" {
return nil
}
cpDir := filepath.Join(checkpointDir, sanitizeEinoPathSegment(conversationID))
store, err := newFileCheckPointStore(cpDir)
if err != nil {
if logger != nil {
logger.Warn("eino checkpoint store disabled", zap.String("dir", cpDir), zap.Error(err))
}
return nil
}
checkPointID := buildEinoCheckpointID(orchMode)
if logger != nil {
logger.Info("eino runner: checkpoint store enabled",
zap.String("dir", cpDir),
zap.String("checkPointID", checkPointID))
}
return &einoCheckpointRuntime{
Store: store,
CheckPointID: checkPointID,
}
}
@@ -0,0 +1,48 @@
package multiagent
import (
"os"
"strings"
"testing"
"go.uber.org/zap"
"go.uber.org/zap/zaptest/observer"
)
func TestNewEinoCheckpointRuntimeDisabledWithoutDir(t *testing.T) {
if got := newEinoCheckpointRuntime(" ", "conv-1", "deep", nil); got != nil {
t.Fatalf("runtime = %#v, want nil", got)
}
}
func TestNewEinoCheckpointRuntimeCreatesStore(t *testing.T) {
core, logs := observer.New(zap.InfoLevel)
runtime := newEinoCheckpointRuntime(t.TempDir(), "conv/1", "deep", zap.New(core))
if runtime == nil || runtime.Store == nil {
t.Fatal("expected checkpoint runtime with store")
}
if runtime.CheckPointID != buildEinoCheckpointID("deep") {
t.Fatalf("checkpoint id = %q", runtime.CheckPointID)
}
if !strings.Contains(runtime.Store.dir, sanitizeEinoPathSegment("conv/1")) {
t.Fatalf("store dir = %q, want sanitized conversation segment", runtime.Store.dir)
}
if logs.FilterMessage("eino runner: checkpoint store enabled").Len() != 1 {
t.Fatalf("expected enabled log, got %d", logs.Len())
}
}
func TestNewEinoCheckpointRuntimeLogsCreateFailure(t *testing.T) {
filePath := t.TempDir() + "/not-a-dir"
if err := os.WriteFile(filePath, []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
core, logs := observer.New(zap.WarnLevel)
runtime := newEinoCheckpointRuntime(filePath, "conv-1", "deep", zap.New(core))
if runtime != nil {
t.Fatalf("runtime = %#v, want nil", runtime)
}
if logs.FilterMessage("eino checkpoint store disabled").Len() != 1 {
t.Fatalf("expected disabled log, got %d", logs.Len())
}
}
@@ -0,0 +1,90 @@
package multiagent
import (
"context"
"github.com/cloudwego/eino/adk"
"go.uber.org/zap"
)
type einoContextOverflowRetryConfig struct {
Context context.Context
ConversationID string
OrchMode string
Args *einoADKRunLoopArgs
BaseMsgs []adk.Message
Progress func(eventType, message string, data interface{})
Logger *zap.Logger
}
type einoContextOverflowRetryResult struct {
Handled bool
RestartMsgs []adk.Message
ContextSrc einoRunRestartContextSource
}
type einoContextOverflowRetryHandler struct {
cfg einoContextOverflowRetryConfig
retried bool
}
func newEinoContextOverflowRetryHandler(cfg einoContextOverflowRetryConfig) *einoContextOverflowRetryHandler {
if cfg.Context == nil {
cfg.Context = context.Background()
}
if cfg.Args == nil {
cfg.Args = &einoADKRunLoopArgs{}
}
return &einoContextOverflowRetryHandler{cfg: cfg}
}
func (h *einoContextOverflowRetryHandler) Prepare(
runErr error,
accumulated []adk.Message,
baseCount int,
) einoContextOverflowRetryResult {
if h == nil || !isEinoContextOverflowError(runErr) || h.retried {
return einoContextOverflowRetryResult{}
}
h.retried = true
restartMsgs, ctxSource := einoMessagesForRunRestart(h.cfg.Args, h.cfg.BaseMsgs, accumulated, baseCount)
restartMsgs = aggressiveCompactMessagesForOverflow(
h.cfg.Context,
restartMsgs,
h.cfg.Args.MaxTotalTokens,
h.cfg.Args.ModelName,
h.cfg.Args.ToolMaxBytes,
h.cfg.OrchMode,
h.cfg.Logger,
)
if h.cfg.Logger != nil {
h.cfg.Logger.Warn("eino context overflow, retrying with aggressive compaction",
zap.Error(runErr),
zap.String("orchestration", h.cfg.OrchMode),
zap.String("contextSource", string(ctxSource)),
)
}
emitEinoContextOverflowRetryProgress(h.cfg.Progress, h.cfg.ConversationID, h.cfg.OrchMode, ctxSource)
return einoContextOverflowRetryResult{
Handled: true,
RestartMsgs: restartMsgs,
ContextSrc: ctxSource,
}
}
func emitEinoContextOverflowRetryProgress(
progress func(eventType, message string, data interface{}),
conversationID, orchMode string,
ctxSource einoRunRestartContextSource,
) bool {
if progress == nil {
return false
}
progress("eino_context_overflow_retry", "上下文超限,正在激进压缩后重试…", map[string]interface{}{
"conversationId": conversationID,
"source": "eino",
"orchestration": orchMode,
"contextSource": string(ctxSource),
})
return true
}
@@ -0,0 +1,90 @@
package multiagent
import (
"context"
"errors"
"testing"
"github.com/cloudwego/eino/adk"
"github.com/cloudwego/eino/schema"
"go.uber.org/zap"
"go.uber.org/zap/zaptest/observer"
)
func TestEinoContextOverflowRetryHandlerPreparesOnce(t *testing.T) {
baseMsgs := []adk.Message{
schema.UserMessage("base"),
}
accumulated := []adk.Message{
schema.UserMessage("base"),
schema.AssistantMessage("partial", nil),
}
var gotType, gotMessage string
var gotData map[string]interface{}
core, logs := observer.New(zap.WarnLevel)
handler := newEinoContextOverflowRetryHandler(einoContextOverflowRetryConfig{
Context: context.Background(),
ConversationID: "conv-1",
OrchMode: "deep_agent",
Args: &einoADKRunLoopArgs{},
BaseMsgs: baseMsgs,
Progress: func(eventType, message string, data interface{}) {
gotType = eventType
gotMessage = message
var ok bool
gotData, ok = data.(map[string]interface{})
if !ok {
t.Fatalf("progress data type = %T, want map[string]interface{}", data)
}
},
Logger: zap.New(core),
})
result := handler.Prepare(errors.New("context length exceeded"), accumulated, len(baseMsgs))
if !result.Handled {
t.Fatal("handled = false, want true")
}
if result.ContextSrc != einoRestartContextAccumulated {
t.Fatalf("context source = %q, want %q", result.ContextSrc, einoRestartContextAccumulated)
}
if len(result.RestartMsgs) != len(accumulated) {
t.Fatalf("restart message count = %d, want %d", len(result.RestartMsgs), len(accumulated))
}
if gotType != "eino_context_overflow_retry" {
t.Fatalf("event type = %q, want eino_context_overflow_retry", gotType)
}
if gotMessage != "上下文超限,正在激进压缩后重试…" {
t.Fatalf("message = %q", gotMessage)
}
assertContextOverflowMapValue(t, gotData, "conversationId", "conv-1")
assertContextOverflowMapValue(t, gotData, "source", "eino")
assertContextOverflowMapValue(t, gotData, "orchestration", "deep_agent")
assertContextOverflowMapValue(t, gotData, "contextSource", string(einoRestartContextAccumulated))
if logs.FilterMessage("eino context overflow, retrying with aggressive compaction").Len() != 1 {
t.Fatalf("expected one context overflow retry log, got %d", logs.Len())
}
second := handler.Prepare(errors.New("maximum context length"), accumulated, len(baseMsgs))
if second.Handled {
t.Fatalf("second result = %+v, want unhandled after first retry", second)
}
}
func TestEinoContextOverflowRetryHandlerIgnoresOtherErrors(t *testing.T) {
handler := newEinoContextOverflowRetryHandler(einoContextOverflowRetryConfig{
Context: context.Background(),
Args: &einoADKRunLoopArgs{},
BaseMsgs: []adk.Message{schema.UserMessage("base")},
})
result := handler.Prepare(errors.New("HTTP 429 Too Many Requests"), nil, 0)
if result.Handled {
t.Fatalf("result = %+v, want unhandled", result)
}
}
func assertContextOverflowMapValue(t *testing.T, data map[string]interface{}, key string, want interface{}) {
t.Helper()
if got := data[key]; got != want {
t.Fatalf("%s = %v, want %v", key, got, want)
}
}
@@ -0,0 +1,57 @@
package multiagent
import (
"strings"
"sync"
)
type einoExecuteStdoutSuppressor struct {
mu sync.Mutex
pending string
}
func newEinoExecuteStdoutSuppressor() *einoExecuteStdoutSuppressor {
return &einoExecuteStdoutSuppressor{}
}
func (s *einoExecuteStdoutSuppressor) Record(toolName, stdout string, isErr bool) {
if s == nil || isErr || !strings.EqualFold(strings.TrimSpace(toolName), "execute") {
return
}
t := strings.TrimSpace(stdout)
if t == "" {
return
}
s.mu.Lock()
s.pending = t
s.mu.Unlock()
}
func (s *einoExecuteStdoutSuppressor) Peek() string {
if s == nil {
return ""
}
s.mu.Lock()
defer s.mu.Unlock()
return s.pending
}
func (s *einoExecuteStdoutSuppressor) Consume() string {
if s == nil {
return ""
}
s.mu.Lock()
defer s.mu.Unlock()
out := s.pending
s.pending = ""
return out
}
func (s *einoExecuteStdoutSuppressor) Clear() {
if s == nil {
return
}
s.mu.Lock()
s.pending = ""
s.mu.Unlock()
}
@@ -0,0 +1,42 @@
package multiagent
import "testing"
func TestEinoExecuteStdoutSuppressorRecordsOnlySuccessfulExecute(t *testing.T) {
s := newEinoExecuteStdoutSuppressor()
s.Record("read_file", "file body", false)
if got := s.Peek(); got != "" {
t.Fatalf("non-execute should not be recorded, got %q", got)
}
s.Record("execute", "failed", true)
if got := s.Peek(); got != "" {
t.Fatalf("failed execute should not be recorded, got %q", got)
}
s.Record(" execute ", " hello\n", false)
if got := s.Peek(); got != "hello" {
t.Fatalf("Peek = %q, want hello", got)
}
}
func TestEinoExecuteStdoutSuppressorConsumeAndClear(t *testing.T) {
s := newEinoExecuteStdoutSuppressor()
s.Record("execute", "stdout", false)
if got := s.Peek(); got != "stdout" {
t.Fatalf("Peek = %q, want stdout", got)
}
if got := s.Peek(); got != "stdout" {
t.Fatalf("Peek should not clear, got %q", got)
}
if got := s.Consume(); got != "stdout" {
t.Fatalf("Consume = %q, want stdout", got)
}
if got := s.Peek(); got != "" {
t.Fatalf("Consume should clear, got %q", got)
}
s.Record("execute", "again", false)
s.Clear()
if got := s.Consume(); got != "" {
t.Fatalf("Clear should remove pending value, got %q", got)
}
}
@@ -64,6 +64,17 @@ func toolCallArgsFromAccumulated(msgs []adk.Message, toolCallID, expectToolName
return map[string]interface{}{}
}
func mustMarshalToolArguments(args map[string]interface{}) string {
if len(args) == 0 {
return "{}"
}
raw, err := json.Marshal(args)
if err != nil {
return "{}"
}
return string(raw)
}
// beginEinoADKFilesystemToolMonitor 在 Eino ADK filesystem 工具开始调用时写入 running 状态。
func beginEinoADKFilesystemToolMonitor(
ctx context.Context,
@@ -71,6 +82,7 @@ func beginEinoADKFilesystemToolMonitor(
rec einomcp.ExecutionRecorder,
binder *MCPExecutionBinder,
toolCallID, toolName string,
args map[string]interface{},
) {
if ag == nil || rec == nil {
return
@@ -87,7 +99,7 @@ func beginEinoADKFilesystemToolMonitor(
return
}
storedName := "eino_fs::" + strings.ToLower(name)
id := ag.BeginLocalToolExecution(ctx, storedName, map[string]interface{}{})
id := ag.BeginLocalToolExecution(ctx, storedName, args)
if id == "" {
return
}
@@ -108,18 +120,21 @@ func recordEinoADKFilesystemToolMonitor(
msgs []adk.Message,
resultText string,
isErr bool,
) {
) string {
if ag == nil || rec == nil {
return
return ""
}
name := strings.TrimSpace(toolName)
if name == "" || strings.EqualFold(name, "execute") {
return
return ""
}
if !isBuiltinEinoADKFilesystemToolName(name) {
return
return ""
}
args := toolCallArgsFromAccumulated(msgs, toolCallID, name)
if len(args) == 0 && binder != nil {
args = binder.Arguments(toolCallID)
}
storedName := "eino_fs::" + strings.ToLower(name)
var invErr error
if isErr {
@@ -138,4 +153,5 @@ func recordEinoADKFilesystemToolMonitor(
if id != "" && execID == "" {
rec(id, toolCallID)
}
return id
}
@@ -0,0 +1,170 @@
package multiagent
import (
"context"
"strings"
"testing"
"cyberstrike-ai/internal/agent"
"cyberstrike-ai/internal/config"
"cyberstrike-ai/internal/einomcp"
"cyberstrike-ai/internal/mcp"
"github.com/cloudwego/eino/adk"
"github.com/cloudwego/eino/components/tool"
"github.com/cloudwego/eino/schema"
"go.uber.org/zap"
)
func TestEinoADKFilesystemToolMonitorBindsFinishesAndUpdatesDisplayResult(t *testing.T) {
t.Parallel()
ctx := context.Background()
logger := zap.NewNop()
server := mcp.NewServer(logger)
ag := agent.NewAgent(&config.OpenAIConfig{}, &config.AgentConfig{}, server, nil, logger, 1)
binder := NewMCPExecutionBinder()
var recorded []string
rec := einomcp.ExecutionRecorder(func(executionID, toolCallID string) {
recorded = append(recorded, executionID+"|"+toolCallID)
})
beginEinoADKFilesystemToolMonitor(ctx, ag, rec, binder, "call-read", "read_file", map[string]interface{}{"path": "/tmp/secret.txt"})
execID := binder.ExecutionID("call-read")
if execID == "" {
t.Fatal("expected begin to bind execution id")
}
exec, ok := server.GetExecution(execID)
if !ok || exec == nil || exec.Status != "running" || exec.ToolName != "eino_fs::read_file" {
t.Fatalf("begin execution = %#v ok=%v", exec, ok)
}
if len(recorded) != 1 || recorded[0] != execID+"|call-read" {
t.Fatalf("recorded begin ids = %#v", recorded)
}
if got, _ := exec.Arguments["path"].(string); got != "/tmp/secret.txt" {
t.Fatalf("begin execution args = %#v", exec.Arguments)
}
runMessages := newEinoRunMessageAccumulator([]adk.Message{
&schema.Message{
Role: schema.Assistant,
ToolCalls: []schema.ToolCall{{
ID: "call-read",
Type: "function",
Function: schema.FunctionCall{
Name: "read_file",
Arguments: `{"path":"/tmp/secret.txt"}`,
},
}},
},
})
emitter := newEinoToolResultProgressEmitter(einoToolResultProgressEmitterConfig{
ConversationID: "conv-1",
RunMessages: runMessages,
FilesystemMonitorAgent: ag,
FilesystemMonitorRecord: rec,
MCPExecutionBinder: binder,
})
if !emitter.Emit(ctx, "read_file", "model-facing truncated body", "call-read", false, "lead") {
t.Fatal("expected tool_result emit")
}
exec, ok = server.GetExecution(execID)
if !ok || exec == nil {
t.Fatalf("finished execution missing: ok=%v exec=%#v", ok, exec)
}
if exec.Status != "completed" || exec.ToolName != "eino_fs::read_file" {
t.Fatalf("finished execution status/name = %#v", exec)
}
if got, _ := exec.Arguments["path"].(string); got != "/tmp/secret.txt" {
t.Fatalf("execution args = %#v", exec.Arguments)
}
if exec.Result == nil || len(exec.Result.Content) != 1 || exec.Result.Content[0].Text != "model-facing truncated body" {
t.Fatalf("execution display result = %#v", exec.Result)
}
if len(recorded) != 1 {
t.Fatalf("finish should reuse existing execution without recording a second id, got %#v", recorded)
}
}
func TestEinoADKFilesystemToolMonitorSpillsLargeReadFileResultForProgress(t *testing.T) {
t.Parallel()
ctx := context.Background()
logger := zap.NewNop()
server := mcp.NewServer(logger)
server.ConfigureToolResultMaxBytes(400)
server.ConfigureToolResultSpillRoot(t.TempDir())
ag := agent.NewAgent(&config.OpenAIConfig{}, &config.AgentConfig{}, server, nil, logger, 1)
binder := NewMCPExecutionBinder()
rec := einomcp.ExecutionRecorder(func(executionID, toolCallID string) {})
var event map[string]interface{}
runMessages := newEinoRunMessageAccumulator([]adk.Message{
&schema.Message{
Role: schema.Assistant,
ToolCalls: []schema.ToolCall{{
ID: "call-read",
Type: "function",
Function: schema.FunctionCall{
Name: "read_file",
Arguments: `{"path":"/tmp/large.txt"}`,
},
}},
},
})
beginEinoADKFilesystemToolMonitor(ctx, ag, rec, binder, "call-read", "read_file", map[string]interface{}{"path": "/tmp/large.txt"})
emitter := newEinoToolResultProgressEmitter(einoToolResultProgressEmitterConfig{
ConversationID: "conv-1",
RunMessages: runMessages,
FilesystemMonitorAgent: ag,
FilesystemMonitorRecord: rec,
MCPExecutionBinder: binder,
Progress: func(eventType, _ string, data interface{}) {
if eventType == "tool_result" {
event, _ = data.(map[string]interface{})
}
},
})
if !emitter.Emit(ctx, "read_file", strings.Repeat("0123456789", 100), "call-read", false, "lead") {
t.Fatal("expected tool result emit")
}
result, _ := event["result"].(string)
if !strings.Contains(result, "<persisted-output>") || !strings.Contains(result, "Full output saved to:") {
t.Fatalf("large read_file result was not spilled in progress event: %q", result)
}
if len(result) > 400 {
t.Fatalf("progress result exceeded configured max: len=%d text=%q", len(result), result)
}
}
func TestEinoAgenticFilesystemWrapperCapturesArgumentsAndSpillsResult(t *testing.T) {
t.Parallel()
binder := NewMCPExecutionBinder()
mw := &einoAgenticFilesystemToolMiddleware{
TypedChatModelAgentMiddleware: &adk.TypedBaseChatModelAgentMiddleware[*schema.AgenticMessage]{},
conversationID: "conv-1",
toolMaxBytes: 400,
reductionRootDir: t.TempDir(),
binder: binder,
}
endpoint, err := mw.WrapInvokableToolCall(context.Background(), func(ctx context.Context, argumentsInJSON string, opts ...tool.Option) (string, error) {
return strings.Repeat("0123456789", 100), nil
}, &adk.ToolContext{Name: "read_file", CallID: "call-read"})
if err != nil {
t.Fatalf("WrapInvokableToolCall: %v", err)
}
result, err := endpoint(context.Background(), `{"file_path":"/tmp/requirements.txt","limit":2000}`)
if err != nil {
t.Fatalf("endpoint: %v", err)
}
args := binder.Arguments("call-read")
if args["file_path"] != "/tmp/requirements.txt" {
t.Fatalf("captured args = %#v", args)
}
if !strings.Contains(result, "<persisted-output>") || !strings.Contains(result, "Full output saved to:") {
t.Fatalf("expected persisted-output summary, got %q", result)
}
if len(result) > 400 {
t.Fatalf("summary exceeded max bytes: len=%d", len(result))
}
}
@@ -0,0 +1,54 @@
package multiagent
import "github.com/cloudwego/eino/adk"
type einoAgentEventIteratorStarter func([]adk.Message) *adk.AsyncIterator[*adk.AgentEvent]
type einoInitialIteratorStartHandlerConfig struct {
ConversationID string
OrchMode string
Progress func(eventType, message string, data interface{})
UseTurnLoop bool
StartRunner einoAgentEventIteratorStarter
StartTurnLoop einoAgentEventIteratorStarter
}
type einoInitialIteratorStartHandler struct {
cfg einoInitialIteratorStartHandlerConfig
}
func newEinoInitialIteratorStartHandler(cfg einoInitialIteratorStartHandlerConfig) *einoInitialIteratorStartHandler {
return &einoInitialIteratorStartHandler{cfg: cfg}
}
func (h *einoInitialIteratorStartHandler) StartIfNeeded(existing *adk.AsyncIterator[*adk.AgentEvent], msgs []adk.Message) *adk.AsyncIterator[*adk.AgentEvent] {
if existing != nil {
return existing
}
if h == nil {
return nil
}
if h.cfg.UseTurnLoop {
h.emitTurnLoopTakeover()
if h.cfg.StartTurnLoop == nil {
return nil
}
return h.cfg.StartTurnLoop(msgs)
}
if h.cfg.StartRunner == nil {
return nil
}
return h.cfg.StartRunner(msgs)
}
func (h *einoInitialIteratorStartHandler) emitTurnLoopTakeover() {
if h == nil || h.cfg.Progress == nil {
return
}
h.cfg.Progress("progress", "Eino TurnLoop 常驻多轮 runtime 已接管本轮会话。", map[string]interface{}{
"conversationId": h.cfg.ConversationID,
"source": "eino",
"orchestration": h.cfg.OrchMode,
"kind": "turn_loop_takeover",
})
}
@@ -0,0 +1,111 @@
package multiagent
import (
"testing"
"github.com/cloudwego/eino/adk"
)
func TestEinoInitialIteratorStartHandlerKeepsExistingIterator(t *testing.T) {
existing, gen := adk.NewAsyncIteratorPair[*adk.AgentEvent]()
defer gen.Close()
var started bool
got := newEinoInitialIteratorStartHandler(einoInitialIteratorStartHandlerConfig{
UseTurnLoop: true,
StartTurnLoop: func([]adk.Message) *adk.AsyncIterator[*adk.AgentEvent] {
started = true
iter, iterGen := adk.NewAsyncIteratorPair[*adk.AgentEvent]()
iterGen.Close()
return iter
},
Progress: func(string, string, interface{}) {
t.Fatal("progress should not be emitted when an iterator already exists")
},
}).StartIfNeeded(existing, nil)
if got != existing {
t.Fatal("existing iterator should be preserved")
}
if started {
t.Fatal("start function should not be called when an iterator already exists")
}
}
func TestEinoInitialIteratorStartHandlerStartsRunner(t *testing.T) {
wantIter, gen := adk.NewAsyncIteratorPair[*adk.AgentEvent]()
defer gen.Close()
var runnerStarted bool
got := newEinoInitialIteratorStartHandler(einoInitialIteratorStartHandlerConfig{
StartRunner: func(msgs []adk.Message) *adk.AsyncIterator[*adk.AgentEvent] {
runnerStarted = true
if msgs == nil {
t.Fatal("msgs should be forwarded")
}
return wantIter
},
StartTurnLoop: func([]adk.Message) *adk.AsyncIterator[*adk.AgentEvent] {
t.Fatal("turn loop should not start when UseTurnLoop is false")
return nil
},
Progress: func(string, string, interface{}) {
t.Fatal("runner start should not emit TurnLoop takeover progress")
},
}).StartIfNeeded(nil, []adk.Message{})
if !runnerStarted {
t.Fatal("runner start was not called")
}
if got != wantIter {
t.Fatal("runner iterator should be returned")
}
}
func TestEinoInitialIteratorStartHandlerStartsTurnLoopWithTakeoverProgress(t *testing.T) {
wantIter, gen := adk.NewAsyncIteratorPair[*adk.AgentEvent]()
defer gen.Close()
var turnLoopStarted bool
var gotType, gotMessage string
var gotData map[string]interface{}
got := newEinoInitialIteratorStartHandler(einoInitialIteratorStartHandlerConfig{
ConversationID: "conv-1",
OrchMode: "deep",
UseTurnLoop: true,
StartRunner: func([]adk.Message) *adk.AsyncIterator[*adk.AgentEvent] {
t.Fatal("runner should not start when UseTurnLoop is true")
return nil
},
StartTurnLoop: func(msgs []adk.Message) *adk.AsyncIterator[*adk.AgentEvent] {
turnLoopStarted = true
if msgs == nil {
t.Fatal("msgs should be forwarded")
}
return wantIter
},
Progress: func(eventType, message string, data interface{}) {
gotType = eventType
gotMessage = message
if m, ok := data.(map[string]interface{}); ok {
gotData = m
}
},
}).StartIfNeeded(nil, []adk.Message{})
if !turnLoopStarted {
t.Fatal("turn loop start was not called")
}
if got != wantIter {
t.Fatal("turn loop iterator should be returned")
}
if gotType != "progress" {
t.Fatalf("progress type = %q, want progress", gotType)
}
if gotMessage != "Eino TurnLoop 常驻多轮 runtime 已接管本轮会话。" {
t.Fatalf("progress message = %q", gotMessage)
}
if gotData["conversationId"] != "conv-1" || gotData["source"] != "eino" || gotData["orchestration"] != "deep" {
t.Fatalf("progress data = %#v", gotData)
}
}
@@ -0,0 +1,49 @@
package multiagent
import "strings"
type einoMainAssistantCompleteHandler struct {
agentName string
emitter *einoMainResponseStreamEmitter
stdoutSuppressor *einoExecuteStdoutSuppressor
assistantOutput *einoAssistantOutputAccumulator
}
type einoMainAssistantCompleteHandlerConfig struct {
AgentName string
Emitter *einoMainResponseStreamEmitter
StdoutSuppressor *einoExecuteStdoutSuppressor
AssistantOutput *einoAssistantOutputAccumulator
}
func newEinoMainAssistantCompleteHandler(cfg einoMainAssistantCompleteHandlerConfig) *einoMainAssistantCompleteHandler {
return &einoMainAssistantCompleteHandler{
agentName: cfg.AgentName,
emitter: cfg.Emitter,
stdoutSuppressor: cfg.StdoutSuppressor,
assistantOutput: cfg.AssistantOutput,
}
}
func (h *einoMainAssistantCompleteHandler) EmitComplete(content string) bool {
if h == nil {
return false
}
body := strings.TrimSpace(content)
if body == "" {
return false
}
if h.stdoutSuppressor != nil {
if dup := h.stdoutSuppressor.Consume(); dup != "" && body == dup {
if h.assistantOutput != nil {
h.assistantOutput.RecordMainAssistant(h.agentName, body)
}
return false
}
}
emitted := h.emitter.EmitDelta(body, body)
if h.assistantOutput != nil {
h.assistantOutput.RecordMainAssistant(h.agentName, body)
}
return emitted
}
@@ -0,0 +1,76 @@
package multiagent
import "testing"
func TestEinoMainAssistantCompleteHandlerEmitsAndRecords(t *testing.T) {
var eventTypes []string
var messages []string
progress := func(eventType, message string, _ interface{}) {
eventTypes = append(eventTypes, eventType)
messages = append(messages, message)
}
out := newEinoAssistantOutputAccumulator("deep")
handler := newEinoMainAssistantCompleteHandler(einoMainAssistantCompleteHandlerConfig{
AgentName: "lead",
Emitter: newEinoMainResponseStreamEmitter("conv-1", "deep", "lead", "stream-1", 2, progress, nil),
AssistantOutput: out,
})
if !handler.EmitComplete(" hello ") {
t.Fatal("complete assistant should emit")
}
if len(eventTypes) != 2 || eventTypes[0] != "response_start" || eventTypes[1] != "response_delta" {
t.Fatalf("events = %#v", eventTypes)
}
if messages[1] != "hello" {
t.Fatalf("delta message = %q", messages[1])
}
if out.LastAssistant() != "hello" {
t.Fatalf("last assistant = %q", out.LastAssistant())
}
}
func TestEinoMainAssistantCompleteHandlerSuppressesDuplicateExecuteStdout(t *testing.T) {
var eventTypes []string
progress := func(eventType, _ string, _ interface{}) {
eventTypes = append(eventTypes, eventType)
}
stdoutDup := newEinoExecuteStdoutSuppressor()
stdoutDup.Record("execute", "hello", false)
out := newEinoAssistantOutputAccumulator("deep")
handler := newEinoMainAssistantCompleteHandler(einoMainAssistantCompleteHandlerConfig{
AgentName: "lead",
Emitter: newEinoMainResponseStreamEmitter("conv-1", "deep", "lead", "stream-1", 1, progress, nil),
StdoutSuppressor: stdoutDup,
AssistantOutput: out,
})
if handler.EmitComplete("hello") {
t.Fatal("duplicate execute stdout should not emit")
}
if len(eventTypes) != 0 {
t.Fatalf("events = %#v, want none", eventTypes)
}
if out.LastAssistant() != "hello" {
t.Fatalf("last assistant = %q", out.LastAssistant())
}
if stdoutDup.Peek() != "" {
t.Fatal("duplicate target should be consumed")
}
}
func TestEinoMainAssistantCompleteHandlerRecordsWithoutProgress(t *testing.T) {
out := newEinoAssistantOutputAccumulator("plan_execute")
handler := newEinoMainAssistantCompleteHandler(einoMainAssistantCompleteHandlerConfig{
AgentName: "executor",
Emitter: newEinoMainResponseStreamEmitter("conv-1", "plan_execute", "executor", "stream-1", 1, nil, nil),
AssistantOutput: out,
})
if handler.EmitComplete(`{"response":"done"}`) {
t.Fatal("nil progress should not emit")
}
if out.LastPlanExecuteExecutor() != "done" {
t.Fatalf("executor output = %q", out.LastPlanExecuteExecutor())
}
}
@@ -0,0 +1,77 @@
package multiagent
import "strings"
type einoMainAssistantStreamHandler struct {
agentName string
emitter *einoMainResponseStreamEmitter
stdoutSuppressor *einoExecuteStdoutSuppressor
assistantOutput *einoAssistantOutputAccumulator
runMessages *einoRunMessageAccumulator
buf string
dupTarget string
}
type einoMainAssistantStreamHandlerConfig struct {
AgentName string
Emitter *einoMainResponseStreamEmitter
StdoutSuppressor *einoExecuteStdoutSuppressor
AssistantOutput *einoAssistantOutputAccumulator
RunMessages *einoRunMessageAccumulator
}
func newEinoMainAssistantStreamHandler(cfg einoMainAssistantStreamHandlerConfig) *einoMainAssistantStreamHandler {
return &einoMainAssistantStreamHandler{
agentName: cfg.AgentName,
emitter: cfg.Emitter,
stdoutSuppressor: cfg.StdoutSuppressor,
assistantOutput: cfg.AssistantOutput,
runMessages: cfg.RunMessages,
}
}
func (h *einoMainAssistantStreamHandler) EmitDelta(content string) bool {
if h == nil || content == "" {
return false
}
var delta string
h.buf, delta = normalizeStreamingDelta(h.buf, content)
if delta == "" {
return false
}
if h.dupTarget == "" && h.stdoutSuppressor != nil {
h.dupTarget = h.stdoutSuppressor.Peek()
}
if h.dupTarget != "" {
return false
}
return h.emitter.EmitDelta(delta, h.buf)
}
func (h *einoMainAssistantStreamHandler) Finish() string {
if h == nil {
return ""
}
body := strings.TrimSpace(h.buf)
if body == "" {
return ""
}
if h.dupTarget != "" {
if h.stdoutSuppressor != nil {
h.stdoutSuppressor.Clear()
}
if body != h.dupTarget {
h.emitter.EmitTailFromFull(h.buf)
}
} else {
h.emitter.EmitTailFromFull(h.buf)
}
if h.assistantOutput != nil {
h.assistantOutput.RecordMainAssistant(h.agentName, body)
}
if h.runMessages != nil {
h.runMessages.AppendAssistantText(body)
}
return body
}
@@ -0,0 +1,103 @@
package multiagent
import "testing"
func TestEinoMainAssistantStreamHandlerEmitsAndRecords(t *testing.T) {
var eventTypes []string
var messages []string
progress := func(eventType, message string, _ interface{}) {
eventTypes = append(eventTypes, eventType)
messages = append(messages, message)
}
out := newEinoAssistantOutputAccumulator("deep")
runMsgs := newEinoRunMessageAccumulator(nil)
emitter := newEinoMainResponseStreamEmitter("conv-1", "deep", "lead", "stream-1", 2, progress, nil)
handler := newEinoMainAssistantStreamHandler(einoMainAssistantStreamHandlerConfig{
AgentName: "lead",
Emitter: emitter,
AssistantOutput: out,
RunMessages: runMsgs,
})
if !handler.EmitDelta("he") {
t.Fatal("first delta should emit")
}
if !handler.EmitDelta("hello") {
t.Fatal("cumulative chunk should emit tail")
}
if got := handler.Finish(); got != "hello" {
t.Fatalf("finish = %q, want hello", got)
}
if len(eventTypes) != 3 || eventTypes[0] != "response_start" || eventTypes[1] != "response_delta" || eventTypes[2] != "response_delta" {
t.Fatalf("events = %#v", eventTypes)
}
if messages[1] != "he" || messages[2] != "llo" {
t.Fatalf("delta messages = %#v", messages)
}
if out.LastAssistant() != "hello" {
t.Fatalf("last assistant = %q", out.LastAssistant())
}
if msgs := runMsgs.Messages(); len(msgs) != 1 || msgs[0].Content != "hello" {
t.Fatalf("run messages = %#v", msgs)
}
}
func TestEinoMainAssistantStreamHandlerSuppressesDuplicateExecuteStdout(t *testing.T) {
var eventTypes []string
progress := func(eventType, _ string, _ interface{}) {
eventTypes = append(eventTypes, eventType)
}
stdoutDup := newEinoExecuteStdoutSuppressor()
stdoutDup.Record("execute", "hello", false)
out := newEinoAssistantOutputAccumulator("deep")
runMsgs := newEinoRunMessageAccumulator(nil)
handler := newEinoMainAssistantStreamHandler(einoMainAssistantStreamHandlerConfig{
AgentName: "lead",
Emitter: newEinoMainResponseStreamEmitter("conv-1", "deep", "lead", "stream-1", 1, progress, nil),
StdoutSuppressor: stdoutDup,
AssistantOutput: out,
RunMessages: runMsgs,
})
if handler.EmitDelta("hello") {
t.Fatal("duplicate execute stdout should not emit delta")
}
if got := handler.Finish(); got != "hello" {
t.Fatalf("finish = %q, want hello", got)
}
if len(eventTypes) != 0 {
t.Fatalf("events = %#v, want none", eventTypes)
}
if stdoutDup.Peek() != "" {
t.Fatal("duplicate target should be cleared on finish")
}
if out.LastAssistant() != "hello" {
t.Fatalf("last assistant = %q", out.LastAssistant())
}
if msgs := runMsgs.Messages(); len(msgs) != 1 || msgs[0].Content != "hello" {
t.Fatalf("run messages = %#v", msgs)
}
}
func TestEinoMainAssistantStreamHandlerRecordsWithoutProgress(t *testing.T) {
out := newEinoAssistantOutputAccumulator("plan_execute")
runMsgs := newEinoRunMessageAccumulator(nil)
handler := newEinoMainAssistantStreamHandler(einoMainAssistantStreamHandlerConfig{
AgentName: "executor",
Emitter: newEinoMainResponseStreamEmitter("conv-1", "plan_execute", "executor", "stream-1", 1, nil, nil),
AssistantOutput: out,
RunMessages: runMsgs,
})
handler.EmitDelta(`{"response":"done"}`)
if got := handler.Finish(); got != `{"response":"done"}` {
t.Fatalf("finish = %q", got)
}
if out.LastPlanExecuteExecutor() != "done" {
t.Fatalf("executor output = %q", out.LastPlanExecuteExecutor())
}
if msgs := runMsgs.Messages(); len(msgs) != 1 || msgs[0].Content != `{"response":"done"}` {
t.Fatalf("run messages = %#v", msgs)
}
}
@@ -0,0 +1,85 @@
package multiagent
import "cyberstrike-ai/internal/openai"
type einoMainResponseStreamEmitter struct {
progress func(eventType, message string, data interface{})
snapshotMCPIDs func() []string
conversationID string
orchMode string
agentName string
streamID string
iteration int
headerSent bool
wireAccum string
}
func newEinoMainResponseStreamEmitter(
conversationID, orchMode, agentName, streamID string,
iteration int,
progress func(eventType, message string, data interface{}),
snapshotMCPIDs func() []string,
) *einoMainResponseStreamEmitter {
if snapshotMCPIDs == nil {
snapshotMCPIDs = func() []string { return nil }
}
return &einoMainResponseStreamEmitter{
progress: progress,
snapshotMCPIDs: snapshotMCPIDs,
conversationID: conversationID,
orchMode: orchMode,
agentName: agentName,
streamID: streamID,
iteration: iteration,
}
}
func (e *einoMainResponseStreamEmitter) EmitDelta(delta, accumulated string) bool {
if e == nil || e.progress == nil || delta == "" {
return false
}
e.emitStart()
e.progress("response_delta", delta, openai.WithSSEAccumulated(e.responseData(), accumulated))
e.wireAccum, _ = normalizeStreamingDelta(e.wireAccum, delta)
return true
}
func (e *einoMainResponseStreamEmitter) EmitTailFromFull(full string) bool {
if e == nil || full == "" {
return false
}
_, tail := normalizeStreamingDelta(e.wireAccum, full)
if tail == "" {
return false
}
return e.EmitDelta(tail, full)
}
func (e *einoMainResponseStreamEmitter) emitStart() {
if e.headerSent || e.progress == nil {
return
}
e.progress("response_start", "", map[string]interface{}{
"conversationId": e.conversationID,
"mcpExecutionIds": e.snapshotMCPIDs(),
"messageGeneratedBy": "eino:" + e.agentName,
"einoRole": "orchestrator",
"einoAgent": e.agentName,
"orchestration": e.orchMode,
"iteration": e.iteration,
"streamId": e.streamID,
})
e.headerSent = true
}
func (e *einoMainResponseStreamEmitter) responseData() map[string]interface{} {
return map[string]interface{}{
"conversationId": e.conversationID,
"mcpExecutionIds": e.snapshotMCPIDs(),
"einoRole": "orchestrator",
"einoAgent": e.agentName,
"orchestration": e.orchMode,
"iteration": e.iteration,
"streamId": e.streamID,
}
}
@@ -0,0 +1,65 @@
package multiagent
import (
"testing"
"cyberstrike-ai/internal/openai"
)
func TestEinoMainResponseStreamEmitterEmitsStartOnceAndTail(t *testing.T) {
type progressEvent struct {
eventType string
message string
data map[string]interface{}
}
var events []progressEvent
progress := func(eventType, message string, data interface{}) {
m, _ := data.(map[string]interface{})
events = append(events, progressEvent{eventType: eventType, message: message, data: m})
}
emitter := newEinoMainResponseStreamEmitter(
"conv-1", "supervisor", "lead", "stream-1", 3, progress, func() []string { return []string{"mcp-1"} },
)
if !emitter.EmitDelta("he", "he") {
t.Fatal("first delta should be emitted")
}
if !emitter.EmitTailFromFull("hello") {
t.Fatal("tail should be emitted")
}
if emitter.EmitTailFromFull("hello") {
t.Fatal("duplicate tail should not be emitted")
}
if len(events) != 3 {
t.Fatalf("events = %#v, want start + 2 deltas", events)
}
if events[0].eventType != "response_start" {
t.Fatalf("event[0] = %s, want response_start", events[0].eventType)
}
if events[1].eventType != "response_delta" || events[1].message != "he" {
t.Fatalf("event[1] = %#v, want first delta", events[1])
}
if events[2].eventType != "response_delta" || events[2].message != "llo" {
t.Fatalf("event[2] = %#v, want tail delta", events[2])
}
if got := events[2].data[openai.SSEAccumulatedKey]; got != "hello" {
t.Fatalf("accumulated = %#v, want hello", got)
}
if got := events[0].data["messageGeneratedBy"]; got != "eino:lead" {
t.Fatalf("messageGeneratedBy = %#v", got)
}
if got := events[0].data["iteration"]; got != 3 {
t.Fatalf("iteration = %#v", got)
}
}
func TestEinoMainResponseStreamEmitterNoProgress(t *testing.T) {
emitter := newEinoMainResponseStreamEmitter("conv", "deep", "agent", "stream", 1, nil, nil)
if emitter.EmitDelta("hello", "hello") {
t.Fatal("nil progress should not emit")
}
if emitter.EmitTailFromFull("hello") {
t.Fatal("nil progress should not emit tail")
}
}
@@ -0,0 +1,115 @@
package multiagent
import (
"strings"
"github.com/cloudwego/eino/adk"
"github.com/cloudwego/eino/schema"
)
type einoMaterializedMessageEventHandlerConfig struct {
ConversationID string
OrchMode string
Progress func(eventType, message string, data interface{})
SnapshotMCPIDs func() []string
StreamsMainAssistant func(agent string) bool
EinoRoleTag func(agent string) string
RunProgress *einoRunProgressTracker
StdoutSuppressor *einoExecuteStdoutSuppressor
AssistantOutput *einoAssistantOutputAccumulator
RunMessages *einoRunMessageAccumulator
Usage *einoRunUsageAccumulator
ToolResultHandler *einoToolResultEventHandler
MarkPending func(toolCallPendingInfo)
NextMainStreamID func() string
}
type einoMaterializedMessageEventHandler struct {
conversationID string
orchMode string
progress func(eventType, message string, data interface{})
snapshotMCPIDs func() []string
streamsMainAssistant func(agent string) bool
einoRoleTag func(agent string) string
runProgress *einoRunProgressTracker
stdoutSuppressor *einoExecuteStdoutSuppressor
assistantOutput *einoAssistantOutputAccumulator
runMessages *einoRunMessageAccumulator
usage *einoRunUsageAccumulator
toolResultHandler *einoToolResultEventHandler
markPending func(toolCallPendingInfo)
nextMainStreamID func() string
}
func newEinoMaterializedMessageEventHandler(cfg einoMaterializedMessageEventHandlerConfig) *einoMaterializedMessageEventHandler {
if cfg.SnapshotMCPIDs == nil {
cfg.SnapshotMCPIDs = func() []string { return nil }
}
if cfg.StreamsMainAssistant == nil {
cfg.StreamsMainAssistant = func(string) bool { return true }
}
if cfg.EinoRoleTag == nil {
cfg.EinoRoleTag = func(string) string { return "" }
}
if cfg.NextMainStreamID == nil {
cfg.NextMainStreamID = func() string { return "eino-main" }
}
return &einoMaterializedMessageEventHandler{
conversationID: cfg.ConversationID,
orchMode: cfg.OrchMode,
progress: cfg.Progress,
snapshotMCPIDs: cfg.SnapshotMCPIDs,
streamsMainAssistant: cfg.StreamsMainAssistant,
einoRoleTag: cfg.EinoRoleTag,
runProgress: cfg.RunProgress,
stdoutSuppressor: cfg.StdoutSuppressor,
assistantOutput: cfg.AssistantOutput,
runMessages: cfg.RunMessages,
usage: cfg.Usage,
toolResultHandler: cfg.ToolResultHandler,
markPending: cfg.MarkPending,
nextMainStreamID: cfg.NextMainStreamID,
}
}
func (h *einoMaterializedMessageEventHandler) Handle(mv *adk.MessageVariant, msg adk.Message, agentName string) bool {
if h == nil || mv == nil || msg == nil {
return false
}
if h.runMessages != nil {
h.runMessages.Append(msg)
}
if msg.Role == schema.Assistant && h.usage != nil {
h.usage.AddMessage(msg)
}
if h.runProgress != nil {
h.runProgress.EmitToolCalls(mergeMessageToolCalls(msg), agentName, h.markPending)
}
if mv.Role == schema.Assistant {
newEinoReasoningStreamEmitter(h.conversationID, h.orchMode, agentName, h.einoRoleTag(agentName), h.progress, nil).EmitComplete(msg.ReasoningContent)
body := strings.TrimSpace(msg.Content)
if body != "" {
if h.streamsMainAssistant(agentName) {
newEinoMainAssistantCompleteHandler(einoMainAssistantCompleteHandlerConfig{
AgentName: agentName,
Emitter: newEinoMainResponseStreamEmitter(h.conversationID, h.orchMode, agentName, h.nextMainStreamID(), h.mainIteration(agentName), h.progress, h.snapshotMCPIDs),
StdoutSuppressor: h.stdoutSuppressor,
AssistantOutput: h.assistantOutput,
}).EmitComplete(body)
} else {
newEinoSubAgentReplyEmitter(h.conversationID, agentName, h.progress, nil).EmitComplete(body)
}
}
}
if h.toolResultHandler != nil {
h.toolResultHandler.HandleMaterialized(mv, msg, agentName)
}
return true
}
func (h *einoMaterializedMessageEventHandler) mainIteration(agentName string) int {
if h == nil || h.runProgress == nil {
return 0
}
return h.runProgress.MainIteration(agentName)
}
@@ -0,0 +1,151 @@
package multiagent
import (
"testing"
"cyberstrike-ai/internal/einomcp"
"github.com/cloudwego/eino/adk"
"github.com/cloudwego/eino/schema"
)
func TestEinoMaterializedMessageEventHandlerHandlesMainAssistant(t *testing.T) {
var events []string
runMessages := newEinoRunMessageAccumulator(nil)
assistantOutput := newEinoAssistantOutputAccumulator("deep")
usage := newEinoRunUsageAccumulator()
runProgress := newEinoRunProgressTracker(
"deep", "lead", "conv-1",
func(eventType, _ string, _ interface{}) { events = append(events, eventType) },
func(agent string) bool { return agent == "lead" },
nil,
)
handler := newEinoMaterializedMessageEventHandler(einoMaterializedMessageEventHandlerConfig{
ConversationID: "conv-1",
OrchMode: "deep",
Progress: func(eventType, _ string, _ interface{}) { events = append(events, eventType) },
RunMessages: runMessages,
Usage: usage,
AssistantOutput: assistantOutput,
RunProgress: runProgress,
StreamsMainAssistant: func(agent string) bool { return agent == "lead" },
EinoRoleTag: func(string) string { return "orchestrator" },
NextMainStreamID: func() string { return "main-complete-1" },
})
msg := schema.AssistantMessage(" done ", nil)
msg.ReasoningContent = "thought"
msg.ResponseMeta = &schema.ResponseMeta{Usage: &schema.TokenUsage{
PromptTokens: 11,
CompletionTokens: 7,
TotalTokens: 18,
}}
mv := &adk.MessageVariant{Role: schema.Assistant}
if !handler.Handle(mv, msg, "lead") {
t.Fatal("main assistant message was not handled")
}
if assistantOutput.LastAssistant() != "done" {
t.Fatalf("last assistant = %q", assistantOutput.LastAssistant())
}
if msgs := runMessages.Messages(); len(msgs) != 1 || msgs[0].Content != " done " {
t.Fatalf("run messages = %#v", msgs)
}
if got := usage.Summary(); got.ModelCalls != 1 || got.TotalTokens != 18 {
t.Fatalf("usage = %#v, want one assistant model call", got)
}
if !containsString(events, "reasoning_chain") || !containsString(events, "response_start") || !containsString(events, "response_delta") {
t.Fatalf("events = %#v, want reasoning and response events", events)
}
}
func TestEinoMaterializedMessageEventHandlerHandlesSubAssistant(t *testing.T) {
var events []string
runMessages := newEinoRunMessageAccumulator(nil)
assistantOutput := newEinoAssistantOutputAccumulator("deep")
handler := newEinoMaterializedMessageEventHandler(einoMaterializedMessageEventHandlerConfig{
ConversationID: "conv-1",
OrchMode: "deep",
Progress: func(eventType, _ string, _ interface{}) { events = append(events, eventType) },
RunMessages: runMessages,
AssistantOutput: assistantOutput,
StreamsMainAssistant: func(agent string) bool { return agent == "lead" },
EinoRoleTag: func(string) string { return "sub" },
})
if !handler.Handle(&adk.MessageVariant{Role: schema.Assistant}, schema.AssistantMessage("sub done", nil), "worker") {
t.Fatal("sub assistant message was not handled")
}
if assistantOutput.LastAssistant() != "" {
t.Fatalf("sub assistant should not update main output, got %q", assistantOutput.LastAssistant())
}
if len(runMessages.Messages()) != 1 {
t.Fatalf("run messages = %#v, want appended original message", runMessages.Messages())
}
if !containsString(events, "eino_agent_reply") {
t.Fatalf("events = %#v, want sub reply event", events)
}
}
func TestEinoMaterializedMessageEventHandlerHandlesToolCallsAndToolResult(t *testing.T) {
var events []string
var marked []toolCallPendingInfo
runMessages := newEinoRunMessageAccumulator(nil)
runProgress := newEinoRunProgressTracker(
"deep", "lead", "conv-1",
func(eventType, _ string, _ interface{}) { events = append(events, eventType) },
func(agent string) bool { return agent == "lead" },
nil,
)
toolResultEmitter := newEinoToolResultProgressEmitter(einoToolResultProgressEmitterConfig{
ConversationID: "conv-1",
Progress: func(eventType, _ string, _ interface{}) { events = append(events, eventType) },
})
toolResultHandler := newEinoToolResultEventHandler(einoToolResultEventHandlerConfig{Emitter: toolResultEmitter})
handler := newEinoMaterializedMessageEventHandler(einoMaterializedMessageEventHandlerConfig{
ConversationID: "conv-1",
OrchMode: "deep",
Progress: func(eventType, _ string, _ interface{}) { events = append(events, eventType) },
RunMessages: runMessages,
RunProgress: runProgress,
ToolResultHandler: toolResultHandler,
MarkPending: func(info toolCallPendingInfo) {
marked = append(marked, info)
},
})
toolCallMsg := &schema.Message{
Role: schema.Assistant,
ToolCalls: []schema.ToolCall{{
ID: "call-1",
Type: "function",
Function: schema.FunctionCall{
Name: "execute",
Arguments: `{"command":`,
},
}},
}
if !handler.Handle(&adk.MessageVariant{Role: schema.Assistant}, toolCallMsg, "lead") {
t.Fatal("tool call message was not handled")
}
toolMsg := schema.ToolMessage(einomcp.ToolErrorPrefix+"bad command", "call-1", schema.WithToolName("execute"))
if !handler.Handle(&adk.MessageVariant{Role: schema.Tool}, toolMsg, "lead") {
t.Fatal("tool message was not handled")
}
if !containsString(events, "tool_call") || !containsString(events, "tool_result") || containsString(events, "model_output_rejected") {
t.Fatalf("events = %#v, want real tool_call and tool_result without model-output recovery", events)
}
if len(marked) != 1 || marked[0].ToolCallID != "call-1" || marked[0].ToolName != "execute" {
t.Fatalf("marked pending = %#v", marked)
}
if len(runMessages.Messages()) != 2 {
t.Fatalf("run messages = %#v, want assistant and tool messages", runMessages.Messages())
}
}
func TestEinoMaterializedMessageEventHandlerIgnoresNil(t *testing.T) {
handler := newEinoMaterializedMessageEventHandler(einoMaterializedMessageEventHandlerConfig{})
if handler.Handle(nil, nil, "lead") {
t.Fatal("nil message should be ignored")
}
}
@@ -0,0 +1,61 @@
package multiagent
import (
"context"
"errors"
"io"
"github.com/cloudwego/eino/schema"
)
// recvEinoSchemaMessageStreamWithContext consumes an Eino schema.Message stream
// and stops promptly when ctx is canceled. EOF and nil chunks are treated as a
// normal stream boundary.
func recvEinoSchemaMessageStreamWithContext(
ctx context.Context,
stream *schema.StreamReader[*schema.Message],
buffer int,
onChunk func(*schema.Message),
) error {
if stream == nil {
return nil
}
if buffer <= 0 {
buffer = 1
}
type streamMsg struct {
chunk *schema.Message
err error
}
recvCh := make(chan streamMsg, buffer)
go func() {
defer close(recvCh)
for {
ch, rerr := stream.Recv()
recvCh <- streamMsg{chunk: ch, err: rerr}
if rerr != nil {
return
}
}
}()
for {
select {
case <-ctx.Done():
return ctx.Err()
case sm, ok := <-recvCh:
if !ok {
return nil
}
if errors.Is(sm.err, io.EOF) {
return nil
}
if sm.err != nil {
return sm.err
}
if sm.chunk == nil || onChunk == nil {
continue
}
onChunk(sm.chunk)
}
}
}
+160
View File
@@ -17,6 +17,7 @@ import (
"github.com/cloudwego/eino/adk/middlewares/plantask"
"github.com/cloudwego/eino/adk/middlewares/reduction"
"github.com/cloudwego/eino/components/tool"
"github.com/cloudwego/eino/schema"
"go.uber.org/zap"
)
@@ -149,6 +150,43 @@ func buildReductionMiddleware(ctx context.Context, mw config.MultiAgentEinoMiddl
return redMW, nil
}
func buildAgenticReductionMiddleware(
ctx context.Context,
mw config.MultiAgentEinoMiddlewareConfig,
projectID, convID string,
loc *localbk.Local,
logger *zap.Logger,
) (adk.TypedChatModelAgentMiddleware[*schema.AgenticMessage], error) {
if loc == nil {
return nil, fmt.Errorf("agentic reduction: local backend nil")
}
root := reductionCacheRootDir(mw.ReductionRootDir, projectID, convID)
if err := os.MkdirAll(root, 0o755); err != nil {
return nil, fmt.Errorf("agentic reduction root: %w", err)
}
excl := append([]string(nil), mw.ReductionClearExclude...)
defaultExcl := []string{
"task", "transfer_to_agent", "exit", "write_todos", "skill", "tool_search",
"TaskCreate", "TaskGet", "TaskUpdate", "TaskList",
}
excl = append(excl, defaultExcl...)
redMW, err := reduction.NewTyped[*schema.AgenticMessage](ctx, &reduction.TypedConfig[*schema.AgenticMessage]{
Backend: loc,
RootDir: root,
ReadFileToolName: "read_file",
ClearExcludeTools: excl,
MaxLengthForTrunc: mw.ReductionMaxLengthForTruncEffective(),
MaxTokensForClear: int64(mw.ReductionMaxTokensForClearEffective()),
})
if err != nil {
return nil, err
}
if logger != nil {
logger.Info("eino middleware: agentic reduction enabled", zap.String("root", root))
}
return redMW, nil
}
// prependEinoMiddlewares returns handlers to prepend (outermost first) and optionally replaces tools when tool_search is used.
// toolSearchActive is true when the toolsearch middleware was mounted (dynamic tools split off); callers should pass this to
// injectToolNamesOnlyInstruction — tool_search is not part of the pre-middleware tools list, so name-scanning alone cannot detect it.
@@ -243,6 +281,97 @@ func prependEinoMiddlewares(
return outTools, extraHandlers, toolSearchActive, nil
}
func prependEinoAgenticMiddlewares(
ctx context.Context,
mw *config.MultiAgentEinoMiddlewareConfig,
place einoMWPlacement,
tools []tool.BaseTool,
einoLoc *localbk.Local,
skillsRoot string,
conversationID string,
projectID string,
logger *zap.Logger,
) (outTools []tool.BaseTool, extraHandlers []adk.TypedChatModelAgentMiddleware[*schema.AgenticMessage], toolSearchActive bool, err error) {
if mw == nil {
return tools, nil, false, nil
}
outTools = tools
if mw.PatchToolCallsEffective() {
patchMW, perr := patchtoolcalls.NewTyped[*schema.AgenticMessage](ctx, &patchtoolcalls.Config{})
if perr != nil {
return nil, nil, false, fmt.Errorf("agentic patchtoolcalls: %w", perr)
}
extraHandlers = append(extraHandlers, patchMW)
}
if mw.ReductionEnable && einoLoc != nil {
if place == einoMWSub && !mw.ReductionSubAgents {
// skip
} else {
redMW, rerr := buildAgenticReductionMiddleware(ctx, *mw, projectID, conversationID, einoLoc, logger)
if rerr != nil {
return nil, nil, false, rerr
}
extraHandlers = append(extraHandlers, redMW)
}
}
minTools := mw.ToolSearchMinTools
if minTools <= 0 {
minTools = 20
}
alwaysVis := mw.ToolSearchAlwaysVisible
if alwaysVis <= 0 {
alwaysVis = 12
}
if mw.ToolSearchEnable && len(tools) >= minTools {
static, dynamic, split := splitToolsForToolSearchByNames(tools, mergeAlwaysVisibleToolNames(mw.ToolSearchAlwaysVisibleTools), alwaysVis)
if split && len(dynamic) > 0 {
ts, terr := toolsearch.NewTyped[*schema.AgenticMessage](ctx, &toolsearch.Config{DynamicTools: dynamic})
if terr != nil {
return nil, nil, false, fmt.Errorf("agentic toolsearch: %w", terr)
}
extraHandlers = append(extraHandlers, ts)
outTools = static
toolSearchActive = true
if logger != nil {
logger.Info("eino middleware: agentic tool_search enabled",
zap.Int("static_tools", len(static)),
zap.Int("dynamic_tools", len(dynamic)))
}
}
}
if place == einoMWMain && mw.PlantaskEnable {
if einoLoc == nil || strings.TrimSpace(skillsRoot) == "" {
if logger != nil {
logger.Warn("eino middleware: agentic plantask_enable ignored (need eino_skills + skills_dir)")
}
} else {
rel := strings.TrimSpace(mw.PlantaskRelDir)
if rel == "" {
rel = ".eino/plantask"
}
baseDir := filepath.Join(skillsRoot, rel, sanitizeEinoPathSegment(conversationID))
if mk := os.MkdirAll(baseDir, 0o755); mk != nil {
return nil, nil, toolSearchActive, fmt.Errorf("agentic plantask mkdir: %w", mk)
}
ptBE := newLocalPlantaskBackend(einoLoc)
pt, perr := plantask.NewTyped[*schema.AgenticMessage](ctx, &plantask.Config{Backend: ptBE, BaseDir: baseDir})
if perr != nil {
return nil, nil, toolSearchActive, fmt.Errorf("agentic plantask: %w", perr)
}
extraHandlers = append(extraHandlers, pt)
if logger != nil {
logger.Info("eino middleware: agentic plantask enabled", zap.String("baseDir", baseDir))
}
}
}
return outTools, extraHandlers, toolSearchActive, nil
}
func deepExtrasFromConfig(ma *config.MultiAgentConfig) (outputKey string, taskDesc func(context.Context, []adk.Agent) (string, error)) {
if ma == nil {
return "", nil
@@ -273,3 +402,34 @@ func deepExtrasFromConfig(ma *config.MultiAgentConfig) (outputKey string, taskDe
}
return outputKey, taskDesc
}
func deepAgenticExtrasFromConfig(ma *config.MultiAgentConfig) (outputKey string, taskDesc func(context.Context, []adk.TypedAgent[*schema.AgenticMessage]) (string, error)) {
if ma == nil {
return "", nil
}
mw := ma.EinoMiddleware
if k := strings.TrimSpace(mw.DeepOutputKey); k != "" {
outputKey = k
}
prefix := strings.TrimSpace(mw.TaskToolDescriptionPrefix)
if prefix != "" {
taskDesc = func(ctx context.Context, agents []adk.TypedAgent[*schema.AgenticMessage]) (string, error) {
_ = ctx
var names []string
for _, a := range agents {
if a == nil {
continue
}
n := strings.TrimSpace(a.Name(ctx))
if n != "" {
names = append(names, n)
}
}
if len(names) == 0 {
return prefix, nil
}
return prefix + "\n可用子代理(按名称 transfer / task 调用):" + strings.Join(names, "、"), nil
}
}
return outputKey, taskDesc
}
+167
View File
@@ -7,6 +7,10 @@ import (
"strings"
"testing"
"cyberstrike-ai/internal/config"
localbk "github.com/cloudwego/eino-ext/adk/backend/local"
"github.com/cloudwego/eino/adk"
"github.com/cloudwego/eino/components/tool"
"github.com/cloudwego/eino/schema"
)
@@ -28,6 +32,169 @@ func TestReductionCacheRootDir(t *testing.T) {
}
}
func TestBuildAgenticReductionMiddlewareClearsOldAgenticToolResult(t *testing.T) {
ctx := context.Background()
loc, err := localbk.NewBackend(ctx, &localbk.Config{})
if err != nil {
t.Fatalf("NewBackend: %v", err)
}
root := t.TempDir()
mw, err := buildAgenticReductionMiddleware(ctx, config.MultiAgentEinoMiddlewareConfig{
ReductionRootDir: root,
ReductionMaxTokensForClear: 1,
}, "", "conv-1", loc, nil)
if err != nil {
t.Fatalf("buildAgenticReductionMiddleware: %v", err)
}
oldText := strings.Repeat("old-tool-output-", 20)
newText := strings.Repeat("new-tool-output-", 20)
state := &adk.TypedChatModelAgentState[*schema.AgenticMessage]{
Messages: []*schema.AgenticMessage{
agenticAssistantToolCall("old-call", "execute", `{"command":"old"}`),
agenticToolResult("old-call", "execute", oldText),
agenticAssistantToolCall("new-call", "execute", `{"command":"new"}`),
agenticToolResult("new-call", "execute", newText),
},
}
_, out, err := mw.BeforeModelRewriteState(ctx, state, nil)
if err != nil {
t.Fatalf("BeforeModelRewriteState: %v", err)
}
oldGot := out.Messages[1].ContentBlocks[0].FunctionToolResult.Content[0].Text.Text
newGot := out.Messages[3].ContentBlocks[0].FunctionToolResult.Content[0].Text.Text
if oldGot == oldText {
t.Fatal("agentic reduction did not clear old oversized tool result")
}
if !strings.Contains(oldGot, "read_file") {
t.Fatalf("cleared content should mention read_file, got %q", oldGot)
}
if newGot != newText {
t.Fatalf("latest tool result should be retained, got %q", newGot)
}
}
func agenticAssistantToolCall(callID, name, arguments string) *schema.AgenticMessage {
return &schema.AgenticMessage{
Role: schema.AgenticRoleTypeAssistant,
ContentBlocks: []*schema.ContentBlock{schema.NewContentBlock(&schema.FunctionToolCall{
CallID: callID,
Name: name,
Arguments: arguments,
})},
}
}
func agenticToolResult(callID, name, text string) *schema.AgenticMessage {
return &schema.AgenticMessage{
Role: schema.AgenticRoleTypeUser,
ContentBlocks: []*schema.ContentBlock{schema.NewContentBlock(&schema.FunctionToolResult{
CallID: callID,
Name: name,
Content: []*schema.FunctionToolResultContentBlock{{
Type: schema.FunctionToolResultContentBlockTypeText,
Text: &schema.UserInputText{Text: text},
}},
})},
}
}
func TestBuildAgenticReductionMiddlewareHandlesSingleAgenticToolResult(t *testing.T) {
ctx := context.Background()
loc, err := localbk.NewBackend(ctx, &localbk.Config{})
if err != nil {
t.Fatalf("NewBackend: %v", err)
}
mw, err := buildAgenticReductionMiddleware(ctx, config.MultiAgentEinoMiddlewareConfig{
ReductionRootDir: t.TempDir(),
ReductionMaxTokensForClear: 1,
}, "", "conv-1", loc, nil)
if err != nil {
t.Fatalf("buildAgenticReductionMiddleware: %v", err)
}
state := &adk.TypedChatModelAgentState[*schema.AgenticMessage]{
Messages: []*schema.AgenticMessage{
{
Role: schema.AgenticRoleTypeUser,
ContentBlocks: []*schema.ContentBlock{schema.NewContentBlock(&schema.FunctionToolResult{
CallID: "call-1",
Name: "execute",
Content: []*schema.FunctionToolResultContentBlock{{
Type: schema.FunctionToolResultContentBlockTypeText,
Text: &schema.UserInputText{Text: strings.Repeat("tool-output-", 20)},
}},
})},
},
},
}
_, out, err := mw.BeforeModelRewriteState(ctx, state, nil)
if err != nil {
t.Fatalf("BeforeModelRewriteState: %v", err)
}
got := out.Messages[0].ContentBlocks[0].FunctionToolResult.Content[0].Text.Text
if got != strings.Repeat("tool-output-", 20) {
t.Fatalf("single retained tool result should not be cleared, got %q", got)
}
}
func TestPrependEinoAgenticMiddlewaresRespectsReductionPlacement(t *testing.T) {
ctx := context.Background()
loc, err := localbk.NewBackend(ctx, &localbk.Config{})
if err != nil {
t.Fatalf("NewBackend: %v", err)
}
patchToolCalls := false
mw := &config.MultiAgentEinoMiddlewareConfig{
ReductionEnable: true,
ReductionRootDir: t.TempDir(),
ReductionMaxTokensForClear: 100,
PatchToolCalls: &patchToolCalls,
}
_, mainHandlers, _, err := prependEinoAgenticMiddlewares(ctx, mw, einoMWMain, nil, loc, "", "conv-1", "", nil)
if err != nil {
t.Fatalf("prepend main: %v", err)
}
if len(mainHandlers) != 1 {
t.Fatalf("main handlers = %d, want reduction", len(mainHandlers))
}
_, subHandlers, _, err := prependEinoAgenticMiddlewares(ctx, mw, einoMWSub, nil, loc, "", "conv-1", "", nil)
if err != nil {
t.Fatalf("prepend sub: %v", err)
}
if len(subHandlers) != 0 {
t.Fatalf("sub handlers = %d, want skipped when reduction_sub_agents=false", len(subHandlers))
}
mw.ReductionSubAgents = true
_, subHandlers, _, err = prependEinoAgenticMiddlewares(ctx, mw, einoMWSub, nil, loc, "", "conv-1", "", nil)
if err != nil {
t.Fatalf("prepend sub enabled: %v", err)
}
if len(subHandlers) != 1 {
t.Fatalf("sub handlers = %d, want reduction when reduction_sub_agents=true", len(subHandlers))
}
}
func TestPrependEinoAgenticMiddlewaresMountsToolSearchAndPatchToolCalls(t *testing.T) {
ctx := context.Background()
mw := &config.MultiAgentEinoMiddlewareConfig{
ToolSearchEnable: true,
ToolSearchMinTools: 20,
ToolSearchAlwaysVisible: 5,
}
outTools, handlers, toolSearchActive, err := prependEinoAgenticMiddlewares(ctx, mw, einoMWMain, stubTools(25), nil, "", "conv-test", "", nil)
if err != nil {
t.Fatalf("prependEinoAgenticMiddlewares: %v", err)
}
if !toolSearchActive {
t.Fatal("agentic tool_search should be active")
}
if len(outTools) != 5 {
t.Fatalf("mounted tools = %d, want static visible tools only", len(outTools))
}
if len(handlers) != 2 {
t.Fatalf("handlers = %d, want patchtoolcalls + toolsearch", len(handlers))
}
}
type stubTool struct{ name string }
func (s stubTool) Info(_ context.Context) (*schema.ToolInfo, error) {
@@ -6,6 +6,7 @@ import (
"sync"
"github.com/cloudwego/eino/adk"
"github.com/cloudwego/eino/schema"
)
// modelFacingTraceHolder 保存「即将送入 ChatModel」的消息快照(已走 summarization / reduction / orphan 修剪等),
@@ -43,6 +44,19 @@ func (h *modelFacingTraceHolder) storeFromState(state *adk.ChatModelAgentState)
h.mu.Unlock()
}
func (h *modelFacingTraceHolder) storeFromAgenticState(state *adk.TypedChatModelAgentState[*schema.AgenticMessage]) {
if h == nil || state == nil || len(state.Messages) == 0 {
return
}
cloned := cloneADKMessagesForTrace(AgenticMessagesToEino(state.Messages))
if len(cloned) == 0 {
return
}
h.mu.Lock()
h.msgs = cloned
h.mu.Unlock()
}
func cloneADKMessagesForTrace(msgs []adk.Message) []adk.Message {
if len(msgs) == 0 {
return nil
@@ -82,3 +96,29 @@ func (m *modelFacingTraceMiddleware) BeforeModelRewriteState(
}
return ctx, state, nil
}
type agenticModelFacingTraceMiddleware struct {
*adk.TypedBaseChatModelAgentMiddleware[*schema.AgenticMessage]
holder *modelFacingTraceHolder
}
func newAgenticModelFacingTraceMiddleware(holder *modelFacingTraceHolder) adk.TypedChatModelAgentMiddleware[*schema.AgenticMessage] {
if holder == nil {
return nil
}
return &agenticModelFacingTraceMiddleware{
TypedBaseChatModelAgentMiddleware: &adk.TypedBaseChatModelAgentMiddleware[*schema.AgenticMessage]{},
holder: holder,
}
}
func (m *agenticModelFacingTraceMiddleware) BeforeModelRewriteState(
ctx context.Context,
state *adk.TypedChatModelAgentState[*schema.AgenticMessage],
mc *adk.TypedModelContext[*schema.AgenticMessage],
) (context.Context, *adk.TypedChatModelAgentState[*schema.AgenticMessage], error) {
if m.holder != nil && state != nil {
m.holder.storeFromAgenticState(state)
}
return ctx, state, nil
}
@@ -0,0 +1,500 @@
package multiagent
import (
"context"
"errors"
"fmt"
"net"
"net/http"
"strings"
"sync"
"time"
"cyberstrike-ai/internal/config"
"cyberstrike-ai/internal/openai"
"cyberstrike-ai/internal/reasoning"
agenticopenai "github.com/cloudwego/eino-ext/components/model/agenticopenai"
einoopenai "github.com/cloudwego/eino-ext/components/model/openai"
"github.com/cloudwego/eino/adk"
"github.com/cloudwego/eino/components/model"
"github.com/cloudwego/eino/schema"
"go.uber.org/zap"
)
type einoModelMode string
const (
einoModelModeNormal einoModelMode = "normal"
einoModelModePlanner einoModelMode = "planner"
)
type einoModelFactory func(ctx context.Context, oa config.OpenAIConfig, mode einoModelMode) (model.ToolCallingChatModel, error)
type einoAgenticModelConfigFactory func(ctx context.Context, oa config.OpenAIConfig, mode einoModelMode) (model.AgenticModel, error)
func newEinoBaseHTTPClient() *http.Client {
return &http.Client{
Timeout: 30 * time.Minute,
Transport: &http.Transport{
DialContext: (&net.Dialer{
Timeout: 300 * time.Second,
KeepAlive: 300 * time.Second,
}).DialContext,
MaxIdleConns: 100,
MaxIdleConnsPerHost: 10,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 30 * time.Second,
ResponseHeaderTimeout: 60 * time.Minute,
},
}
}
func newEinoOpenAIChatModelFactory(
baseHTTPClient *http.Client,
reasoningClient *reasoning.ClientIntent,
logger *zap.Logger,
) einoModelFactory {
if baseHTTPClient == nil {
baseHTTPClient = newEinoBaseHTTPClient()
}
return func(ctx context.Context, oa config.OpenAIConfig, mode einoModelMode) (model.ToolCallingChatModel, error) {
httpClient := openai.NewEinoHTTPClient(&oa, baseHTTPClient)
openai.AttachSummarizationDiagTransport(httpClient, logger)
maxCompletionTokens := oa.MaxCompletionTokensEffective()
modelCfg := &einoopenai.ChatModelConfig{
APIKey: oa.APIKey,
BaseURL: strings.TrimSuffix(oa.BaseURL, "/"),
Model: oa.Model,
HTTPClient: httpClient,
MaxCompletionTokens: &maxCompletionTokens,
}
if mode == einoModelModePlanner {
reasoning.ApplyPlanExecutePlannerModelConfig(modelCfg, &oa)
} else {
reasoning.ApplyToEinoChatModelConfig(modelCfg, &oa, reasoningClient)
}
baseModel, err := einoopenai.NewChatModel(ctx, modelCfg)
if err != nil {
return nil, err
}
return newStreamToolCallIndexRepairModel(baseModel), nil
}
}
func newEinoOpenAIAgenticChatModelFactory(
baseHTTPClient *http.Client,
reasoningClient *reasoning.ClientIntent,
logger *zap.Logger,
) einoAgenticModelConfigFactory {
if baseHTTPClient == nil {
baseHTTPClient = newEinoBaseHTTPClient()
}
return func(ctx context.Context, oa config.OpenAIConfig, mode einoModelMode) (model.AgenticModel, error) {
if !supportsEinoAgenticOpenAIBackend(oa) {
return nil, fmt.Errorf("eino agentic model: provider %q is not enabled for agenticopenai backend", strings.TrimSpace(oa.Provider))
}
httpClient := openai.NewEinoHTTPClient(&oa, baseHTTPClient)
openai.AttachSummarizationDiagTransport(httpClient, logger)
maxCompletionTokens := oa.MaxCompletionTokensEffective()
modelCfg := &agenticopenai.ChatConfig{
APIKey: oa.APIKey,
BaseURL: strings.TrimSuffix(oa.BaseURL, "/"),
Model: oa.Model,
HTTPClient: httpClient,
MaxCompletionTokens: &maxCompletionTokens,
ExtraFields: reasoning.AgenticOpenAIExtraFields(&oa, reasoningClient),
}
if mode == einoModelModePlanner {
modelCfg.ExtraFields = reasoning.AgenticOpenAIPlannerExtraFields(&oa)
}
return agenticopenai.NewChatModel(ctx, modelCfg)
}
}
func supportsEinoAgenticOpenAIBackend(oa config.OpenAIConfig) bool {
provider := strings.ToLower(strings.TrimSpace(oa.Provider))
return provider == "" || provider == "openai" || provider == "openai_compatible"
}
func agenticModelGateFactory(factory einoAgenticModelConfigFactory, oa config.OpenAIConfig, mode einoModelMode) einoAgenticModelFactory {
if factory == nil {
return nil
}
return func(ctx context.Context) (model.AgenticModel, error) {
return factory(ctx, oa, mode)
}
}
func newEinoModelRetryConfig(
mw *config.MultiAgentEinoMiddlewareConfig,
logger *zap.Logger,
scope string,
) *adk.ModelRetryConfig {
maxRetries := RunRetryMaxAttemptsFromConfig(mw)
maxBackoff := einoRunRetryMaxBackoffFromConfig(mw)
return &adk.ModelRetryConfig{
MaxRetries: maxRetries,
BackoffFunc: func(_ context.Context, attempt int) time.Duration {
return einoTransientRetryBackoff(attempt-1, maxBackoff)
},
ShouldRetry: func(ctx context.Context, retryCtx *adk.RetryContext) *adk.RetryDecision {
if retryCtx == nil || ctx.Err() != nil {
return &adk.RetryDecision{}
}
if retryCtx.Err != nil {
if !isEinoTransientRunError(retryCtx.Err) {
return &adk.RetryDecision{}
}
if logger != nil {
kind, summary := einoTransientRunErrorUserDetail(retryCtx.Err)
logger.Warn("eino native model retry",
zap.String("scope", scope),
zap.Int("attempt", retryCtx.RetryAttempt),
zap.Int("maxRetries", maxRetries),
zap.String("errorKind", kind),
zap.String("errorSummary", summary),
)
}
return &adk.RetryDecision{Retry: true, RejectReason: "transient_model_error"}
}
if isRetryableEmptyModelOutput(retryCtx.OutputMessage) {
if logger != nil {
logger.Warn("eino native model retry: empty model output",
zap.String("scope", scope),
zap.Int("attempt", retryCtx.RetryAttempt),
zap.Int("maxRetries", maxRetries),
)
}
return &adk.RetryDecision{Retry: true, RejectReason: "empty_model_output"}
}
return &adk.RetryDecision{}
},
}
}
func newEinoAgenticModelRetryConfig(
mw *config.MultiAgentEinoMiddlewareConfig,
logger *zap.Logger,
scope string,
) *adk.TypedModelRetryConfig[*schema.AgenticMessage] {
maxRetries := RunRetryMaxAttemptsFromConfig(mw)
maxBackoff := einoRunRetryMaxBackoffFromConfig(mw)
return &adk.TypedModelRetryConfig[*schema.AgenticMessage]{
MaxRetries: maxRetries,
BackoffFunc: func(_ context.Context, attempt int) time.Duration {
return einoTransientRetryBackoff(attempt-1, maxBackoff)
},
ShouldRetry: func(ctx context.Context, retryCtx *adk.TypedRetryContext[*schema.AgenticMessage]) *adk.TypedRetryDecision[*schema.AgenticMessage] {
if retryCtx == nil || ctx.Err() != nil {
return &adk.TypedRetryDecision[*schema.AgenticMessage]{}
}
if retryCtx.Err != nil {
if !isEinoTransientRunError(retryCtx.Err) {
return &adk.TypedRetryDecision[*schema.AgenticMessage]{}
}
if logger != nil {
kind, summary := einoTransientRunErrorUserDetail(retryCtx.Err)
logger.Warn("eino native agentic model retry",
zap.String("scope", scope),
zap.Int("attempt", retryCtx.RetryAttempt),
zap.Int("maxRetries", maxRetries),
zap.String("errorKind", kind),
zap.String("errorSummary", summary),
)
}
return &adk.TypedRetryDecision[*schema.AgenticMessage]{Retry: true, RejectReason: "transient_model_error"}
}
if isRetryableEmptyAgenticModelOutput(retryCtx.OutputMessage) {
if logger != nil {
logger.Warn("eino native agentic model retry: empty model output",
zap.String("scope", scope),
zap.Int("attempt", retryCtx.RetryAttempt),
zap.Int("maxRetries", maxRetries),
)
}
return &adk.TypedRetryDecision[*schema.AgenticMessage]{Retry: true, RejectReason: "empty_model_output"}
}
return &adk.TypedRetryDecision[*schema.AgenticMessage]{}
},
}
}
func newEinoModelFailoverConfig(
ctx context.Context,
appCfg *config.Config,
mw *config.MultiAgentEinoMiddlewareConfig,
mode einoModelMode,
factory einoModelFactory,
logger *zap.Logger,
scope string,
progress func(eventType, message string, data interface{}),
orchestration string,
conversationID string,
) (*adk.ModelFailoverConfig[*schema.Message], error) {
channels := resolveEinoFailoverChannels(appCfg, mw)
if len(channels) == 0 {
return nil, nil
}
if factory == nil {
return nil, fmt.Errorf("eino model failover: 模型工厂为空")
}
maxRetries := len(channels)
if mw != nil && mw.ModelFailoverMaxRetries > 0 && mw.ModelFailoverMaxRetries < maxRetries {
maxRetries = mw.ModelFailoverMaxRetries
}
channels = channels[:maxRetries]
cache := make(map[string]model.BaseModel[*schema.Message], len(channels))
var mu sync.Mutex
return &adk.ModelFailoverConfig[*schema.Message]{
MaxRetries: uint(maxRetries),
ShouldFailover: func(ctx context.Context, _ *schema.Message, err error) bool {
if ctx.Err() != nil || err == nil {
return false
}
err = unwrapEinoRetryExhausted(err)
return isEinoTransientRunError(err)
},
GetFailoverModel: func(ctx context.Context, failoverCtx *adk.FailoverContext[*schema.Message]) (model.BaseModel[*schema.Message], []*schema.Message, error) {
if failoverCtx == nil || failoverCtx.FailoverAttempt == 0 {
return nil, nil, fmt.Errorf("eino model failover: invalid failover attempt")
}
idx := int(failoverCtx.FailoverAttempt) - 1
if idx < 0 || idx >= len(channels) {
return nil, nil, fmt.Errorf("eino model failover: no channel for attempt %d", failoverCtx.FailoverAttempt)
}
ch := channels[idx]
mu.Lock()
cached := cache[ch.id]
mu.Unlock()
if cached != nil {
emitEinoModelFailoverEvent(progress, conversationID, orchestration, scope, ch.id, ch.cfg.Model, failoverCtx.FailoverAttempt)
if logger != nil {
logger.Warn("eino native model failover",
zap.String("scope", scope),
zap.String("channel", ch.id),
zap.String("model", ch.cfg.Model),
zap.Uint("attempt", failoverCtx.FailoverAttempt),
)
}
return cached, nil, nil
}
m, err := factory(ctx, ch.cfg, mode)
if err != nil {
return nil, nil, fmt.Errorf("eino model failover channel %q: %w", ch.id, err)
}
mu.Lock()
cache[ch.id] = m
mu.Unlock()
emitEinoModelFailoverEvent(progress, conversationID, orchestration, scope, ch.id, ch.cfg.Model, failoverCtx.FailoverAttempt)
if logger != nil {
logger.Warn("eino native model failover",
zap.String("scope", scope),
zap.String("channel", ch.id),
zap.String("model", ch.cfg.Model),
zap.Uint("attempt", failoverCtx.FailoverAttempt),
)
}
return m, nil, nil
},
}, nil
}
func newEinoAgenticModelFailoverConfig(
ctx context.Context,
appCfg *config.Config,
mw *config.MultiAgentEinoMiddlewareConfig,
mode einoModelMode,
factory einoAgenticModelConfigFactory,
logger *zap.Logger,
scope string,
progress func(eventType, message string, data interface{}),
orchestration string,
conversationID string,
) (*adk.ModelFailoverConfig[*schema.AgenticMessage], error) {
channels := resolveEinoFailoverChannels(appCfg, mw)
if len(channels) == 0 {
return nil, nil
}
if factory == nil {
return nil, fmt.Errorf("eino agentic model failover: 模型工厂为空")
}
maxRetries := len(channels)
if mw != nil && mw.ModelFailoverMaxRetries > 0 && mw.ModelFailoverMaxRetries < maxRetries {
maxRetries = mw.ModelFailoverMaxRetries
}
channels = channels[:maxRetries]
cache := make(map[string]model.BaseModel[*schema.AgenticMessage], len(channels))
var mu sync.Mutex
return &adk.ModelFailoverConfig[*schema.AgenticMessage]{
MaxRetries: uint(maxRetries),
ShouldFailover: func(ctx context.Context, _ *schema.AgenticMessage, err error) bool {
if ctx.Err() != nil || err == nil {
return false
}
err = unwrapEinoRetryExhausted(err)
return isEinoTransientRunError(err)
},
GetFailoverModel: func(ctx context.Context, failoverCtx *adk.FailoverContext[*schema.AgenticMessage]) (model.BaseModel[*schema.AgenticMessage], []*schema.AgenticMessage, error) {
if failoverCtx == nil || failoverCtx.FailoverAttempt == 0 {
return nil, nil, fmt.Errorf("eino agentic model failover: invalid failover attempt")
}
idx := int(failoverCtx.FailoverAttempt) - 1
if idx < 0 || idx >= len(channels) {
return nil, nil, fmt.Errorf("eino agentic model failover: no channel for attempt %d", failoverCtx.FailoverAttempt)
}
ch := channels[idx]
mu.Lock()
cached := cache[ch.id]
mu.Unlock()
if cached != nil {
emitEinoModelFailoverEvent(progress, conversationID, orchestration, scope, ch.id, ch.cfg.Model, failoverCtx.FailoverAttempt)
if logger != nil {
logger.Warn("eino native agentic model failover",
zap.String("scope", scope),
zap.String("channel", ch.id),
zap.String("model", ch.cfg.Model),
zap.Uint("attempt", failoverCtx.FailoverAttempt),
)
}
return cached, nil, nil
}
m, err := factory(ctx, ch.cfg, mode)
if err != nil {
return nil, nil, fmt.Errorf("eino agentic model failover channel %q: %w", ch.id, err)
}
mu.Lock()
cache[ch.id] = m
mu.Unlock()
emitEinoModelFailoverEvent(progress, conversationID, orchestration, scope, ch.id, ch.cfg.Model, failoverCtx.FailoverAttempt)
if logger != nil {
logger.Warn("eino native agentic model failover",
zap.String("scope", scope),
zap.String("channel", ch.id),
zap.String("model", ch.cfg.Model),
zap.Uint("attempt", failoverCtx.FailoverAttempt),
)
}
return m, nil, nil
},
}, nil
}
type resolvedEinoFailoverChannel struct {
id string
cfg config.OpenAIConfig
}
func resolveEinoFailoverChannels(appCfg *config.Config, mw *config.MultiAgentEinoMiddlewareConfig) []resolvedEinoFailoverChannel {
if appCfg == nil || mw == nil || len(mw.ModelFailoverChannels) == 0 {
return nil
}
primary := appCfg.OpenAI
seen := map[string]struct{}{}
out := make([]resolvedEinoFailoverChannel, 0, len(mw.ModelFailoverChannels))
for _, raw := range mw.ModelFailoverChannels {
id := config.NormalizeAIChannelID(raw)
if id == "" {
continue
}
if _, ok := seen[id]; ok {
continue
}
oa, resolvedID, ok := appCfg.AI.ResolveChannel(id)
if !ok {
continue
}
if sameOpenAIModelEndpoint(primary, oa) {
continue
}
seen[resolvedID] = struct{}{}
out = append(out, resolvedEinoFailoverChannel{id: resolvedID, cfg: oa})
}
return out
}
func sameOpenAIModelEndpoint(a, b config.OpenAIConfig) bool {
return strings.EqualFold(strings.TrimSpace(a.Provider), strings.TrimSpace(b.Provider)) &&
strings.TrimRight(strings.TrimSpace(a.BaseURL), "/") == strings.TrimRight(strings.TrimSpace(b.BaseURL), "/") &&
strings.TrimSpace(a.APIKey) == strings.TrimSpace(b.APIKey) &&
strings.TrimSpace(a.Model) == strings.TrimSpace(b.Model)
}
func isRetryableEmptyModelOutput(msg *schema.Message) bool {
if msg == nil {
return true
}
return strings.TrimSpace(msg.Content) == "" &&
strings.TrimSpace(msg.ReasoningContent) == "" &&
len(msg.ToolCalls) == 0 &&
len(msg.MultiContent) == 0 &&
len(msg.UserInputMultiContent) == 0 &&
len(msg.AssistantGenMultiContent) == 0
}
func isRetryableEmptyAgenticModelOutput(msg *schema.AgenticMessage) bool {
if msg == nil {
return true
}
for _, block := range msg.ContentBlocks {
if block == nil {
continue
}
switch {
case block.Reasoning != nil:
if strings.TrimSpace(block.Reasoning.Text) != "" {
return false
}
case block.UserInputText != nil:
if strings.TrimSpace(block.UserInputText.Text) != "" {
return false
}
case block.AssistantGenText != nil:
if strings.TrimSpace(block.AssistantGenText.Text) != "" {
return false
}
default:
return false
}
}
return true
}
func unwrapEinoRetryExhausted(err error) error {
var retryErr *adk.RetryExhaustedError
if errors.As(err, &retryErr) && retryErr.LastErr != nil {
return retryErr.LastErr
}
return err
}
func isEinoNativeWillRetry(err error) (*adk.WillRetryError, bool) {
var willRetry *adk.WillRetryError
if errors.As(err, &willRetry) {
return willRetry, true
}
return nil, false
}
func emitEinoModelFailoverEvent(
progress func(eventType, message string, data interface{}),
conversationID, orchestration, scope, channelID, modelName string,
attempt uint,
) {
if progress == nil {
return
}
msg := fmt.Sprintf("主模型重试耗尽,正在切换备用模型 %s。", modelName)
progress("eino_model_failover", msg, map[string]interface{}{
"conversationId": conversationID,
"source": "eino",
"orchestration": orchestration,
"scope": scope,
"channel": channelID,
"model": modelName,
"attempt": attempt,
})
}
@@ -0,0 +1,376 @@
package multiagent
import (
"context"
"errors"
"testing"
"time"
"cyberstrike-ai/internal/config"
"github.com/cloudwego/eino/adk"
"github.com/cloudwego/eino/components/model"
"github.com/cloudwego/eino/schema"
)
func TestNewEinoModelRetryConfigUsesNativeFieldsFirst(t *testing.T) {
t.Parallel()
mw := &config.MultiAgentEinoMiddlewareConfig{
ModelRetryMaxRetries: 2,
ModelRetryMaxBackoffSec: 7,
RunRetryMaxAttempts: 9,
RunRetryMaxBackoffSec: 11,
}
cfg := newEinoModelRetryConfig(mw, nil, "test")
if cfg.MaxRetries != 2 {
t.Fatalf("MaxRetries = %d, want 2", cfg.MaxRetries)
}
backoff := cfg.BackoffFunc(context.Background(), 1)
if backoff < 500*time.Millisecond || backoff > 2*time.Second {
t.Fatalf("attempt 1 backoff = %v, want first equal-jitter window", backoff)
}
if got := einoRunRetryMaxBackoffFromConfig(mw); got != 7*time.Second {
t.Fatalf("backoff from config = %v, want 7s", got)
}
}
func TestEinoModelRetryPolicyRetriesTransientAndEmptyOutput(t *testing.T) {
t.Parallel()
cfg := newEinoModelRetryConfig(&config.MultiAgentEinoMiddlewareConfig{ModelRetryMaxRetries: 1}, nil, "test")
if got := cfg.ShouldRetry(context.Background(), &adk.RetryContext{Err: errors.New("HTTP 429 Too Many Requests")}); got == nil || !got.Retry {
t.Fatal("transient model error should retry")
}
if got := cfg.ShouldRetry(context.Background(), &adk.RetryContext{OutputMessage: schema.AssistantMessage("", nil)}); got == nil || !got.Retry {
t.Fatal("empty assistant output should retry")
}
if got := cfg.ShouldRetry(context.Background(), &adk.RetryContext{OutputMessage: schema.AssistantMessage("", []schema.ToolCall{{ID: "call_1"}})}); got == nil || got.Retry {
t.Fatal("assistant tool call output should not be treated as empty")
}
if got := cfg.ShouldRetry(context.Background(), &adk.RetryContext{Err: errors.New("invalid api key")}); got == nil || got.Retry {
t.Fatal("permanent auth error should not retry")
}
}
func TestEinoAgenticModelRetryPolicyRetriesTransientAndEmptyOutput(t *testing.T) {
t.Parallel()
cfg := newEinoAgenticModelRetryConfig(&config.MultiAgentEinoMiddlewareConfig{ModelRetryMaxRetries: 1}, nil, "agentic")
if got := cfg.ShouldRetry(context.Background(), &adk.TypedRetryContext[*schema.AgenticMessage]{Err: errors.New("HTTP 429 Too Many Requests")}); got == nil || !got.Retry {
t.Fatal("transient agentic model error should retry")
}
if got := cfg.ShouldRetry(context.Background(), &adk.TypedRetryContext[*schema.AgenticMessage]{
OutputMessage: &schema.AgenticMessage{Role: schema.AgenticRoleTypeAssistant},
}); got == nil || !got.Retry {
t.Fatal("empty agentic assistant output should retry")
}
if got := cfg.ShouldRetry(context.Background(), &adk.TypedRetryContext[*schema.AgenticMessage]{
OutputMessage: &schema.AgenticMessage{
Role: schema.AgenticRoleTypeAssistant,
ContentBlocks: []*schema.ContentBlock{schema.NewContentBlock(&schema.AssistantGenText{Text: "ok"})},
},
}); got == nil || got.Retry {
t.Fatal("agentic assistant text should not be treated as empty")
}
if got := cfg.ShouldRetry(context.Background(), &adk.TypedRetryContext[*schema.AgenticMessage]{
OutputMessage: &schema.AgenticMessage{
Role: schema.AgenticRoleTypeAssistant,
ContentBlocks: []*schema.ContentBlock{schema.NewContentBlock(&schema.FunctionToolCall{
CallID: "call_1", Name: "search", Arguments: `{"q":"x"}`,
})},
},
}); got == nil || got.Retry {
t.Fatal("agentic tool call output should not be treated as empty")
}
if got := cfg.ShouldRetry(context.Background(), &adk.TypedRetryContext[*schema.AgenticMessage]{Err: errors.New("invalid api key")}); got == nil || got.Retry {
t.Fatal("permanent auth error should not retry")
}
}
func TestResolveEinoFailoverChannelsSkipsPrimaryDuplicateAndUnknown(t *testing.T) {
t.Parallel()
appCfg := &config.Config{
OpenAI: config.OpenAIConfig{Provider: "openai", APIKey: "k1", BaseURL: "https://api.example/v1", Model: "primary"},
AI: config.AIConfig{Channels: map[string]config.AIChannelConfig{
"same": {Provider: "openai", APIKey: "k1", BaseURL: "https://api.example/v1", Model: "primary"},
"fb1": {Provider: "openai", APIKey: "k2", BaseURL: "https://api.example/v1", Model: "fallback-1"},
"fb2": {Provider: "claude", APIKey: "k3", BaseURL: "https://api.anthropic.com/v1", Model: "claude-sonnet"},
}},
}
got := resolveEinoFailoverChannels(appCfg, &config.MultiAgentEinoMiddlewareConfig{
ModelFailoverChannels: []string{"same", "missing", "fb1", "fb1", "fb2"},
ModelFailoverMaxRetries: 1,
})
if len(got) != 2 {
t.Fatalf("resolved channels len = %d, want 2 before max cap is applied by config builder", len(got))
}
if got[0].id != "fb1" || got[1].id != "fb2" {
t.Fatalf("resolved channel order = %#v", got)
}
}
func TestNewEinoModelFailoverConfigBuildsDistinctFallbackModel(t *testing.T) {
t.Parallel()
appCfg := &config.Config{
OpenAI: config.OpenAIConfig{APIKey: "k1", BaseURL: "https://api.example/v1", Model: "primary"},
AI: config.AIConfig{Channels: map[string]config.AIChannelConfig{
"fb1": {APIKey: "k2", BaseURL: "https://api.example/v1", Model: "fallback-1"},
"fb2": {APIKey: "k3", BaseURL: "https://api.example/v1", Model: "fallback-2"},
}},
}
var built []string
cfg, err := newEinoModelFailoverConfig(
context.Background(),
appCfg,
&config.MultiAgentEinoMiddlewareConfig{
ModelFailoverChannels: []string{"fb1", "fb2"},
ModelFailoverMaxRetries: 1,
},
einoModelModeNormal,
func(_ context.Context, oa config.OpenAIConfig, _ einoModelMode) (model.ToolCallingChatModel, error) {
built = append(built, oa.Model)
return &streamToolCallIndexFakeModel{}, nil
},
nil,
"test",
nil,
"deep",
"conv-1",
)
if err != nil {
t.Fatalf("newEinoModelFailoverConfig: %v", err)
}
if cfg == nil || cfg.MaxRetries != 1 {
t.Fatalf("failover cfg = %#v, want max retries 1", cfg)
}
m, msgs, err := cfg.GetFailoverModel(context.Background(), &adk.FailoverContext[*schema.Message]{FailoverAttempt: 1})
if err != nil || m == nil || msgs != nil {
t.Fatalf("GetFailoverModel = (%v, %v, %v)", m, msgs, err)
}
if len(built) != 1 || built[0] != "fallback-1" {
t.Fatalf("built models = %v, want [fallback-1]", built)
}
if !cfg.ShouldFailover(context.Background(), nil, &adk.RetryExhaustedError{LastErr: errors.New("upstream returned 503"), TotalRetries: 4}) {
t.Fatal("retry-exhausted transient error should fail over")
}
if cfg.ShouldFailover(context.Background(), nil, &adk.RetryExhaustedError{LastErr: errors.New("invalid api key"), TotalRetries: 4}) {
t.Fatal("retry-exhausted permanent error should not fail over")
}
}
func TestNewEinoModelFailoverConfigEmitsProgressEvent(t *testing.T) {
t.Parallel()
appCfg := &config.Config{
OpenAI: config.OpenAIConfig{APIKey: "k1", BaseURL: "https://api.example/v1", Model: "primary"},
AI: config.AIConfig{Channels: map[string]config.AIChannelConfig{
"fb1": {APIKey: "k2", BaseURL: "https://api.example/v1", Model: "fallback-1"},
}},
}
var events []struct {
eventType string
message string
data interface{}
}
cfg, err := newEinoModelFailoverConfig(
context.Background(),
appCfg,
&config.MultiAgentEinoMiddlewareConfig{ModelFailoverChannels: []string{"fb1"}},
einoModelModeNormal,
func(_ context.Context, _ config.OpenAIConfig, _ einoModelMode) (model.ToolCallingChatModel, error) {
return &streamToolCallIndexFakeModel{}, nil
},
nil,
"test",
func(eventType, message string, data interface{}) {
events = append(events, struct {
eventType string
message string
data interface{}
}{eventType: eventType, message: message, data: data})
},
"deep",
"conv-1",
)
if err != nil {
t.Fatalf("newEinoModelFailoverConfig: %v", err)
}
if _, _, err := cfg.GetFailoverModel(context.Background(), &adk.FailoverContext[*schema.Message]{FailoverAttempt: 1}); err != nil {
t.Fatalf("GetFailoverModel: %v", err)
}
if len(events) != 1 || events[0].eventType != "eino_model_failover" {
t.Fatalf("events = %#v, want one eino_model_failover", events)
}
payload, ok := events[0].data.(map[string]interface{})
if !ok {
t.Fatalf("event payload type = %T", events[0].data)
}
if payload["conversationId"] != "conv-1" || payload["orchestration"] != "deep" || payload["channel"] != "fb1" || payload["model"] != "fallback-1" {
t.Fatalf("payload = %#v", payload)
}
}
func TestNewEinoAgenticModelFailoverConfigBuildsDistinctFallbackModel(t *testing.T) {
t.Parallel()
appCfg := &config.Config{
OpenAI: config.OpenAIConfig{Provider: "openai", APIKey: "k1", BaseURL: "https://api.example/v1", Model: "primary"},
AI: config.AIConfig{Channels: map[string]config.AIChannelConfig{
"fb1": {Provider: "openai", APIKey: "k2", BaseURL: "https://api.example/v1", Model: "fallback-1"},
"fb2": {Provider: "openai", APIKey: "k3", BaseURL: "https://api.example/v1", Model: "fallback-2"},
}},
}
var built []string
cfg, err := newEinoAgenticModelFailoverConfig(
context.Background(),
appCfg,
&config.MultiAgentEinoMiddlewareConfig{
ModelFailoverChannels: []string{"fb1", "fb2"},
ModelFailoverMaxRetries: 1,
},
einoModelModeNormal,
func(_ context.Context, oa config.OpenAIConfig, _ einoModelMode) (model.AgenticModel, error) {
built = append(built, oa.Model)
return &fakeAgenticGateModel{}, nil
},
nil,
"agentic",
nil,
"eino_single_agentic",
"conv-1",
)
if err != nil {
t.Fatalf("newEinoAgenticModelFailoverConfig: %v", err)
}
if cfg == nil || cfg.MaxRetries != 1 {
t.Fatalf("agentic failover cfg = %#v, want max retries 1", cfg)
}
m, msgs, err := cfg.GetFailoverModel(context.Background(), &adk.FailoverContext[*schema.AgenticMessage]{FailoverAttempt: 1})
if err != nil || m == nil || msgs != nil {
t.Fatalf("GetFailoverModel = (%v, %v, %v)", m, msgs, err)
}
if len(built) != 1 || built[0] != "fallback-1" {
t.Fatalf("built models = %v, want [fallback-1]", built)
}
if !cfg.ShouldFailover(context.Background(), nil, &adk.RetryExhaustedError{LastErr: errors.New("upstream returned 503"), TotalRetries: 4}) {
t.Fatal("retry-exhausted transient agentic error should fail over")
}
if cfg.ShouldFailover(context.Background(), nil, &adk.RetryExhaustedError{LastErr: errors.New("invalid api key"), TotalRetries: 4}) {
t.Fatal("retry-exhausted permanent agentic error should not fail over")
}
}
func TestNewEinoAgenticModelFailoverConfigEmitsProgressEvent(t *testing.T) {
t.Parallel()
appCfg := &config.Config{
OpenAI: config.OpenAIConfig{Provider: "openai", APIKey: "k1", BaseURL: "https://api.example/v1", Model: "primary"},
AI: config.AIConfig{Channels: map[string]config.AIChannelConfig{
"fb1": {Provider: "openai", APIKey: "k2", BaseURL: "https://api.example/v1", Model: "fallback-1"},
}},
}
var events []struct {
eventType string
message string
data interface{}
}
cfg, err := newEinoAgenticModelFailoverConfig(
context.Background(),
appCfg,
&config.MultiAgentEinoMiddlewareConfig{ModelFailoverChannels: []string{"fb1"}},
einoModelModeNormal,
func(_ context.Context, _ config.OpenAIConfig, _ einoModelMode) (model.AgenticModel, error) {
return &fakeAgenticGateModel{}, nil
},
nil,
"agentic",
func(eventType, message string, data interface{}) {
events = append(events, struct {
eventType string
message string
data interface{}
}{eventType: eventType, message: message, data: data})
},
"eino_single_agentic",
"conv-1",
)
if err != nil {
t.Fatalf("newEinoAgenticModelFailoverConfig: %v", err)
}
if _, _, err := cfg.GetFailoverModel(context.Background(), &adk.FailoverContext[*schema.AgenticMessage]{FailoverAttempt: 1}); err != nil {
t.Fatalf("GetFailoverModel: %v", err)
}
if len(events) != 1 || events[0].eventType != "eino_model_failover" {
t.Fatalf("events = %#v, want one eino_model_failover", events)
}
payload, ok := events[0].data.(map[string]interface{})
if !ok {
t.Fatalf("event payload type = %T", events[0].data)
}
if payload["conversationId"] != "conv-1" || payload["orchestration"] != "eino_single_agentic" || payload["channel"] != "fb1" || payload["model"] != "fallback-1" {
t.Fatalf("payload = %#v", payload)
}
}
func TestNewEinoOpenAIAgenticChatModelFactoryBuildsBackend(t *testing.T) {
t.Parallel()
factory := newEinoOpenAIAgenticChatModelFactory(newEinoBaseHTTPClient(), nil, nil)
m, err := factory(context.Background(), config.OpenAIConfig{
Provider: "openai",
APIKey: "test-key",
BaseURL: "https://api.example/v1",
Model: "gpt-4o-mini",
Reasoning: config.OpenAIReasoningConfig{
Profile: "openai_compat",
Mode: "on",
Effort: "high",
},
}, einoModelModeNormal)
if err != nil {
t.Fatalf("agentic factory: %v", err)
}
if m == nil {
t.Fatal("agentic factory returned nil model")
}
gate := evaluateEinoAgenticModelGate(agenticModelGateFactory(factory, config.OpenAIConfig{
Provider: "openai",
APIKey: "test-key",
BaseURL: "https://api.example/v1",
Model: "gpt-4o-mini",
}, einoModelModeNormal), einoAgenticRuntimeSupportV0914())
if !gate.Ready {
t.Fatalf("gate = %#v, want ready with buildable agentic backend", gate)
}
}
func TestNewEinoOpenAIAgenticChatModelFactoryRejectsUnsupportedProvider(t *testing.T) {
t.Parallel()
factory := newEinoOpenAIAgenticChatModelFactory(newEinoBaseHTTPClient(), nil, nil)
if _, err := factory(context.Background(), config.OpenAIConfig{
Provider: "claude",
APIKey: "test-key",
BaseURL: "https://api.anthropic.com/v1",
Model: "claude-sonnet-4",
}, einoModelModeNormal); err == nil {
t.Fatal("expected unsupported provider error")
}
gate := evaluateEinoAgenticModelGate(agenticModelGateFactory(factory, config.OpenAIConfig{
Provider: "claude",
APIKey: "test-key",
BaseURL: "https://api.anthropic.com/v1",
Model: "claude-sonnet-4",
}, einoModelModeNormal), einoAgenticRuntimeSupportV0914())
if gate.Ready || !containsString(gate.Missing, "model.AgenticModel backend") {
t.Fatalf("gate = %#v, want backend missing for unsupported provider", gate)
}
}
func TestEinoNativeRetryErrorsDoNotTriggerRunLevelTransientRetry(t *testing.T) {
t.Parallel()
err := &adk.WillRetryError{ErrStr: "HTTP 429 Too Many Requests", RetryAttempt: 1}
if isEinoTransientRunError(err) {
t.Fatal("WillRetryError should be observed, not treated as a run-level transient failure")
}
exhausted := &adk.RetryExhaustedError{LastErr: errors.New("HTTP 429 Too Many Requests"), TotalRetries: 4}
if isEinoTransientRunError(exhausted) {
t.Fatal("RetryExhaustedError should not trigger a second run-level retry layer")
}
if got := unwrapEinoRetryExhausted(exhausted); got == exhausted {
t.Fatal("unwrapEinoRetryExhausted should return the underlying model error")
}
}
+102
View File
@@ -0,0 +1,102 @@
package multiagent
import (
"context"
"errors"
"time"
"github.com/cloudwego/eino/adk"
)
const (
einoNativeCancelImmediateWait = 1200 * time.Millisecond
einoNativeCancelSafePointWait = 3500 * time.Millisecond
einoNativeCancelSafePointTTL = 3 * time.Second
)
type agentRuntimeCancelRegistrarKey struct{}
type agentTurnLoopInterruptRegistrarKey struct{}
// AgentRuntimeCancelRegistrar binds the currently active Eino ADK cancel hook
// into the host task manager. The hook returns true when Eino accepted and
// handled the cancel request, so the host can avoid canceling the parent context.
type AgentRuntimeCancelRegistrar func(cancel func(error) bool) (unregister func())
// WithAgentRuntimeCancelRegistrar lets the HTTP/task layer trigger Eino's native
// Agent Cancel before falling back to the existing context cancellation path.
func WithAgentRuntimeCancelRegistrar(ctx context.Context, registrar AgentRuntimeCancelRegistrar) context.Context {
if ctx == nil || registrar == nil {
return ctx
}
return context.WithValue(ctx, agentRuntimeCancelRegistrarKey{}, registrar)
}
func agentRuntimeCancelRegistrarFromContext(ctx context.Context) AgentRuntimeCancelRegistrar {
if ctx == nil {
return nil
}
if v, ok := ctx.Value(agentRuntimeCancelRegistrarKey{}).(AgentRuntimeCancelRegistrar); ok {
return v
}
return nil
}
// AgentTurnLoopInterruptRegistrar binds a conversation-level TurnLoop interrupt
// pusher into the host task manager. The pusher receives the user supplied note
// and returns true when the note was accepted by the loop.
type AgentTurnLoopInterruptRegistrar func(push func(note string) bool) (unregister func())
// WithAgentTurnLoopInterruptRegistrar lets the HTTP/task layer enqueue a user
// supplement into an active Eino TurnLoop before falling back to cancellation.
func WithAgentTurnLoopInterruptRegistrar(ctx context.Context, registrar AgentTurnLoopInterruptRegistrar) context.Context {
if ctx == nil || registrar == nil {
return ctx
}
return context.WithValue(ctx, agentTurnLoopInterruptRegistrarKey{}, registrar)
}
func agentTurnLoopInterruptRegistrarFromContext(ctx context.Context) AgentTurnLoopInterruptRegistrar {
if ctx == nil {
return nil
}
if v, ok := ctx.Value(agentTurnLoopInterruptRegistrarKey{}).(AgentTurnLoopInterruptRegistrar); ok {
return v
}
return nil
}
func requestEinoNativeAgentCancel(cancelFn adk.AgentCancelFunc, cause error) (waitErr error, submitted bool, handled bool) {
if cancelFn == nil {
return nil, false, false
}
opts, waitFor := einoNativeCancelOptions(cause)
handle, submitted := cancelFn(opts...)
if !submitted || handle == nil {
return nil, submitted, false
}
waitCh := make(chan error, 1)
go func() {
waitCh <- handle.Wait()
}()
select {
case err := <-waitCh:
handled := err == nil || errors.Is(err, adk.ErrCancelTimeout) || errors.Is(err, adk.ErrExecutionEnded)
return err, submitted, handled
case <-time.After(waitFor):
return context.DeadlineExceeded, submitted, false
}
}
func einoNativeCancelOptions(cause error) ([]adk.AgentCancelOption, time.Duration) {
if errors.Is(cause, ErrInterruptContinue) {
return []adk.AgentCancelOption{
adk.WithAgentCancelMode(adk.CancelAfterChatModel | adk.CancelAfterToolCalls),
adk.WithAgentCancelTimeout(einoNativeCancelSafePointTTL),
adk.WithRecursive(),
}, einoNativeCancelSafePointWait
}
return []adk.AgentCancelOption{
adk.WithAgentCancelMode(adk.CancelImmediate),
adk.WithRecursive(),
}, einoNativeCancelImmediateWait
}
@@ -0,0 +1,27 @@
package multiagent
import (
"context"
"testing"
)
func TestEinoNativeCancelOptionsByCause(t *testing.T) {
fullStopOpts, fullStopWait := einoNativeCancelOptions(context.Canceled)
if len(fullStopOpts) != 2 {
t.Fatalf("full stop options: got %d want 2", len(fullStopOpts))
}
if fullStopWait != einoNativeCancelImmediateWait {
t.Fatalf("full stop wait: got %v want %v", fullStopWait, einoNativeCancelImmediateWait)
}
interruptOpts, interruptWait := einoNativeCancelOptions(ErrInterruptContinue)
if len(interruptOpts) != 3 {
t.Fatalf("interrupt options: got %d want 3", len(interruptOpts))
}
if interruptWait != einoNativeCancelSafePointWait {
t.Fatalf("interrupt wait: got %v want %v", interruptWait, einoNativeCancelSafePointWait)
}
if interruptWait <= einoNativeCancelSafePointTTL {
t.Fatalf("interrupt wait must allow the Eino safe-point timeout to elapse: wait=%v ttl=%v", interruptWait, einoNativeCancelSafePointTTL)
}
}
@@ -0,0 +1,41 @@
package multiagent
import (
"fmt"
"github.com/cloudwego/eino/adk"
"go.uber.org/zap"
)
func emitEinoNativeModelRetryProgress(
conversationID, orchMode string,
willRetry *adk.WillRetryError,
progress func(eventType, message string, data interface{}),
logger *zap.Logger,
runErr error,
) bool {
if willRetry == nil {
return false
}
if progress != nil {
reason := ""
if willRetry.RejectReason() != nil {
reason = fmt.Sprint(willRetry.RejectReason())
}
progress("eino_model_retry", "模型调用遇到临时问题,Eino 正在原生重试…", map[string]interface{}{
"conversationId": conversationID,
"source": "eino",
"orchestration": orchMode,
"attempt": willRetry.RetryAttempt,
"reason": reason,
"error": willRetry.Error(),
})
}
if logger != nil {
logger.Warn("eino native model retry event",
zap.String("orchestration", orchMode),
zap.Int("attempt", willRetry.RetryAttempt),
zap.Error(runErr))
}
return true
}
@@ -0,0 +1,85 @@
package multiagent
import (
"testing"
"github.com/cloudwego/eino/adk"
"go.uber.org/zap"
"go.uber.org/zap/zaptest/observer"
)
func TestEmitEinoNativeModelRetryProgress(t *testing.T) {
willRetry := &adk.WillRetryError{
ErrStr: "HTTP 429 Too Many Requests",
RetryAttempt: 2,
}
var gotType, gotMessage string
var gotData map[string]interface{}
called := emitEinoNativeModelRetryProgress("conv-1", "deep_agent", willRetry, func(eventType, message string, data interface{}) {
gotType = eventType
gotMessage = message
var ok bool
gotData, ok = data.(map[string]interface{})
if !ok {
t.Fatalf("progress data type = %T, want map[string]interface{}", data)
}
}, nil, willRetry)
if !called {
t.Fatal("called = false, want true")
}
if gotType != "eino_model_retry" {
t.Fatalf("event type = %q, want eino_model_retry", gotType)
}
if gotMessage != "模型调用遇到临时问题,Eino 正在原生重试…" {
t.Fatalf("message = %q", gotMessage)
}
assertNativeRetryMapValue(t, gotData, "conversationId", "conv-1")
assertNativeRetryMapValue(t, gotData, "source", "eino")
assertNativeRetryMapValue(t, gotData, "orchestration", "deep_agent")
assertNativeRetryMapValue(t, gotData, "attempt", 2)
assertNativeRetryMapValue(t, gotData, "reason", "")
assertNativeRetryMapValue(t, gotData, "error", "HTTP 429 Too Many Requests")
}
func TestEmitEinoNativeModelRetryProgressNilSafe(t *testing.T) {
calledProgress := false
called := emitEinoNativeModelRetryProgress("conv-1", "deep_agent", nil, func(string, string, interface{}) {
calledProgress = true
}, nil, nil)
if called {
t.Fatal("called = true, want false")
}
if calledProgress {
t.Fatal("progress called for nil willRetry")
}
}
func TestEmitEinoNativeModelRetryProgressLogsEvent(t *testing.T) {
core, logs := observer.New(zap.WarnLevel)
logger := zap.New(core)
willRetry := &adk.WillRetryError{
ErrStr: "HTTP 500",
RetryAttempt: 3,
}
emitEinoNativeModelRetryProgress("conv-1", "single_agent", willRetry, nil, logger, willRetry)
entry := logs.FilterMessage("eino native model retry event").TakeAll()
if len(entry) != 1 {
t.Fatalf("log count = %d, want 1", len(entry))
}
fields := entry[0].ContextMap()
if fields["orchestration"] != "single_agent" {
t.Fatalf("orchestration field = %v", fields["orchestration"])
}
if fields["attempt"] != int64(3) {
t.Fatalf("attempt field = %v", fields["attempt"])
}
}
func assertNativeRetryMapValue(t *testing.T, data map[string]interface{}, key string, want interface{}) {
t.Helper()
if got := data[key]; got != want {
t.Fatalf("%s = %v, want %v", key, got, want)
}
}
+27 -31
View File
@@ -9,7 +9,6 @@ import (
"cyberstrike-ai/internal/config"
"cyberstrike-ai/internal/database"
"github.com/cloudwego/eino-ext/components/model/openai"
"github.com/cloudwego/eino/adk"
"github.com/cloudwego/eino/adk/prebuilt/planexecute"
"github.com/cloudwego/eino/components/model"
@@ -19,8 +18,8 @@ import (
// PlanExecuteRootArgs 构建 Eino adk/prebuilt/planexecute 根 Agent 所需参数。
type PlanExecuteRootArgs struct {
MainToolCallingModel *openai.ChatModel
ExecModel *openai.ChatModel
MainToolCallingModel model.ToolCallingChatModel
AgenticExecModel model.AgenticModel
OrchInstruction string
ToolsCfg adk.ToolsConfig
ExecMaxIter int
@@ -35,17 +34,16 @@ type PlanExecuteRootArgs struct {
Logger *zap.Logger
// ModelName is used for model input token estimation logs.
ModelName string
// ExecPreMiddlewares 是由 prependEinoMiddlewares 构建的前置中间件patchtoolcalls, reduction, toolsearch, plantask),
// 与 Deep/Supervisor 主代理的 mainOrchestratorPre 一致。
ExecPreMiddlewares []adk.ChatModelAgentMiddleware
// SkillMiddleware 是 Eino 官方 skill 渐进式披露中间件(可选)。
SkillMiddleware adk.ChatModelAgentMiddleware
// FilesystemMiddleware 是 Eino filesystem 中间件,当 eino_skills.filesystem_tools 启用时提供本机文件读写与 Shell 能力(可选)。
FilesystemMiddleware adk.ChatModelAgentMiddleware
// AgenticExecPreMiddlewares 是由 prependEinoAgenticMiddlewares 构建的前置中间件
AgenticExecPreMiddlewares []adk.TypedChatModelAgentMiddleware[*schema.AgenticMessage]
AgenticSkillMiddleware adk.TypedChatModelAgentMiddleware[*schema.AgenticMessage]
AgenticFilesystemMiddleware adk.TypedChatModelAgentMiddleware[*schema.AgenticMessage]
// PlannerReplannerRewriteHandlers applies BeforeModelRewriteState pipeline for planner/replanner input.
PlannerReplannerRewriteHandlers []adk.ChatModelAgentMiddleware
// ModelFacingTrace 可选:由 Executor Handlers 链末尾写入,供 last_react 与 summarization 后上下文对齐。
ModelFacingTrace *modelFacingTraceHolder
AgenticModelRetryConfig *adk.TypedModelRetryConfig[*schema.AgenticMessage]
AgenticModelFailoverConfig *adk.ModelFailoverConfig[*schema.AgenticMessage]
}
// NewPlanExecuteRoot 返回 plan → execute → replan 预置编排根节点(与 Deep / Supervisor 并列)。
@@ -53,7 +51,7 @@ func NewPlanExecuteRoot(ctx context.Context, a *PlanExecuteRootArgs) (adk.Resuma
if a == nil {
return nil, fmt.Errorf("plan_execute: args 为空")
}
if a.MainToolCallingModel == nil || a.ExecModel == nil {
if a.MainToolCallingModel == nil || a.AgenticExecModel == nil {
return nil, fmt.Errorf("plan_execute: 模型为空")
}
tcm, ok := interface{}(a.MainToolCallingModel).(model.ToolCallingChatModel)
@@ -80,16 +78,16 @@ func NewPlanExecuteRoot(ctx context.Context, a *PlanExecuteRootArgs) (adk.Resuma
return nil, fmt.Errorf("plan_execute replanner: %w", err)
}
execHandlers, err := buildPlanExecuteExecutorHandlers(ctx, a)
if err != nil {
return nil, err
var executor adk.Agent
agenticExecHandlers, herr := buildPlanExecuteAgenticExecutorHandlers(ctx, a)
if herr != nil {
return nil, herr
}
executor, err := newPlanExecuteExecutor(ctx, &planexecute.ExecutorConfig{
Model: a.ExecModel,
executor, err = newPlanExecuteAgenticExecutor(ctx, &planexecute.ExecutorConfig{
ToolsConfig: a.ToolsCfg,
MaxIterations: a.ExecMaxIter,
GenInputFn: planExecuteExecutorGenInput(a.OrchInstruction, a.AppCfg, a.MwCfg, a.Logger, a.ModelName, a.ConversationID),
}, execHandlers)
}, a.AgenticExecModel, agenticExecHandlers, a.AgenticModelRetryConfig, a.AgenticModelFailoverConfig)
if err != nil {
return nil, fmt.Errorf("plan_execute executor: %w", err)
}
@@ -105,31 +103,29 @@ func NewPlanExecuteRoot(ctx context.Context, a *PlanExecuteRootArgs) (adk.Resuma
})
}
// buildPlanExecuteExecutorHandlers 组装 Executor 中间件栈(outermost first),与 Deep/Supervisor 主代理对齐:
// ExecPreMiddlewarespatch / reduction / toolsearch / plantask)→ filesystem → skill → summarization tail。
func buildPlanExecuteExecutorHandlers(ctx context.Context, a *PlanExecuteRootArgs) ([]adk.ChatModelAgentMiddleware, error) {
func buildPlanExecuteAgenticExecutorHandlers(ctx context.Context, a *PlanExecuteRootArgs) ([]adk.TypedChatModelAgentMiddleware[*schema.AgenticMessage], error) {
if a == nil {
return nil, fmt.Errorf("plan_execute: args 为空")
}
var execHandlers []adk.ChatModelAgentMiddleware
if len(a.ExecPreMiddlewares) > 0 {
execHandlers = append(execHandlers, a.ExecPreMiddlewares...)
var execHandlers []adk.TypedChatModelAgentMiddleware[*schema.AgenticMessage]
if len(a.AgenticExecPreMiddlewares) > 0 {
execHandlers = append(execHandlers, a.AgenticExecPreMiddlewares...)
}
if a.FilesystemMiddleware != nil {
execHandlers = append(execHandlers, a.FilesystemMiddleware)
if a.AgenticFilesystemMiddleware != nil {
execHandlers = append(execHandlers, a.AgenticFilesystemMiddleware)
}
if a.SkillMiddleware != nil {
execHandlers = append(execHandlers, a.SkillMiddleware)
if a.AgenticSkillMiddleware != nil {
execHandlers = append(execHandlers, a.AgenticSkillMiddleware)
}
if a.AppCfg != nil {
sumMw, sumErr := newEinoSummarizationMiddleware(ctx, a.ExecModel, a.AppCfg, a.MwCfg, a.ConversationID, a.DB, a.ProjectID, a.Logger)
sumMw, sumErr := newEinoAgenticSummarizationMiddleware(ctx, a.AgenticExecModel, a.AppCfg, a.MwCfg, a.ConversationID, a.DB, a.ProjectID, a.Logger)
if sumErr != nil {
return nil, fmt.Errorf("plan_execute executor summarization: %w", sumErr)
return nil, fmt.Errorf("plan_execute agentic executor summarization: %w", sumErr)
}
execHandlers = appendEinoChatModelTailMiddlewares(execHandlers, einoChatModelTailConfig{
execHandlers = appendEinoAgenticChatModelTailMiddlewares(execHandlers, einoChatModelTailConfig{
logger: a.Logger,
phase: "plan_execute_executor",
summarization: sumMw,
agenticSummarization: sumMw,
modelName: a.ModelName,
maxTotalTokens: a.AppCfg.OpenAI.MaxTotalTokens,
toolMaxBytes: toolMaxBytesFromMW(a.MwCfg),
@@ -0,0 +1,125 @@
package multiagent
import (
"fmt"
"strings"
"sync"
)
type einoPendingToolCalls struct {
conversationID string
progress func(eventType, message string, data interface{})
mu sync.Mutex
byID map[string]toolCallPendingInfo
queueByAgent map[string][]string
}
func newEinoPendingToolCalls(conversationID string, progress func(eventType, message string, data interface{})) *einoPendingToolCalls {
return &einoPendingToolCalls{
conversationID: conversationID,
progress: progress,
byID: make(map[string]toolCallPendingInfo),
queueByAgent: make(map[string][]string),
}
}
func (p *einoPendingToolCalls) Mark(tc toolCallPendingInfo) {
if p == nil || strings.TrimSpace(tc.ToolCallID) == "" {
return
}
p.mu.Lock()
defer p.mu.Unlock()
p.byID[tc.ToolCallID] = tc
p.queueByAgent[tc.EinoAgent] = append(p.queueByAgent[tc.EinoAgent], tc.ToolCallID)
}
func (p *einoPendingToolCalls) PopNextForAgent(agentName string) (toolCallPendingInfo, bool) {
if p == nil {
return toolCallPendingInfo{}, false
}
p.mu.Lock()
defer p.mu.Unlock()
q := p.queueByAgent[agentName]
for len(q) > 0 {
id := q[0]
q = q[1:]
p.queueByAgent[agentName] = q
if tc, ok := p.byID[id]; ok {
delete(p.byID, id)
return tc, true
}
}
return toolCallPendingInfo{}, false
}
func (p *einoPendingToolCalls) RemoveByID(toolCallID string) {
if p == nil || strings.TrimSpace(toolCallID) == "" {
return
}
p.mu.Lock()
defer p.mu.Unlock()
delete(p.byID, toolCallID)
}
func (p *einoPendingToolCalls) PopAny() (toolCallPendingInfo, bool) {
if p == nil {
return toolCallPendingInfo{}, false
}
p.mu.Lock()
defer p.mu.Unlock()
for id, tc := range p.byID {
delete(p.byID, id)
return tc, true
}
return toolCallPendingInfo{}, false
}
func (p *einoPendingToolCalls) Count() int {
if p == nil {
return 0
}
p.mu.Lock()
defer p.mu.Unlock()
return len(p.byID)
}
func (p *einoPendingToolCalls) FlushAsFailed(err error) {
if p == nil {
return
}
p.mu.Lock()
pendingSnapshot := make([]toolCallPendingInfo, 0, len(p.byID))
for _, tc := range p.byID {
pendingSnapshot = append(pendingSnapshot, tc)
}
p.byID = make(map[string]toolCallPendingInfo)
p.queueByAgent = make(map[string][]string)
p.mu.Unlock()
if p.progress == nil {
return
}
msg := ""
if err != nil {
msg = err.Error()
}
for _, tc := range pendingSnapshot {
toolName := tc.ToolName
if strings.TrimSpace(toolName) == "" {
toolName = "unknown"
}
p.progress("tool_result", fmt.Sprintf("工具结果 (%s)", toolName), map[string]interface{}{
"toolName": toolName,
"success": false,
"isError": true,
"result": msg,
"resultPreview": msg,
"toolCallId": tc.ToolCallID,
"conversationId": p.conversationID,
"einoAgent": tc.EinoAgent,
"einoRole": tc.EinoRole,
"source": "eino",
})
}
}
@@ -0,0 +1,81 @@
package multiagent
import (
"errors"
"testing"
)
func TestEinoPendingToolCallsPopNextForAgentSkipsRemovedIDs(t *testing.T) {
p := newEinoPendingToolCalls("conv", nil)
p.Mark(toolCallPendingInfo{ToolCallID: "call-1", ToolName: "first", EinoAgent: "agent"})
p.Mark(toolCallPendingInfo{ToolCallID: "call-2", ToolName: "second", EinoAgent: "agent"})
p.RemoveByID("call-1")
got, ok := p.PopNextForAgent("agent")
if !ok {
t.Fatal("expected pending tool call")
}
if got.ToolCallID != "call-2" {
t.Fatalf("toolCallID = %q, want call-2", got.ToolCallID)
}
if p.Count() != 0 {
t.Fatalf("pending count = %d, want 0", p.Count())
}
}
func TestEinoPendingToolCallsPopAny(t *testing.T) {
p := newEinoPendingToolCalls("conv", nil)
p.Mark(toolCallPendingInfo{ToolCallID: "call-1", ToolName: "tool"})
got, ok := p.PopAny()
if !ok || got.ToolCallID != "call-1" {
t.Fatalf("PopAny = %#v ok=%v", got, ok)
}
if _, ok := p.PopAny(); ok {
t.Fatal("PopAny should be empty after first pop")
}
}
func TestEinoPendingToolCallsFlushAsFailedEmitsAndClears(t *testing.T) {
var events []struct {
eventType string
message string
data map[string]interface{}
}
p := newEinoPendingToolCalls("conv-1", func(eventType, message string, data interface{}) {
m, _ := data.(map[string]interface{})
events = append(events, struct {
eventType string
message string
data map[string]interface{}
}{eventType: eventType, message: message, data: m})
})
p.Mark(toolCallPendingInfo{
ToolCallID: "call-err",
ToolName: "",
EinoAgent: "agent",
EinoRole: "sub",
})
p.FlushAsFailed(errors.New("boom"))
if p.Count() != 0 {
t.Fatalf("pending count = %d, want 0", p.Count())
}
if len(events) != 1 {
t.Fatalf("events = %#v, want one", events)
}
ev := events[0]
if ev.eventType != "tool_result" || ev.message != "工具结果 (unknown)" {
t.Fatalf("event = %#v", ev)
}
if ev.data["toolCallId"] != "call-err" ||
ev.data["conversationId"] != "conv-1" ||
ev.data["einoAgent"] != "agent" ||
ev.data["einoRole"] != "sub" ||
ev.data["isError"] != true ||
ev.data["success"] != false ||
ev.data["result"] != "boom" {
t.Fatalf("payload = %#v", ev.data)
}
}
@@ -0,0 +1,111 @@
package multiagent
import (
"strings"
"cyberstrike-ai/internal/openai"
)
type einoReasoningStreamEmitter struct {
progress func(eventType, message string, data interface{})
conversation string
orchMode string
agentName string
einoRole string
nextStreamID func() string
streamID string
rawBuf string
displayPrev string
}
func newEinoReasoningStreamEmitter(
conversationID, orchMode, agentName, einoRole string,
progress func(eventType, message string, data interface{}),
nextStreamID func() string,
) *einoReasoningStreamEmitter {
return &einoReasoningStreamEmitter{
progress: progress,
conversation: conversationID,
orchMode: orchMode,
agentName: agentName,
einoRole: einoRole,
nextStreamID: nextStreamID,
}
}
func (e *einoReasoningStreamEmitter) EmitDelta(reasoningContent string) bool {
if e == nil || strings.TrimSpace(reasoningContent) == "" {
return false
}
var rawDelta string
e.rawBuf, rawDelta = normalizeStreamingDelta(e.rawBuf, reasoningContent)
if rawDelta == "" || e.progress == nil {
return false
}
fullDisplay := openai.DisplayReasoningContent(e.rawBuf)
displayDelta := fullDisplay
if strings.HasPrefix(fullDisplay, e.displayPrev) {
displayDelta = fullDisplay[len(e.displayPrev):]
}
e.displayPrev = fullDisplay
if displayDelta == "" {
return false
}
if e.streamID == "" {
if e.nextStreamID != nil {
e.streamID = e.nextStreamID()
}
if e.streamID == "" {
e.streamID = "eino-reasoning"
}
e.progress("reasoning_chain_stream_start", " ", map[string]interface{}{
"streamId": e.streamID,
"source": "eino",
"einoAgent": e.agentName,
"einoRole": e.einoRole,
"orchestration": e.orchMode,
})
}
e.progress("reasoning_chain_stream_delta", displayDelta, openai.WithSSEAccumulated(map[string]interface{}{
"streamId": e.streamID,
}, fullDisplay))
return true
}
func (e *einoReasoningStreamEmitter) Finish() string {
if e == nil {
return ""
}
display := openai.DisplayReasoningContent(strings.TrimSpace(e.rawBuf))
if display == "" || e.streamID == "" || e.progress == nil {
return display
}
e.progress("reasoning_chain_stream_end", display, map[string]interface{}{
"streamId": e.streamID,
"conversationId": e.conversation,
"source": "eino",
"einoAgent": e.agentName,
"einoRole": e.einoRole,
"orchestration": e.orchMode,
})
return display
}
func (e *einoReasoningStreamEmitter) EmitComplete(reasoningContent string) bool {
if e == nil || e.progress == nil {
return false
}
display := openai.DisplayReasoningContent(strings.TrimSpace(reasoningContent))
if display == "" {
return false
}
e.progress("reasoning_chain", display, map[string]interface{}{
"conversationId": e.conversation,
"source": "eino",
"einoAgent": e.agentName,
"einoRole": e.einoRole,
"orchestration": e.orchMode,
})
return true
}
@@ -0,0 +1,86 @@
package multiagent
import (
"testing"
"cyberstrike-ai/internal/openai"
)
func TestEinoReasoningStreamEmitterStreamingLifecycle(t *testing.T) {
type progressEvent struct {
eventType string
message string
data map[string]interface{}
}
var events []progressEvent
progress := func(eventType, message string, data interface{}) {
m, _ := data.(map[string]interface{})
events = append(events, progressEvent{eventType: eventType, message: message, data: m})
}
emitter := newEinoReasoningStreamEmitter("conv-1", "deep", "lead", "orchestrator", progress, func() string {
return "reasoning-1"
})
if !emitter.EmitDelta("he") {
t.Fatal("first reasoning delta should emit")
}
if !emitter.EmitDelta("hello") {
t.Fatal("cumulative reasoning chunk should emit tail")
}
if got := emitter.Finish(); got != "hello" {
t.Fatalf("finish body = %q, want hello", got)
}
if len(events) != 4 {
t.Fatalf("events = %#v, want start + 2 deltas + end", events)
}
if events[0].eventType != "reasoning_chain_stream_start" || events[0].message != " " {
t.Fatalf("event[0] = %#v", events[0])
}
if events[1].eventType != "reasoning_chain_stream_delta" || events[1].message != "he" {
t.Fatalf("event[1] = %#v", events[1])
}
if events[2].eventType != "reasoning_chain_stream_delta" || events[2].message != "llo" {
t.Fatalf("event[2] = %#v", events[2])
}
if got := events[2].data[openai.SSEAccumulatedKey]; got != "hello" {
t.Fatalf("accumulated = %#v, want hello", got)
}
if events[3].eventType != "reasoning_chain_stream_end" || events[3].message != "hello" {
t.Fatalf("event[3] = %#v", events[3])
}
if got := events[3].data["einoRole"]; got != "orchestrator" {
t.Fatalf("einoRole = %#v", got)
}
}
func TestEinoReasoningStreamEmitterComplete(t *testing.T) {
var eventType, message string
var data map[string]interface{}
progress := func(et, msg string, raw interface{}) {
eventType = et
message = msg
data, _ = raw.(map[string]interface{})
}
ok := newEinoReasoningStreamEmitter("conv-1", "supervisor", "worker", "sub", progress, nil).EmitComplete(" thought ")
if !ok {
t.Fatal("complete reasoning should emit")
}
if eventType != "reasoning_chain" || message != "thought" {
t.Fatalf("event = %s %q", eventType, message)
}
if data["conversationId"] != "conv-1" || data["einoAgent"] != "worker" || data["einoRole"] != "sub" {
t.Fatalf("bad event data: %#v", data)
}
}
func TestEinoReasoningStreamEmitterNoProgressStillBuffers(t *testing.T) {
emitter := newEinoReasoningStreamEmitter("conv", "deep", "lead", "orchestrator", nil, nil)
if emitter.EmitDelta("hello") {
t.Fatal("nil progress should not emit")
}
if got := emitter.Finish(); got != "hello" {
t.Fatalf("finish body = %q, want hello", got)
}
}
@@ -0,0 +1,56 @@
package multiagent
import "context"
type einoRunCancellationHandler struct {
ctx context.Context
conversationID string
progress func(eventType, message string, data interface{})
pending *einoPendingToolCalls
takePartial einoPartialResultFunc
}
type einoRunCancellationHandlerConfig struct {
Context context.Context
ConversationID string
Progress func(eventType, message string, data interface{})
Pending *einoPendingToolCalls
TakePartial einoPartialResultFunc
}
func newEinoRunCancellationHandler(cfg einoRunCancellationHandlerConfig) *einoRunCancellationHandler {
return &einoRunCancellationHandler{
ctx: cfg.Context,
conversationID: cfg.ConversationID,
progress: cfg.Progress,
pending: cfg.Pending,
takePartial: cfg.TakePartial,
}
}
func (h *einoRunCancellationHandler) Handle(runErr error) (*RunResult, error) {
if h == nil {
return nil, runErr
}
if h.pending != nil {
h.pending.FlushAsFailed(runErr)
}
if h.progress != nil {
if isInterruptContinue(h.ctx) {
h.progress("progress", "已暂停当前输出,正在合并用户补充并继续…", map[string]interface{}{
"conversationId": h.conversationID,
"source": "eino",
"kind": "interrupt_continue",
})
} else if runErr != nil {
h.progress("error", runErr.Error(), map[string]interface{}{
"conversationId": h.conversationID,
"source": "eino",
})
}
}
if h.takePartial == nil {
return nil, runErr
}
return h.takePartial(runErr)
}
@@ -0,0 +1,97 @@
package multiagent
import (
"context"
"errors"
"testing"
)
func TestEinoRunCancellationHandlerFlushesPendingAndEmitsError(t *testing.T) {
runErr := errors.New("context canceled")
var events []struct {
eventType string
data map[string]interface{}
}
progress := func(eventType, _ string, data interface{}) {
m, _ := data.(map[string]interface{})
events = append(events, struct {
eventType string
data map[string]interface{}
}{eventType: eventType, data: m})
}
pending := newEinoPendingToolCalls("conv-1", progress)
pending.Mark(toolCallPendingInfo{ToolCallID: "call-1", ToolName: "execute", EinoAgent: "lead", EinoRole: "orchestrator"})
want := &RunResult{Response: "partial"}
result, err := newEinoRunCancellationHandler(einoRunCancellationHandlerConfig{
Context: context.Background(),
ConversationID: "conv-1",
Progress: progress,
Pending: pending,
TakePartial: func(got error) (*RunResult, error) {
if !errors.Is(got, runErr) {
t.Fatalf("partial err = %v", got)
}
return want, got
},
}).Handle(runErr)
if result != want || !errors.Is(err, runErr) {
t.Fatalf("result=%#v err=%v", result, err)
}
if pending.Count() != 0 {
t.Fatalf("pending count = %d, want 0", pending.Count())
}
var sawError, sawFailedTool bool
for _, ev := range events {
if ev.eventType == "error" {
sawError = ev.data["conversationId"] == "conv-1" && ev.data["source"] == "eino"
}
if ev.eventType == "tool_result" {
sawFailedTool = ev.data["toolCallId"] == "call-1" && ev.data["isError"] == true
}
}
if !sawError || !sawFailedTool {
t.Fatalf("events = %#v", events)
}
}
func TestEinoRunCancellationHandlerInterruptContinueProgress(t *testing.T) {
ctx, cancel := context.WithCancelCause(context.Background())
cancel(ErrInterruptContinue)
runErr := context.Canceled
var eventType string
var data map[string]interface{}
_, err := newEinoRunCancellationHandler(einoRunCancellationHandlerConfig{
Context: ctx,
ConversationID: "conv-1",
Progress: func(et, _ string, raw interface{}) {
eventType = et
data, _ = raw.(map[string]interface{})
},
TakePartial: func(got error) (*RunResult, error) {
return nil, got
},
}).Handle(runErr)
if !errors.Is(err, runErr) {
t.Fatalf("err = %v", err)
}
if eventType != "progress" || data["kind"] != "interrupt_continue" || data["conversationId"] != "conv-1" {
t.Fatalf("eventType=%q data=%#v", eventType, data)
}
}
func TestEinoRunCancellationHandlerNilSafe(t *testing.T) {
runErr := errors.New("boom")
var h *einoRunCancellationHandler
result, err := h.Handle(runErr)
if result != nil || !errors.Is(err, runErr) {
t.Fatalf("result=%#v err=%v", result, err)
}
result, err = newEinoRunCancellationHandler(einoRunCancellationHandlerConfig{}).Handle(runErr)
if result != nil || !errors.Is(err, runErr) {
t.Fatalf("result=%#v err=%v", result, err)
}
}
@@ -0,0 +1,81 @@
package multiagent
import (
"errors"
"os"
"go.uber.org/zap"
)
type einoRunCompletionHandler struct {
conversationID string
orchMode string
progress func(eventType, message string, data interface{})
logger *zap.Logger
pending *einoPendingToolCalls
cpStore *fileCheckPointStore
checkPointID string
}
type einoRunCompletionHandlerConfig struct {
ConversationID string
OrchMode string
Progress func(eventType, message string, data interface{})
Logger *zap.Logger
Pending *einoPendingToolCalls
Checkpoint *fileCheckPointStore
CheckpointID string
}
func newEinoRunCompletionHandler(cfg einoRunCompletionHandlerConfig) *einoRunCompletionHandler {
return &einoRunCompletionHandler{
conversationID: cfg.ConversationID,
orchMode: cfg.OrchMode,
progress: cfg.Progress,
logger: cfg.Logger,
pending: cfg.Pending,
cpStore: cfg.Checkpoint,
checkPointID: cfg.CheckpointID,
}
}
func (h *einoRunCompletionHandler) Complete() {
if h == nil {
return
}
h.flushOrphanedPending()
h.cleanupCheckpoint()
}
func (h *einoRunCompletionHandler) flushOrphanedPending() {
if h.pending == nil {
return
}
orphanCount := h.pending.Count()
if orphanCount <= 0 {
return
}
h.pending.FlushAsFailed(errors.New("pending tool call missing result before run completion"))
if h.progress != nil {
h.progress("eino_pending_orphaned", "pending tool calls were force-closed at run end", map[string]interface{}{
"conversationId": h.conversationID,
"source": "eino",
"orchestration": h.orchMode,
"pendingCount": orphanCount,
})
}
}
func (h *einoRunCompletionHandler) cleanupCheckpoint() {
if h.cpStore == nil || h.checkPointID == "" {
return
}
p, err := h.cpStore.path(h.checkPointID)
if err != nil {
return
}
if rmErr := os.Remove(p); rmErr != nil && !os.IsNotExist(rmErr) && h.logger != nil {
h.logger.Warn("eino checkpoint cleanup failed", zap.String("path", p), zap.Error(rmErr))
}
}
@@ -0,0 +1,72 @@
package multiagent
import (
"context"
"os"
"testing"
)
func TestEinoRunCompletionHandlerFlushesOrphansAndCleansCheckpoint(t *testing.T) {
var events []struct {
eventType string
data map[string]interface{}
}
progress := func(eventType, _ string, data interface{}) {
m, _ := data.(map[string]interface{})
events = append(events, struct {
eventType string
data map[string]interface{}
}{eventType: eventType, data: m})
}
pending := newEinoPendingToolCalls("conv-1", progress)
pending.Mark(toolCallPendingInfo{ToolCallID: "call-1", ToolName: "execute", EinoAgent: "lead", EinoRole: "orchestrator"})
store, err := newFileCheckPointStore(t.TempDir())
if err != nil {
t.Fatal(err)
}
if err := store.Set(context.Background(), "cp-1", []byte("checkpoint")); err != nil {
t.Fatal(err)
}
cpPath, err := store.path("cp-1")
if err != nil {
t.Fatal(err)
}
newEinoRunCompletionHandler(einoRunCompletionHandlerConfig{
ConversationID: "conv-1",
OrchMode: "deep",
Progress: progress,
Pending: pending,
Checkpoint: store,
CheckpointID: "cp-1",
}).Complete()
if pending.Count() != 0 {
t.Fatalf("pending count = %d, want 0", pending.Count())
}
if _, err := os.Stat(cpPath); !os.IsNotExist(err) {
t.Fatalf("checkpoint should be removed, stat err=%v", err)
}
var orphanEvent map[string]interface{}
var failedToolResult map[string]interface{}
for _, ev := range events {
switch ev.eventType {
case "eino_pending_orphaned":
orphanEvent = ev.data
case "tool_result":
failedToolResult = ev.data
}
}
if orphanEvent == nil || orphanEvent["conversationId"] != "conv-1" || orphanEvent["orchestration"] != "deep" || orphanEvent["pendingCount"] != 1 {
t.Fatalf("orphan event = %#v", orphanEvent)
}
if failedToolResult == nil || failedToolResult["toolCallId"] != "call-1" || failedToolResult["isError"] != true {
t.Fatalf("failed tool result = %#v", failedToolResult)
}
}
func TestEinoRunCompletionHandlerNoopWithoutState(t *testing.T) {
newEinoRunCompletionHandler(einoRunCompletionHandlerConfig{}).Complete()
var h *einoRunCompletionHandler
h.Complete()
}
@@ -0,0 +1,93 @@
package multiagent
import (
"context"
"errors"
"github.com/cloudwego/eino/adk"
)
type einoRunErrorHandler struct {
conversationID string
orchMode string
progress func(eventType, message string, data interface{})
pending *einoPendingToolCalls
nativeCancelFallback func() error
}
type einoRunErrorHandlerConfig struct {
ConversationID string
OrchMode string
Progress func(eventType, message string, data interface{})
Pending *einoPendingToolCalls
NativeCancelFallback func() error
}
func newEinoRunErrorHandler(cfg einoRunErrorHandlerConfig) *einoRunErrorHandler {
return &einoRunErrorHandler{
conversationID: cfg.ConversationID,
orchMode: cfg.OrchMode,
progress: cfg.Progress,
pending: cfg.Pending,
nativeCancelFallback: cfg.NativeCancelFallback,
}
}
func (h *einoRunErrorHandler) Handle(runErr error) error {
if h == nil || runErr == nil {
return runErr
}
var cancelErr *adk.CancelError
if errors.As(runErr, &cancelErr) {
h.flushPending(runErr)
if h.nativeCancelFallback != nil {
return h.nativeCancelFallback()
}
return context.Canceled
}
if errors.Is(runErr, context.DeadlineExceeded) {
h.flushPending(runErr)
h.emitError(runErr, "timeout")
return runErr
}
if errors.Is(runErr, context.Canceled) {
h.flushPending(runErr)
h.emitError(runErr, "")
return runErr
}
if isEinoIterationLimitError(runErr) {
h.flushPending(runErr)
if h.progress != nil {
h.progress("iteration_limit_reached", runErr.Error(), map[string]interface{}{
"conversationId": h.conversationID,
"source": "eino",
"orchestration": h.orchMode,
})
}
h.emitError(runErr, "iteration_limit")
return runErr
}
h.flushPending(runErr)
h.emitError(runErr, "")
return runErr
}
func (h *einoRunErrorHandler) flushPending(err error) {
if h != nil && h.pending != nil {
h.pending.FlushAsFailed(err)
}
}
func (h *einoRunErrorHandler) emitError(err error, kind string) {
if h == nil || h.progress == nil || err == nil {
return
}
data := map[string]interface{}{
"conversationId": h.conversationID,
"source": "eino",
}
if kind != "" {
data["errorKind"] = kind
}
h.progress("error", err.Error(), data)
}
@@ -0,0 +1,101 @@
package multiagent
import (
"context"
"errors"
"testing"
"github.com/cloudwego/eino/adk"
)
func TestEinoRunErrorHandlerCancelUsesNativeFallback(t *testing.T) {
pending := newEinoPendingToolCalls("conv-1", nil)
pending.Mark(toolCallPendingInfo{ToolCallID: "call-1", ToolName: "execute"})
want := errors.New("native cancel")
got := newEinoRunErrorHandler(einoRunErrorHandlerConfig{
ConversationID: "conv-1",
Pending: pending,
NativeCancelFallback: func() error {
return want
},
}).Handle(&adk.CancelError{Info: &adk.AgentCancelInfo{}})
if !errors.Is(got, want) {
t.Fatalf("err = %v, want native fallback", got)
}
if pending.Count() != 0 {
t.Fatalf("pending count = %d, want 0", pending.Count())
}
}
func TestEinoRunErrorHandlerTimeoutAndGeneralErrorProgress(t *testing.T) {
for _, tc := range []struct {
name string
err error
errorKind interface{}
}{
{name: "timeout", err: context.DeadlineExceeded, errorKind: "timeout"},
{name: "general", err: errors.New("boom"), errorKind: nil},
} {
t.Run(tc.name, func(t *testing.T) {
var data map[string]interface{}
got := newEinoRunErrorHandler(einoRunErrorHandlerConfig{
ConversationID: "conv-1",
Progress: func(eventType, _ string, raw interface{}) {
if eventType == "error" {
data, _ = raw.(map[string]interface{})
}
},
}).Handle(tc.err)
if !errors.Is(got, tc.err) {
t.Fatalf("err = %v", got)
}
if data["conversationId"] != "conv-1" || data["source"] != "eino" {
t.Fatalf("data = %#v", data)
}
if gotKind := data["errorKind"]; gotKind != tc.errorKind {
t.Fatalf("errorKind = %#v, want %#v", gotKind, tc.errorKind)
}
})
}
}
func TestEinoRunErrorHandlerIterationLimitProgress(t *testing.T) {
var events []string
var errorKind interface{}
err := errors.New("maximum iteration reached")
got := newEinoRunErrorHandler(einoRunErrorHandlerConfig{
ConversationID: "conv-1",
OrchMode: "deep",
Progress: func(eventType, _ string, raw interface{}) {
events = append(events, eventType)
if eventType == "error" {
data, _ := raw.(map[string]interface{})
errorKind = data["errorKind"]
}
},
}).Handle(err)
if !errors.Is(got, err) {
t.Fatalf("err = %v", got)
}
if len(events) != 2 || events[0] != "iteration_limit_reached" || events[1] != "error" {
t.Fatalf("events = %#v", events)
}
if errorKind != "iteration_limit" {
t.Fatalf("errorKind = %#v", errorKind)
}
}
func TestEinoRunErrorHandlerNilSafe(t *testing.T) {
var h *einoRunErrorHandler
if h.Handle(nil) != nil {
t.Fatal("nil handler nil err should return nil")
}
err := errors.New("boom")
if got := h.Handle(err); !errors.Is(got, err) {
t.Fatalf("nil handler err = %v", got)
}
}
+245
View File
@@ -0,0 +1,245 @@
package multiagent
import (
"context"
"fmt"
"sync/atomic"
"cyberstrike-ai/internal/agent"
"cyberstrike-ai/internal/config"
"cyberstrike-ai/internal/einomcp"
"github.com/cloudwego/eino/adk"
"go.uber.org/zap"
)
type einoRunEventDrainConfig struct {
Context context.Context
ConversationID string
OrchMode string
OrchestratorName string
Progress func(eventType, message string, data interface{})
Logger *zap.Logger
BaseMessages []adk.Message
SnapshotMCPIDs func() []string
StreamsMainAssistant func(agent string) bool
EinoRoleTag func(agent string) string
MiddlewareConfig *config.MultiAgentEinoMiddlewareConfig
FilesystemMonitorAgent *agent.Agent
FilesystemMonitorRecord einomcp.ExecutionRecorder
MCPExecutionBinder *MCPExecutionBinder
}
type einoRunEventDrain struct {
cfg einoRunEventDrainConfig
runMessages *einoRunMessageAccumulator
assistantOutput *einoAssistantOutputAccumulator
runProgress *einoRunProgressTracker
pendingToolCalls *einoPendingToolCalls
stdoutSuppressor *einoExecuteStdoutSuppressor
toolResultEmitter *einoToolResultProgressEmitter
usage *einoRunUsageAccumulator
reasoningStreamSeq int64
subReplyStreamSeq int64
mainResponseStreamSeq int64
toolResultHandler *einoToolResultEventHandler
assistantStreamHandler *einoAssistantStreamEventHandler
materializedMessageHandler *einoMaterializedMessageEventHandler
}
func newEinoRunEventDrain(cfg einoRunEventDrainConfig) *einoRunEventDrain {
if cfg.Context == nil {
cfg.Context = context.Background()
}
if cfg.SnapshotMCPIDs == nil {
cfg.SnapshotMCPIDs = func() []string { return nil }
}
if cfg.StreamsMainAssistant == nil {
cfg.StreamsMainAssistant = func(agentName string) bool {
return agentName == "" || agentName == cfg.OrchestratorName
}
}
if cfg.EinoRoleTag == nil {
cfg.EinoRoleTag = func(agentName string) string {
if cfg.StreamsMainAssistant(agentName) {
return "orchestrator"
}
return "sub"
}
}
runMessages := newEinoRunMessageAccumulator(cfg.BaseMessages)
assistantOutput := newEinoAssistantOutputAccumulator(cfg.OrchMode)
runProgress := newEinoRunProgressTracker(
cfg.OrchMode,
cfg.OrchestratorName,
cfg.ConversationID,
cfg.Progress,
cfg.StreamsMainAssistant,
cfg.EinoRoleTag,
)
pendingToolCalls := newEinoPendingToolCalls(cfg.ConversationID, cfg.Progress)
stdoutSuppressor := newEinoExecuteStdoutSuppressor()
usage := newEinoRunUsageAccumulator()
toolResultEmitter := newEinoToolResultProgressEmitter(einoToolResultProgressEmitterConfig{
ConversationID: cfg.ConversationID,
OrchestratorName: cfg.OrchestratorName,
Progress: cfg.Progress,
EinoRoleTag: cfg.EinoRoleTag,
Pending: pendingToolCalls,
ExecuteStdoutDup: stdoutSuppressor,
RunMessages: runMessages,
FilesystemMonitorAgent: cfg.FilesystemMonitorAgent,
FilesystemMonitorRecord: cfg.FilesystemMonitorRecord,
MCPExecutionBinder: cfg.MCPExecutionBinder,
})
return &einoRunEventDrain{
cfg: cfg,
runMessages: runMessages,
assistantOutput: assistantOutput,
runProgress: runProgress,
pendingToolCalls: pendingToolCalls,
stdoutSuppressor: stdoutSuppressor,
toolResultEmitter: toolResultEmitter,
usage: usage,
}
}
func (d *einoRunEventDrain) BindHandlers(confirmRecovery func()) {
if d == nil {
return
}
d.toolResultHandler = newEinoToolResultEventHandler(einoToolResultEventHandlerConfig{
Context: d.cfg.Context,
Logger: d.cfg.Logger,
RunMessages: d.runMessages,
Emitter: d.toolResultEmitter,
ConfirmRecovery: confirmRecovery,
})
streamToolCallCompletion := newEinoStreamToolCallCompletionHandler(einoStreamToolCallCompletionHandlerConfig{
ConversationID: d.cfg.ConversationID,
OrchMode: d.cfg.OrchMode,
Progress: d.cfg.Progress,
RunProgress: d.runProgress,
RunMessages: d.runMessages,
MarkPending: d.markPendingWithMonitor,
})
d.assistantStreamHandler = newEinoAssistantStreamEventHandler(einoAssistantStreamEventHandlerConfig{
Context: d.cfg.Context,
ConversationID: d.cfg.ConversationID,
OrchMode: d.cfg.OrchMode,
Progress: d.cfg.Progress,
Logger: d.cfg.Logger,
SnapshotMCPIDs: d.cfg.SnapshotMCPIDs,
StreamsMainAssistant: d.cfg.StreamsMainAssistant,
EinoRoleTag: d.cfg.EinoRoleTag,
RunProgress: d.runProgress,
StdoutSuppressor: d.stdoutSuppressor,
AssistantOutput: d.assistantOutput,
RunMessages: d.runMessages,
Usage: d.usage,
ToolCallCompletion: streamToolCallCompletion,
NextMainStreamID: d.nextMainStreamID,
NextReasoningStreamID: d.nextReasoningStreamID,
NextSubAgentReplyStreamID: d.nextSubAgentReplyStreamID,
})
d.materializedMessageHandler = newEinoMaterializedMessageEventHandler(einoMaterializedMessageEventHandlerConfig{
ConversationID: d.cfg.ConversationID,
OrchMode: d.cfg.OrchMode,
Progress: d.cfg.Progress,
SnapshotMCPIDs: d.cfg.SnapshotMCPIDs,
StreamsMainAssistant: d.cfg.StreamsMainAssistant,
EinoRoleTag: d.cfg.EinoRoleTag,
RunProgress: d.runProgress,
StdoutSuppressor: d.stdoutSuppressor,
AssistantOutput: d.assistantOutput,
RunMessages: d.runMessages,
Usage: d.usage,
ToolResultHandler: d.toolResultHandler,
MarkPending: d.markPendingWithMonitor,
NextMainStreamID: d.nextMainStreamID,
})
}
func (d *einoRunEventDrain) RunMessages() *einoRunMessageAccumulator {
if d == nil {
return nil
}
return d.runMessages
}
func (d *einoRunEventDrain) AssistantOutput() *einoAssistantOutputAccumulator {
if d == nil {
return nil
}
return d.assistantOutput
}
func (d *einoRunEventDrain) PendingToolCalls() *einoPendingToolCalls {
if d == nil {
return nil
}
return d.pendingToolCalls
}
func (d *einoRunEventDrain) Usage() *einoRunUsageAccumulator {
if d == nil {
return nil
}
return d.usage
}
func (d *einoRunEventDrain) ObserveAgent(agentName string) {
if d == nil || d.runProgress == nil {
return
}
d.runProgress.ObserveAgent(agentName)
}
func (d *einoRunEventDrain) HandleToolResultStreaming(mv *adk.MessageVariant, agentName string) bool {
return d != nil && d.toolResultHandler != nil && d.toolResultHandler.HandleStreaming(mv, agentName)
}
func (d *einoRunEventDrain) HandleAssistantStream(mv *adk.MessageVariant, agentName string) (bool, error) {
if d == nil || d.assistantStreamHandler == nil {
return false, nil
}
return d.assistantStreamHandler.Handle(mv, agentName)
}
func (d *einoRunEventDrain) HandleMaterialized(mv *adk.MessageVariant, msg adk.Message, agentName string) bool {
return d != nil && d.materializedMessageHandler != nil && d.materializedMessageHandler.Handle(mv, msg, agentName)
}
func (d *einoRunEventDrain) markPendingWithMonitor(tc toolCallPendingInfo) {
if d == nil || d.pendingToolCalls == nil {
return
}
d.pendingToolCalls.Mark(tc)
beginEinoADKFilesystemToolMonitor(
d.cfg.Context,
d.cfg.FilesystemMonitorAgent,
d.cfg.FilesystemMonitorRecord,
d.cfg.MCPExecutionBinder,
tc.ToolCallID,
tc.ToolName,
tc.Arguments,
)
}
func (d *einoRunEventDrain) nextMainStreamID() string {
return fmt.Sprintf("eino-main-%s-%d", d.cfg.ConversationID, atomic.AddInt64(&d.mainResponseStreamSeq, 1))
}
func (d *einoRunEventDrain) nextReasoningStreamID() string {
return fmt.Sprintf("eino-reasoning-%s-%d", d.cfg.ConversationID, atomic.AddInt64(&d.reasoningStreamSeq, 1))
}
func (d *einoRunEventDrain) nextSubAgentReplyStreamID() string {
return fmt.Sprintf("eino-sub-reply-%s-%d", d.cfg.ConversationID, atomic.AddInt64(&d.subReplyStreamSeq, 1))
}
@@ -0,0 +1,79 @@
package multiagent
import (
"testing"
"github.com/cloudwego/eino/adk"
"github.com/cloudwego/eino/schema"
)
func TestEinoRunEventDrainDefaultsAndStreamIDs(t *testing.T) {
drain := newEinoRunEventDrain(einoRunEventDrainConfig{
ConversationID: "conv-1",
OrchestratorName: "lead",
BaseMessages: []adk.Message{schema.UserMessage("base")},
})
if drain.RunMessages().BaseCount() != 1 {
t.Fatalf("base count = %d, want 1", drain.RunMessages().BaseCount())
}
if !drain.cfg.StreamsMainAssistant("lead") || drain.cfg.StreamsMainAssistant("worker") {
t.Fatal("default main-assistant predicate should match only orchestrator")
}
if got := drain.cfg.EinoRoleTag("lead"); got != "orchestrator" {
t.Fatalf("lead role = %q, want orchestrator", got)
}
if got := drain.cfg.EinoRoleTag("worker"); got != "sub" {
t.Fatalf("worker role = %q, want sub", got)
}
if got := drain.nextMainStreamID(); got != "eino-main-conv-1-1" {
t.Fatalf("first main stream id = %q", got)
}
if got := drain.nextMainStreamID(); got != "eino-main-conv-1-2" {
t.Fatalf("second main stream id = %q", got)
}
}
func TestEinoRunEventDrainBindsHandlersAndRecordsEvents(t *testing.T) {
var events []string
recovered := false
drain := newEinoRunEventDrain(einoRunEventDrainConfig{
ConversationID: "conv-1",
OrchMode: "deep",
OrchestratorName: "lead",
Progress: func(eventType, _ string, _ interface{}) {
events = append(events, eventType)
},
BaseMessages: []adk.Message{schema.UserMessage("base")},
})
drain.BindHandlers(func() { recovered = true })
drain.ObserveAgent("lead")
if !drain.HandleMaterialized(&adk.MessageVariant{Role: schema.Assistant}, schema.AssistantMessage("done", nil), "lead") {
t.Fatal("materialized assistant should be handled")
}
if got := drain.AssistantOutput().LastAssistant(); got != "done" {
t.Fatalf("last assistant = %q, want done", got)
}
stream := schema.StreamReaderFromArray([]*schema.Message{
{Role: schema.Tool, Content: "ok", ToolCallID: "call-1"},
})
if !drain.HandleToolResultStreaming(&adk.MessageVariant{
IsStreaming: true,
Role: schema.Tool,
ToolName: "execute",
MessageStream: stream,
}, "lead") {
t.Fatal("streaming tool result should be handled")
}
if !recovered {
t.Fatal("tool stream completion should confirm recovery")
}
if len(drain.RunMessages().Messages()) != 3 {
t.Fatalf("run messages = %#v, want base + assistant + tool", drain.RunMessages().Messages())
}
if !containsString(events, "iteration") || !containsString(events, "response_start") || !containsString(events, "tool_result") {
t.Fatalf("events = %#v, want iteration, response and tool_result", events)
}
}

Some files were not shown because too many files have changed in this diff Show More