Compare commits

...
72 Commits
Author SHA1 Message Date
a24702a178 1 (#267)
* fix: 为 Eino agentic 路径补充 tool_call/tool_result 配对防御中间件

agentic 路径(单代理模式)缺少 toolPairReconciler 和 orphanToolPruner,
当 summarization 截断历史破坏配对后,序列化到 OpenAI API 触发 400
"insufficient tool messages following tool_calls message"。

新增 AgenticMessage 版本的 reconciler 和 pruner,与 classic 路径对齐。

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: 为 Eino agentic 路径补充 tool_call/tool_result 配对防御中间件 (#265) (#266)

agentic 路径(单代理模式)缺少 toolPairReconciler 和 orphanToolPruner,
当 summarization 截断历史破坏配对后,序列化到 OpenAI API 触发 400
"insufficient tool messages following tool_calls message"。

新增 AgenticMessage 版本的 reconciler 和 pruner,与 classic 路径对齐。

Co-authored-by: temp <temp@tempdeMacBook-Pro.local>
Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: temp <temp@tempdeMacBook-Pro.local>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 17:31:39 +08:00
8b3dc0e4d2 fix: 为 Eino agentic 路径补充 tool_call/tool_result 配对防御中间件 (#265)
agentic 路径(单代理模式)缺少 toolPairReconciler 和 orphanToolPruner,
当 summarization 截断历史破坏配对后,序列化到 OpenAI API 触发 400
"insufficient tool messages following tool_calls message"。

新增 AgenticMessage 版本的 reconciler 和 pruner,与 classic 路径对齐。

Co-authored-by: temp <temp@tempdeMacBook-Pro.local>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 17:28:29 +08:00
RuoJi6 bec2d2faf1 修复多会话任务栏抖动、会话跳回与停止失效 (#264)
* fix(web): stabilize active task ordering

* fix(chat): preserve navigation during conversation startup

* fix(tasks): guarantee hard cancellation

* fix(chat): bind navigation and hard stop targets

* fix(chat): prevent replay from reclaiming navigation
2026-08-19 13:54:30 +08:00
RuoJi6 c7cc0bc9da 修复人工审批长内容遮挡及刷新审批人状态异常 (#263)
* docs(hitl): remove stale asm_list_resources references

* fix(hitl): constrain approval layout and preserve reviewer
2026-08-19 13:31:37 +08:00
公明andCursor 24d06c5220 Fix missing results for parallel Eino tool calls.
Merge streaming tool outputs by CallID with ConcatMessages, pair same-name historical results, and FIFO-match duplicate IDs so concurrent nmap 1/2 and 2/2 stay distinct.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 00:29:12 +08:00
公明 ac6e04a94c Update config.example.yaml 2026-08-19 00:14:46 +08:00
公明 cb58b90083 Update .gitignore 2026-08-18 20:53:37 +08:00
公明 5ccabdc3e4 Add files via upload 2026-08-18 20:52:02 +08:00
公明 286511d0a8 Add files via upload 2026-08-18 20:48:50 +08:00
公明 1f66b93866 Add files via upload 2026-08-18 20:41:19 +08:00
公明 c21ba6c419 Add files via upload 2026-08-18 20:38:40 +08:00
公明 5720de0d27 Add files via upload 2026-08-18 20:36:34 +08:00
公明 5aa2c382b0 Add files via upload 2026-08-18 20:34:53 +08:00
公明 98c380ed5f Add files via upload 2026-08-18 20:31:48 +08:00
公明 11537713ce Add files via upload 2026-08-18 20:30:18 +08:00
公明 acc2ebd7e2 Add files via upload 2026-08-18 20:29:17 +08:00
公明 5eaddd9e8c Add files via upload 2026-08-18 20:25:43 +08:00
公明 3421d8b047 Add files via upload 2026-08-18 20:23:28 +08:00
公明 5b000d1e3e Add files via upload 2026-08-18 20:22:40 +08:00
公明 cecbe5a086 Add files via upload 2026-08-18 20:20:39 +08:00
公明 c7603c2b66 Add files via upload 2026-08-18 20:17:38 +08:00
公明 d3bfb1c98e Add files via upload 2026-08-18 20:15:30 +08:00
公明 55ee5fbac9 Delete internal directory 2026-08-18 20:14:29 +08:00
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
227 changed files with 30672 additions and 5203 deletions
+24 -1
View File
@@ -1,30 +1,48 @@
# Runtime data
data/
chat_uploads/
*.db
*.db-shm
*.db-wal
*.db-journal
*.sqlite
*.sqlite3
# Local configuration and secrets
config.yaml
config.local.yaml
config.yaml.backup
*.backup
.env
.env.*
!.env.example
*.pem
*.key
*.crt
# Upgrade / settings backups
.upgrade-backup/
# Eino runtime task board (under skills_dir)
skills/.eino/
# Build outputs
cyberstrike-ai
cyberstrike-ai.exe
*.test
bin/
dist/
build/
.build/
target/
.gradle/
coverage.out
coverage.html
# Logs and temporary files
*.log
*.bak
*~
tmp/
temp/
@@ -38,7 +56,12 @@ __pycache__/
# Go
vendor/
# macOS / editors
# Node / browser extension
node_modules/
# macOS / Windows / editors
.DS_Store
Thumbs.db
desktop.ini
.idea/
.vscode/
+17 -11
View File
@@ -10,7 +10,7 @@
# ============================================
# 前端显示的版本号(可选,不填则显示默认版本)
version: "v1.7.12"
version: "v1.7.15"
# 服务器配置
server:
host: 0.0.0.0 # 监听地址,0.0.0.0 表示监听所有网络接口
@@ -57,7 +57,7 @@ monitor:
# - DeepSeek: https://api.deepseek.com/v1
# - 其他兼容 OpenAI 协议的 API
# 常用模型: gpt-4, gpt-3.5-turbo, deepseek-chat, claude-3-opus 等
# provider: 可选值 openai_compatible(默认) | claude(自动桥接到 Anthropic Claude Messages API)
# provider: 可选值 openai_compatible(默认) | claude(Eino 原生 Anthropic Messages API)
ai:
default_channel: qwen-max
channels:
@@ -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
@@ -99,10 +107,10 @@ fofa:
base_url: https://fofa.info/api/v1/search/all # 可选,留空则使用默认
api_key: "" # FOFA API Key(可选,建议在系统设置中填写)
zoomeye:
base_url: https://api.zoomeye.org/v2/search # 可选,留空则使用默认
base_url: https://api.zoomeye.ai/v2/search # 可选,留空则使用默认
api_key: "" # ZoomEye API Key;也可通过 ZOOMEYE_API_KEY 环境变量配置
quake:
base_url: https://quake.360.cn/api/v3/search/quake_service # 可选,留空则使用默认
base_url: https://quake.360.net/api/v3/search/quake_service # 可选,留空则使用默认
api_key: "" # Quake API Token;也可通过 QUAKE_API_KEY 环境变量配置
shodan:
base_url: https://api.shodan.io # 可选,留空则使用默认
@@ -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:
+1 -1
View File
@@ -68,7 +68,7 @@ ai:
| `ai.default_channel` | Default channel ID for new conversations and requests without an explicit channel. |
| `ai.channels.<id>` | Channel config. IDs are normalized to lowercase letters, digits, and hyphens. |
| `name` | Display name in the Web UI; falls back to the ID. |
| `provider` | `openai_compatible` or `claude`. OpenAI-compatible channels map to runtime `openai`; Claude channels bridge to Anthropic Messages API. |
| `provider` | `openai_compatible` or `claude`. OpenAI-compatible channels map to runtime `openai`; Claude channels use Eino's native Anthropic Messages API component. |
| `base_url/api_key/model` | Required. Base URL usually includes a version path such as `/v1`. |
| `max_total_tokens` | Shared context budget for compression, attack-chain generation, multi-agent summaries, and similar paths. |
| `max_completion_tokens` | Per-response output cap; default is used when empty. |
+1 -1
View File
@@ -10,7 +10,7 @@ CyberStrikeAI can run as a local testing tool, an internal team service, or a pr
- Python for some MCP servers and tool scripts.
- SQLite files under `data/`; no external DB is required by default.
- Actual security tools installed in PATH. YAML files under `tools/` only describe commands.
- At least one `ai.channels` entry. Use `provider: openai_compatible` for OpenAI-compatible endpoints, or `provider: claude` for the Claude bridge.
- At least one `ai.channels` entry. Use `provider: openai_compatible` for OpenAI-compatible endpoints, or `provider: claude` for Eino's native Claude component.
Important persistent paths:
+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
+1 -1
View File
@@ -65,7 +65,7 @@ ai:
| `ai.default_channel` | 默认通道 ID。新对话、机器人、批量任务和未显式选择通道的请求使用它。 |
| `ai.channels.<id>` | 通道配置。ID 会归一化为小写、数字和短横线,例如 `Qwen_Max` 会变成 `qwen-max`。 |
| `name` | Web UI 展示名。留空时使用通道 ID。 |
| `provider` | `openai_compatible``claude``openai_compatible` 会在运行时映射为 `openai``claude` 会桥接到 Anthropic Messages API。 |
| `provider` | `openai_compatible``claude``openai_compatible` 会在运行时映射为 `openai``claude` 使用 Eino 原生 Anthropic Messages API。 |
| `base_url/api_key/model` | 必填。Base URL 通常需要包含版本路径,如 OpenAI/兼容网关的 `/v1`。 |
| `max_total_tokens` | 上下文压缩、攻击链构建、多代理摘要等共用的总预算。 |
| `max_completion_tokens` | 单次模型输出上限;未填时使用默认值。 |
+1 -1
View File
@@ -10,7 +10,7 @@
- Python:部分 MCP 服务或工具脚本需要 Python 运行环境。
- SQLite:默认使用文件型数据库,无需单独服务。
- 安全工具:`tools/` 中的 YAML 只是工具定义,实际命令如 `nmap``sqlmap``nuclei` 仍需安装到系统 PATH。
- 模型服务:至少配置一个 `ai.channels` 通道;`provider: openai_compatible` 适用于 OpenAI 兼容 API`provider: claude` 会走 Claude 桥接
- 模型服务:至少配置一个 `ai.channels` 通道;`provider: openai_compatible` 适用于 OpenAI 兼容 API`provider: claude` 使用 Eino 原生 Claude 组件
建议目录:
+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`
## 模式选择
+70 -26
View File
@@ -3,17 +3,19 @@ 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/agenticclaude v0.1.3
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
@@ -23,53 +25,84 @@ require (
github.com/gorilla/websocket v1.5.3
github.com/larksuite/oapi-sdk-go/v3 v3.4.22
github.com/mattn/go-sqlite3 v1.14.18
github.com/modelcontextprotocol/go-sdk v1.2.0
github.com/modelcontextprotocol/go-sdk v1.3.1
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1
github.com/pkoukk/tiktoken-go v0.1.8
github.com/robfig/cron/v3 v3.0.1
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 (
cloud.google.com/go/auth v0.7.2 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.3 // indirect
cloud.google.com/go/compute/metadata v0.9.0 // indirect
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/anthropics/anthropic-sdk-go v1.56.0 // indirect
github.com/aws/aws-sdk-go-v2 v1.30.3 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.3 // indirect
github.com/aws/aws-sdk-go-v2/config v1.27.27 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.17.27 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.11 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.15 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.15 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.3 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.17 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.22.4 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.26.4 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.30.3 // indirect
github.com/aws/smithy-go v1.20.3 // 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/buger/jsonparser v1.1.2 // 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/felixge/httpsnoop v1.0.4 // 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/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/google/jsonschema-go v0.4.2 // indirect
github.com/google/s2a-go v0.1.7 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.2 // indirect
github.com/goph/emperror v0.17.2 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.25.1 // indirect
github.com/invopop/jsonschema v0.14.0 // 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 +110,45 @@ 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/pb33f/ordered-map/v2 v2.3.1 // indirect
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/segmentio/asm v1.1.3 // indirect
github.com/segmentio/encoding v0.5.4 // 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/standard-webhooks/standard-webhooks/libraries v0.0.1 // 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.opencensus.io v0.24.0 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.49.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // 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
go.yaml.in/yaml/v4 v4.0.0-rc.2 // 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/api v0.189.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"
+238 -58
View File
@@ -1,36 +1,88 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go/auth v0.7.2 h1:uiha352VrCDMXg+yoBtaD0tUF4Kv9vrtrWPYXwutnDE=
cloud.google.com/go/auth v0.7.2/go.mod h1:VEc4p5NNxycWQTMQEDQF0bd6aTMb6VgYDXEwiJJQAbs=
cloud.google.com/go/auth/oauth2adapt v0.2.3 h1:MlxF+Pd3OmSudg/b1yZ5lJwoXCEaeedAguodky1PcKI=
cloud.google.com/go/auth/oauth2adapt v0.2.3/go.mod h1:tMQXOfZzFuNuUxOypHlQEXgdfX5cuhwU+ffUuXRJE8I=
cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
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/azidentity v1.7.0 h1:tfLQ34V6F7tVSwoTf/4lH5sE0o6eCJuNDTmH09nDpbc=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.7.0/go.mod h1:9kIvujWAA58nmPmWB1m23fyWic1kYZMxD9CxaWn4Qpg=
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/AzureAD/microsoft-authentication-library-for-go v1.2.2 h1:XHOnouVk1mxXfQidrMEnLlPk9UMeRtyBTnEFtxkV0kU=
github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
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/anthropics/anthropic-sdk-go v1.56.0 h1:idVU14wOZ06D0GBNEvuhn927xXmBVEquo0469iDwLsc=
github.com/anthropics/anthropic-sdk-go v1.56.0/go.mod h1:3EfIfmFqxH6rbiLcIP4tPFyXL/IHakx2wDG4OU+TIEI=
github.com/aws/aws-sdk-go-v2 v1.30.3 h1:jUeBtG0Ih+ZIFH0F4UkmL9w3cSpaMv9tYYDbzILP8dY=
github.com/aws/aws-sdk-go-v2 v1.30.3/go.mod h1:nIQjQVp5sfpQcTc9mPSr1B0PaWK5ByX9MOoDadSN4lc=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.3 h1:tW1/Rkad38LA15X4UQtjXZXNKsCgkshC3EbmcUmghTg=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.3/go.mod h1:UbnqO+zjqk3uIt9yCACHJ9IVNhyhOCnYk8yA19SAWrM=
github.com/aws/aws-sdk-go-v2/config v1.27.27 h1:HdqgGt1OAP0HkEDDShEl0oSYa9ZZBSOmKpdpsDMdO90=
github.com/aws/aws-sdk-go-v2/config v1.27.27/go.mod h1:MVYamCg76dFNINkZFu4n4RjDixhVr51HLj4ErWzrVwg=
github.com/aws/aws-sdk-go-v2/credentials v1.17.27 h1:2raNba6gr2IfA0eqqiP2XiQ0UVOpGPgDSi0I9iAP+UI=
github.com/aws/aws-sdk-go-v2/credentials v1.17.27/go.mod h1:gniiwbGahQByxan6YjQUMcW4Aov6bLC3m+evgcoN4r4=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.11 h1:KreluoV8FZDEtI6Co2xuNk/UqI9iwMrOx/87PBNIKqw=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.11/go.mod h1:SeSUYBLsMYFoRvHE0Tjvn7kbxaUhl75CJi1sbfhMxkU=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.15 h1:SoNJ4RlFEQEbtDcCEt+QG56MY4fm4W8rYirAmq+/DdU=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.15/go.mod h1:U9ke74k1n2bf+RIgoX1SXFed1HLs51OgUSs+Ph0KJP8=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.15 h1:C6WHdGnTDIYETAm5iErQUiVNsclNx9qbJVPIt03B6bI=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.15/go.mod h1:ZQLZqhcu+JhSrA9/NXRm8SkDvsycE+JkV3WGY41e+IM=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0 h1:hT8rVHwugYE2lEfdFE0QWVo81lF7jMrYJVDWI+f+VxU=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0/go.mod h1:8tu/lYfQfFe6IGnaOdrpVgEL2IrrDOf6/m9RQum4NkY=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.3 h1:dT3MqvGhSoaIhRseqw2I0yH81l7wiR2vjs57O51EAm8=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.3/go.mod h1:GlAeCkHwugxdHaueRr4nhPuY+WW+gR8UjlcqzPr1SPI=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.17 h1:HGErhhrxZlQ044RiM+WdoZxp0p+EGM62y3L6pwA4olE=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.17/go.mod h1:RkZEx4l0EHYDJpWppMJ3nD9wZJAa8/0lq9aVC+r2UII=
github.com/aws/aws-sdk-go-v2/service/sso v1.22.4 h1:BXx0ZIxvrJdSgSvKTZ+yRBeSqqgPM89VPlulEcl37tM=
github.com/aws/aws-sdk-go-v2/service/sso v1.22.4/go.mod h1:ooyCOXjvJEsUw7x+ZDHeISPMhtwI3ZCB7ggFMcFfWLU=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.26.4 h1:yiwVzJW2ZxZTurVbYWA7QOrAaCYQR72t0wrSBfoesUE=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.26.4/go.mod h1:0oxfLkpz3rQ/CHlx5hB7H69YUpFiI1tql6Q6Ne+1bCw=
github.com/aws/aws-sdk-go-v2/service/sts v1.30.3 h1:ZsDKRLXGWHk8WdtyYMoGNO7bTudrvuKpDKgMVRlepGE=
github.com/aws/aws-sdk-go-v2/service/sts v1.30.3/go.mod h1:zwySh8fpFyXp9yOr/KVzxOl8SRqgf/IDw5aUt9UKFcQ=
github.com/aws/smithy-go v1.20.3 h1:ryHwveWzPV5BIof6fyDvor6V3iUL7nTfiTKXHiW05nE=
github.com/aws/smithy-go v1.20.3/go.mod h1:krry+ya/rV9RDcV/Q16kpu6ypI4K2czasz0NC3qS14E=
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=
github.com/bitly/go-simplejson v0.5.0/go.mod h1:cXHtHw4XUPsvGaxgjIAn8PhEWG9NfngEKAMDJEczWVA=
github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs=
github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4=
github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=
github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
github.com/buger/jsonparser v1.1.2 h1:frqHqw7otoVbk5M8LlE/L7HTnIq2v9RX6EJ48i9AxJk=
github.com/buger/jsonparser v1.1.2/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
github.com/bugsnag/bugsnag-go v1.4.0/go.mod h1:2oa8nejYd4cQ/b0hMIopN0lCRxU0bueqREvZLWFrtK8=
github.com/bugsnag/panicwrap v1.2.0/go.mod h1:D/8v3kj0zr8ZAKg1AQ6crr+5VwKN5eIywRkfhyM/+dE=
github.com/bwmarrin/discordgo v0.29.0 h1:FmWeXFaKUwrcL3Cx65c20bTRW+vOb6k8AnaP+EgjDno=
github.com/bwmarrin/discordgo v0.29.0/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY=
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/mockey v1.4.6/go.mod h1:1BPHF9sol5R1ud/+0VEHGQq/+i2lN+GTsr3O2Q9IENY=
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=
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
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/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
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 +91,17 @@ 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/agenticclaude v0.1.3 h1:nfO9jUPhIKTHDPyL7rso6y7Mx8cAmrIgkeJC6KqTuXI=
github.com/cloudwego/eino-ext/components/model/agenticclaude v0.1.3/go.mod h1:/dX1P7HNC7D3/nYLDoXBvdKAFnGS4tBbtJ8NrkaXuUk=
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/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 h1:6xNmx7iTtyBRev0+D/Tv1FZd4SCg8axKApyNyRsAt/w=
github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI=
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=
@@ -54,12 +113,27 @@ github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
github.com/dlclark/regexp2 v1.10.0 h1:+/GIL799phkJqYW+3YbOd8LCcbHzT0Pbo8zl70MHsq0=
github.com/dlclark/regexp2 v1.10.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI=
github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/eino-contrib/jsonschema v1.0.3 h1:2Kfsm1xlMV0ssY2nuxshS4AwbLFuqmPmzIjLVJ1Fsp0=
github.com/eino-contrib/jsonschema v1.0.3/go.mod h1:cpnX4SyKjWjGC7iN2EbhxaTdLqGjCi0e9DxpLYxddD4=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA=
github.com/envoyproxy/go-control-plane/envoy v1.36.0 h1:yg/JjO5E7ubRyKX3m07GF3reDNEnfOboJ0QySbH736g=
github.com/envoyproxy/go-control-plane/envoy v1.36.0/go.mod h1:ty89S1YCCVruQAm9OtKeEkQLTb+Lkz0k8v9W0Oxsv98=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/envoyproxy/protoc-gen-validate v1.3.0 h1:TvGH1wof4H33rezVKWSpqKz5NXWg5VPuZ0uONDT6eb4=
github.com/envoyproxy/protoc-gen-validate v1.3.0/go.mod h1:HvYl7zwPa5mffgyeTUHA9zHIH36nmrm7oCbo4YKoSWA=
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/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
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 +146,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 +161,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=
@@ -97,31 +176,49 @@ github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
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/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8=
github.com/google/jsonschema-go v0.4.2/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/s2a-go v0.1.7 h1:60BLSyTrOV4/haCDW4zb1guZItoSq8foHCXrAnjBo/o=
github.com/google/s2a-go v0.1.7/go.mod h1:50CgR4k1jNlWBu4UfS4AcfhVe1r6pdZPygJ3R8F0Qdw=
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
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=
github.com/googleapis/enterprise-certificate-proxy v0.3.2 h1:Vie5ybvEvT75RniqhfFxPRy3Bf7vr3h0cechB90XaQs=
github.com/googleapis/enterprise-certificate-proxy v0.3.2/go.mod h1:VLSiSSBs/ksPL8kq3OBOQ6WRI2QnaFynd1DCjZ62+V0=
github.com/goph/emperror v0.17.2 h1:yLapQcmEsO0ipe9p5TaN22djm3OFV/TfM/fcYP0/J18=
github.com/goph/emperror v0.17.2/go.mod h1:+ZbQ+fUNO/6FNiUo0ujtMjhgad9Xa6fQL9KhH4LNHic=
github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g=
@@ -133,7 +230,11 @@ github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/ad
github.com/grpc-ecosystem/grpc-gateway/v2 v2.25.1 h1:VNqngBF40hVlDloBruUehVYC3ArSgIyScOAyMRqBxRg=
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/invopop/jsonschema v0.14.0 h1:MHQqLhvpNUZfw+hM3AZDYK7jxO8FZoQeQM77g8iyZjg=
github.com/invopop/jsonschema v0.14.0/go.mod h1:ygm6C2EaVNMBDPpaPlnOA2pFAxBnxGjFlMZABxm9n2I=
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 +244,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=
@@ -153,14 +256,16 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/larksuite/oapi-sdk-go/v3 v3.4.22 h1:57daKuslQPX9X3hC2idc5bu8bl2krfsBGWGJ6b5FlD8=
github.com/larksuite/oapi-sdk-go/v3 v3.4.22/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI=
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=
@@ -169,8 +274,8 @@ github.com/meguminnnnnnnnn/go-openai v0.1.2 h1:iXombGGjqjBrmE9WaSidUhhi3YQhf42QT
github.com/meguminnnnnnnnn/go-openai v0.1.2/go.mod h1:qs96ysDmxhE4BZoU45I43zcyfnaYxU3X+aRzLko/htY=
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b h1:j7+1HpAFS1zy5+Q4qx1fWh90gTKwiN4QCGoY9TWyyO4=
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
github.com/modelcontextprotocol/go-sdk v1.2.0 h1:Y23co09300CEk8iZ/tMxIX1dVmKZkzoSBZOpJwUnc/s=
github.com/modelcontextprotocol/go-sdk v1.2.0/go.mod h1:6fM3LCm3yV7pAs8isnKLn07oKtB0MP9LHd3DfAcKw10=
github.com/modelcontextprotocol/go-sdk v1.3.1 h1:TfqtNKOIWN4Z1oqmPAiWDC2Jq7K9OdJaooe0teoXASI=
github.com/modelcontextprotocol/go-sdk v1.3.1/go.mod h1:DgVX498dMD8UJlseK1S5i1T4tFz2fkBk4xogC3D15nw=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -184,20 +289,34 @@ 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/pb33f/ordered-map/v2 v2.3.1 h1:5319HDO0aw4DA4gzi+zv4FXU9UlSs3xGZ40wcP1nBjY=
github.com/pb33f/ordered-map/v2 v2.3.1/go.mod h1:qxFQgd0PkVUtOMCkTapqotNgzRhMPL7VvaHKbd1HnmQ=
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/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkoukk/tiktoken-go v0.1.8 h1:85ENo+3FpWgAACBaEUVp+lctuTcYUO7BtmfhlN/QTRo=
github.com/pkoukk/tiktoken-go v0.1.8/go.mod h1:9NiV+i9mJKGj1rYOT+njbv+ZwA/zJxYdewGl6qVatpg=
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo=
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
@@ -205,6 +324,10 @@ github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/f
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/rollbar/rollbar-go v1.0.2/go.mod h1:AcFs5f0I+c71bpHlXNNDbOWJiKwjFDtISeXco0L5PKQ=
github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc=
github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg=
github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0=
github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0=
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
@@ -218,6 +341,8 @@ github.com/smarty/assertions v1.16.0 h1:EvHNkdRA4QHMrn75NZSoUQ/mAUXAYWfatfB01yTC
github.com/smarty/assertions v1.16.0/go.mod h1:duaaFdCS0K9dnoM50iyek/eYINOZ64gbh1Xlf6LG7AI=
github.com/smartystreets/goconvey v1.8.1 h1:qGjIddxOk4grTu9JPOU31tVfq3cNdBlNa5sSznIX1xY=
github.com/smartystreets/goconvey v1.8.1/go.mod h1:+/u4qLyY6x1jReYOp7GOM2FSt8aP9CzCZL03bI28W60=
github.com/standard-webhooks/standard-webhooks/libraries v0.0.1 h1:uOfcYT+3QungH6tIGSVCR/Y3KJmgJiHcojJbMTPDZAI=
github.com/standard-webhooks/standard-webhooks/libraries v0.0.1/go.mod h1:L1MQhA6x4dn9r007T033lsaZMv9EmBAdXyU/+EF40fo=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
@@ -237,12 +362,19 @@ 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/tidwall/gjson v1.9.3 h1:hqzS9wAHMO+KVBBkLxYdkEeeFHuqr95GfClRLKlgK0E=
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/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 +392,44 @@ 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.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
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/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.49.0 h1:4Pp6oUg3+e/6M4C0A/3kJ2VYa++dsWVTtGgLVj5xtHg=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.49.0/go.mod h1:Mjt1i1INqiaoZOMGR1RIUJN+i3ChKoFRqzrRQhlkbs0=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw=
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=
go.yaml.in/yaml/v4 v4.0.0-rc.2 h1:/FrI8D64VSr4HtGIlUtlFMGsm7H7pWTbj6vOLVZcA6s=
go.yaml.in/yaml/v4 v4.0.0-rc.2/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
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,22 +439,33 @@ 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-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
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=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
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-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
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=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
@@ -320,19 +473,22 @@ 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.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
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-20181108010431-42b317875d0f/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-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
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 +510,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,39 +526,60 @@ 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=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
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/api v0.189.0 h1:equMo30LypAkdkLMBqfeIqtyAnlyig1JSZArl4XPwdI=
google.golang.org/api v0.189.0/go.mod h1:FLWGJKb0hb+pU2j+rJqwbnsF+ym+fQs73rbJ+KAUgy8=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
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.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
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=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
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=
@@ -414,7 +591,10 @@ gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
+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)
+42 -58
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"`
}
@@ -555,15 +537,19 @@ func NormalizeMultiAgentOrchestration(s string) string {
// MultiAgentAPIUpdate 设置页/API 仅更新多代理标量字段;写入 YAML 时不覆盖 sub_agents 等块。
type MultiAgentAPIUpdate struct {
Enabled bool `json:"enabled"`
RobotDefaultAgentMode string `json:"robot_default_agent_mode,omitempty"`
BatchUseMultiAgent bool `json:"batch_use_multi_agent"`
PlanExecuteLoopMaxIterations *int `json:"plan_execute_loop_max_iterations,omitempty"`
SummarizationUserIntentLedgerMaxRunes *int `json:"summarization_user_intent_ledger_max_runes,omitempty"`
SummarizationUserIntentLedgerEntryMaxRunes *int `json:"summarization_user_intent_ledger_entry_max_runes,omitempty"`
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"`
Enabled bool `json:"enabled"`
RobotDefaultAgentMode string `json:"robot_default_agent_mode,omitempty"`
BatchUseMultiAgent bool `json:"batch_use_multi_agent"`
PlanExecuteLoopMaxIterations *int `json:"plan_execute_loop_max_iterations,omitempty"`
SummarizationUserIntentLedgerMaxRunes *int `json:"summarization_user_intent_ledger_max_runes,omitempty"`
SummarizationUserIntentLedgerEntryMaxRunes *int `json:"summarization_user_intent_ledger_entry_max_runes,omitempty"`
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"`
}
@@ -831,7 +817,7 @@ type MCPConfig struct {
}
type OpenAIConfig struct {
Provider string `yaml:"provider,omitempty" json:"provider,omitempty"` // API 提供商: "openai"(默认) 或 "claude"claude 时自动桥接为 Anthropic Messages API
Provider string `yaml:"provider,omitempty" json:"provider,omitempty"` // API 提供商: "openai"(默认) 或 "claude"claude 使用 Eino 原生 Anthropic Messages API
APIKey string `yaml:"api_key" json:"api_key"`
BaseURL string `yaml:"base_url" json:"base_url"`
Model string `yaml:"model" json:"model"`
@@ -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")
}
}
+178 -51
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
}
@@ -1463,12 +1548,12 @@ func (db *DB) GetProcessDetailsSummary(messageID string) (*ProcessDetailsSummary
seenExecIDs := make(map[string]bool)
// A provider may reuse a fallback toolCallId across streaming rounds. Keep a
// FIFO per ID instead of a single index so every persisted call gets at most
// one result. Results without a stable ID are kept separate instead of being
// guessed by order; showing no link is safer than linking to the wrong tool.
// one result. ID-less results still attach to an unmatched call with the same
// tool name (parallel nmap 1/2, 2/2 often lose one ID); different tools stay
// unlinked so a leftover preview cannot steal another call's slot.
toolIndexesByCallID := make(map[string][]int)
lastMatchedToolIndexByCallID := make(map[string]int)
matchedToolIndexes := make([]bool, 0)
nextUnmatchedToolIdx := 0
for execRows.Next() {
var detailID string
var eventType string
@@ -1484,24 +1569,10 @@ func (db *DB) GetProcessDetailsSummary(messageID string) (*ProcessDetailsSummary
if err := json.Unmarshal([]byte(dataJSON), &payload); err != nil {
continue
}
toolName, _ := payload["toolName"].(string)
toolName = strings.TrimSpace(toolName)
toolCallID, _ := payload["toolCallId"].(string)
toolCallID = strings.TrimSpace(toolCallID)
execID, _ := payload["executionId"].(string)
execID = strings.TrimSpace(execID)
status := ""
if eventType == "tool_result" {
if success, ok := payload["success"].(bool); ok {
if success {
status = "completed"
} else {
status = "failed"
}
} else if isErr, ok := payload["isError"].(bool); ok && isErr {
status = "failed"
}
}
toolName := processDetailString(payload, "toolName")
toolCallID := processDetailString(payload, "toolCallId")
execID := processDetailString(payload, "executionId")
status := toolResultStatusFromPayload(payload, eventType)
if eventType == "tool_call" {
summary.ToolExecutions = append(summary.ToolExecutions, ProcessDetailsToolExecution{
ProcessDetailID: strings.TrimSpace(detailID),
@@ -1518,36 +1589,14 @@ func (db *DB) GetProcessDetailsSummary(messageID string) (*ProcessDetailsSummary
}
}
if eventType == "tool_result" {
idx := -1
if toolCallID != "" {
queue := toolIndexesByCallID[toolCallID]
for len(queue) > 0 {
candidate := queue[0]
queue = queue[1:]
if candidate >= 0 && candidate < len(matchedToolIndexes) && !matchedToolIndexes[candidate] {
idx = candidate
break
}
}
toolIndexesByCallID[toolCallID] = queue
if idx < 0 {
// Multiple persisted result events for one call (for example an
// agent-facing reduced result replacing an earlier preview) update
// that call instead of consuming an unrelated FIFO entry.
if previous, ok := lastMatchedToolIndexByCallID[toolCallID]; ok {
idx = previous
}
}
}
if idx < 0 && toolCallID != "" {
for nextUnmatchedToolIdx < len(matchedToolIndexes) && matchedToolIndexes[nextUnmatchedToolIdx] {
nextUnmatchedToolIdx++
}
if nextUnmatchedToolIdx < len(matchedToolIndexes) {
idx = nextUnmatchedToolIdx
nextUnmatchedToolIdx++
}
}
idx := matchToolExecutionIndex(
summary.ToolExecutions,
matchedToolIndexes,
toolCallID,
toolName,
toolIndexesByCallID,
lastMatchedToolIndexByCallID,
)
if idx >= 0 && idx < len(summary.ToolExecutions) {
matchedToolIndexes[idx] = true
if toolCallID != "" {
@@ -1563,6 +1612,8 @@ func (db *DB) GetProcessDetailsSummary(messageID string) (*ProcessDetailsSummary
summary.ToolExecutions[idx].ExecutionID = execID
if status != "" {
summary.ToolExecutions[idx].Status = status
} else {
summary.ToolExecutions[idx].Status = "completed"
}
} else {
summary.ToolExecutions = append(summary.ToolExecutions, ProcessDetailsToolExecution{
@@ -1619,6 +1670,82 @@ func (db *DB) GetProcessDetailsSummary(messageID string) (*ProcessDetailsSummary
return summary, nil
}
func processDetailString(payload map[string]interface{}, key string) string {
if payload == nil {
return ""
}
v, ok := payload[key]
if !ok || v == nil {
return ""
}
s := strings.TrimSpace(fmt.Sprint(v))
if s == "" || s == "<nil>" {
return ""
}
return s
}
func toolResultStatusFromPayload(payload map[string]interface{}, eventType string) string {
if eventType != "tool_result" {
return ""
}
if status := processDetailString(payload, "status"); strings.EqualFold(status, "background_running") {
return "background_running"
}
if success, ok := payload["success"].(bool); ok {
if success {
return "completed"
}
return "failed"
}
if isErr, ok := payload["isError"].(bool); ok && isErr {
return "failed"
}
return "completed"
}
func matchToolExecutionIndex(
executions []ProcessDetailsToolExecution,
matched []bool,
toolCallID, toolName string,
toolIndexesByCallID map[string][]int,
lastMatchedToolIndexByCallID map[string]int,
) int {
if toolCallID != "" {
queue := toolIndexesByCallID[toolCallID]
for len(queue) > 0 {
candidate := queue[0]
queue = queue[1:]
if candidate >= 0 && candidate < len(matched) && !matched[candidate] {
toolIndexesByCallID[toolCallID] = queue
return candidate
}
}
toolIndexesByCallID[toolCallID] = queue
if previous, ok := lastMatchedToolIndexByCallID[toolCallID]; ok {
return previous
}
}
if toolName != "" {
for i := range matched {
if matched[i] {
continue
}
if strings.EqualFold(strings.TrimSpace(executions[i].ToolName), toolName) {
return i
}
}
}
if toolCallID != "" {
for i := range matched {
if !matched[i] {
return i
}
}
}
return -1
}
// GetProcessDetailsPage 分页获取消息的过程详情(按时间升序)。
func (db *DB) GetProcessDetailsPage(messageID string, limit, offset int) ([]ProcessDetail, int, error) {
var total int
+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,11 +3,12 @@ package database
import (
"path/filepath"
"testing"
"time"
"go.uber.org/zap"
)
func TestProcessDetailsSummaryDoesNotGuessIDLessResultsByOrder(t *testing.T) {
func TestProcessDetailsSummaryDoesNotGuessIDLessResultsOntoDifferentTool(t *testing.T) {
db, conversationID, messageID := setupProcessDetailsSummaryTest(t)
for _, id := range []string{"call-1", "call-2", "call-3", "call-4"} {
if err := db.AddProcessDetail(messageID, conversationID, "tool_call", "call", map[string]interface{}{
@@ -19,8 +20,8 @@ func TestProcessDetailsSummaryDoesNotGuessIDLessResultsByOrder(t *testing.T) {
results := []map[string]interface{}{
{"toolName": "http-framework-test", "toolCallId": "call-1", "success": true},
{"toolName": "http-framework-test", "toolCallId": "call-2", "success": true},
{"toolName": "http-framework-test", "success": true},
{"toolName": "http-framework-test", "success": true},
{"toolName": "other-tool", "success": true},
{"toolName": "other-tool", "success": true},
}
var resultIDs []string
for _, result := range results {
@@ -52,12 +53,71 @@ func TestProcessDetailsSummaryDoesNotGuessIDLessResultsByOrder(t *testing.T) {
}
}
for i, execution := range summary.ToolExecutions[4:] {
if execution.Status != "completed" || execution.ToolCallID != "" {
if execution.Status != "completed" || execution.ToolCallID != "" || execution.ToolName != "other-tool" {
t.Fatalf("idless result %d = %#v, want separate completed result without toolCallId", i, execution)
}
}
}
func TestProcessDetailsSummaryPairsIDLessResultsWithSameToolName(t *testing.T) {
db, conversationID, messageID := setupProcessDetailsSummaryTest(t)
for i, id := range []string{"call-1", "call-2"} {
if err := db.AddProcessDetail(messageID, conversationID, "tool_call", "call", map[string]interface{}{
"toolName": "nmap", "toolCallId": id, "index": i + 1, "total": 2,
}); err != nil {
t.Fatalf("AddProcessDetail(tool_call): %v", err)
}
}
var resultIDs []string
for i := 0; i < 2; i++ {
resultID, err := db.AddProcessDetailWithID(messageID, conversationID, "tool_result", "result", map[string]interface{}{
"toolName": "nmap", "success": true,
})
if err != nil {
t.Fatalf("AddProcessDetail(tool_result): %v", err)
}
resultIDs = append(resultIDs, resultID)
}
summary, err := db.GetProcessDetailsSummary(messageID)
if err != nil {
t.Fatalf("GetProcessDetailsSummary: %v", err)
}
if len(summary.ToolExecutions) != 2 {
t.Fatalf("tool executions = %d, want 2", len(summary.ToolExecutions))
}
for i, execution := range summary.ToolExecutions {
if execution.Status != "completed" {
t.Fatalf("execution %d status = %q, want completed", i, execution.Status)
}
if execution.ResultDetailID != resultIDs[i] {
t.Fatalf("execution %d result detail id = %q, want %q", i, execution.ResultDetailID, resultIDs[i])
}
}
}
func TestProcessDetailsSummaryPairedResultWithoutSuccessIsCompleted(t *testing.T) {
db, conversationID, messageID := setupProcessDetailsSummaryTest(t)
if err := db.AddProcessDetail(messageID, conversationID, "tool_call", "call", map[string]interface{}{
"toolName": "nmap", "toolCallId": "call-1",
}); err != nil {
t.Fatalf("AddProcessDetail(tool_call): %v", err)
}
if err := db.AddProcessDetail(messageID, conversationID, "tool_result", "result", map[string]interface{}{
"toolName": "nmap", "toolCallId": "call-1", "resultPreview": "open 22",
}); err != nil {
t.Fatalf("AddProcessDetail(tool_result): %v", err)
}
summary, err := db.GetProcessDetailsSummary(messageID)
if err != nil {
t.Fatalf("GetProcessDetailsSummary: %v", err)
}
if len(summary.ToolExecutions) != 1 || summary.ToolExecutions[0].Status != "completed" {
t.Fatalf("tool executions = %#v, want completed", summary.ToolExecutions)
}
}
func TestProcessDetailsSummaryPairsRepeatedToolCallIDsFIFO(t *testing.T) {
db, conversationID, messageID := setupProcessDetailsSummaryTest(t)
for i := 0; i < 2; i++ {
@@ -105,6 +165,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
}
+8 -4
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,
@@ -206,7 +210,7 @@ func (h *runHandler) onStart(ctx context.Context, info *callbacks.RunInfo, input
"spanId": spanID,
"parentSpanId": parentID,
"conversationId": strings.TrimSpace(h.params.ConversationID),
"orchestration": strings.TrimSpace(h.params.OrchMode),
"orchestration": strings.TrimSpace(h.params.OrchMode),
"component": string(ri.Component),
"name": ri.Name,
"type": ri.Type,
@@ -255,7 +259,7 @@ func (h *runHandler) onEnd(ctx context.Context, info *callbacks.RunInfo, output
"runId": h.runID,
"spanId": spanID,
"conversationId": strings.TrimSpace(h.params.ConversationID),
"orchestration": strings.TrimSpace(h.params.OrchMode),
"orchestration": strings.TrimSpace(h.params.OrchMode),
"component": string(ri.Component),
"name": ri.Name,
"type": ri.Type,
@@ -301,7 +305,7 @@ func (h *runHandler) onError(ctx context.Context, info *callbacks.RunInfo, err e
"runId": h.runID,
"spanId": spanID,
"conversationId": strings.TrimSpace(h.params.ConversationID),
"orchestration": strings.TrimSpace(h.params.OrchMode),
"orchestration": strings.TrimSpace(h.params.OrchMode),
"component": string(ri.Component),
"name": ri.Name,
"type": ri.Type,
+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)
+100 -23
View File
@@ -75,8 +75,11 @@ found:
// responsePlanAgg buffers main-assistant response_stream chunks for one "planning" process_detail row.
type responsePlanAgg struct {
meta map[string]interface{}
b strings.Builder
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())
+65 -1
View File
@@ -18,11 +18,13 @@ import (
"cyberstrike-ai/internal/config"
"cyberstrike-ai/internal/database"
"cyberstrike-ai/internal/knowledge"
"cyberstrike-ai/internal/llm"
"cyberstrike-ai/internal/mcp"
"cyberstrike-ai/internal/mcp/builtin"
"cyberstrike-ai/internal/openai"
"cyberstrike-ai/internal/security"
"github.com/cloudwego/eino/schema"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"gopkg.in/yaml.v3"
@@ -356,6 +358,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 +1008,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 +1045,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)),
)
}
@@ -1184,7 +1218,7 @@ func (h *ConfigHandler) TestOpenAI(c *gin.Context) {
"max_completion_tokens": 5,
}
// 使用内部 openai Client 进行测试,若 provider 为 claude 会自动走桥接层
// OpenAI-compatible 通道使用内部客户端;Claude 通道在下方直接使用 Eino agenticclaude。
tmpCfg := &config.OpenAIConfig{
Provider: req.Provider,
BaseURL: baseURL,
@@ -1197,6 +1231,28 @@ func (h *ConfigHandler) TestOpenAI(c *gin.Context) {
defer cancel()
start := time.Now()
if llm.IsClaudeProvider(req.Provider) {
nativeModel, err := llm.NewClaudeAgenticModel(ctx, *tmpCfg, nil, 5, nil)
if err == nil {
_, err = nativeModel.Generate(ctx, []*schema.AgenticMessage{
schema.UserAgenticMessage("Hi"),
})
}
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"error": "连接失败: " + err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"model": tmpCfg.Model,
"latency_ms": time.Since(start).Milliseconds(),
})
return
}
var chatResp struct {
ID string `json:"id"`
Object string `json:"object"`
@@ -2191,10 +2247,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)
}
}
}
+133 -5
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,10 +497,11 @@ 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,
"status": true, "modelFacingIsError": true, "resultPreview": true,
"status": true, "modelFacingIsError": true, "resultPreview": true,
}
out := make(map[string]interface{}, len(allow)+1)
for k, v := range m {
@@ -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)
+210 -54
View File
@@ -1,11 +1,13 @@
package handler
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
@@ -87,6 +89,24 @@ type fofaSearchResponse struct {
Results []map[string]interface{} `json:"results"`
}
type spaceSearchEnvelope struct {
Code interface{} `json:"code"`
Message string `json:"message"`
Error string `json:"error"`
Query string `json:"query"`
Total int `json:"total"`
TotalCount int `json:"total_count"`
Page int `json:"page"`
PageSize int `json:"pagesize"`
Data json.RawMessage `json:"data"`
Matches json.RawMessage `json:"matches"`
Meta struct {
Pagination struct {
Total int `json:"total"`
} `json:"pagination"`
} `json:"meta"`
}
func normalizeSpaceSearchProvider(provider string) string {
switch strings.ToLower(strings.TrimSpace(provider)) {
case "", "fofa":
@@ -156,19 +176,20 @@ func (h *FofaHandler) resolveAPIKey(provider string) string {
}
func (h *FofaHandler) resolveBaseURL(provider string) string {
provider = normalizeSpaceSearchProvider(provider)
if h.cfg != nil {
switch normalizeSpaceSearchProvider(provider) {
switch provider {
case "zoomeye":
if v := strings.TrimSpace(h.cfg.ZoomEye.BaseURL); v != "" {
return v
return canonicalizeSpaceSearchBaseURL(provider, v)
}
case "quake":
if v := strings.TrimSpace(h.cfg.Quake.BaseURL); v != "" {
return v
return canonicalizeSpaceSearchBaseURL(provider, v)
}
case "shodan":
if v := strings.TrimSpace(h.cfg.Shodan.BaseURL); v != "" {
return v
return canonicalizeSpaceSearchBaseURL(provider, v)
}
default:
if v := strings.TrimSpace(h.cfg.FOFA.BaseURL); v != "" {
@@ -176,11 +197,11 @@ func (h *FofaHandler) resolveBaseURL(provider string) string {
}
}
}
switch normalizeSpaceSearchProvider(provider) {
switch provider {
case "zoomeye":
return "https://api.zoomeye.org/v2/search"
return "https://api.zoomeye.ai/v2/search"
case "quake":
return "https://quake.360.cn/api/v3/search/quake_service"
return "https://quake.360.net/api/v3/search/quake_service"
case "shodan":
return "https://api.shodan.io"
default:
@@ -188,6 +209,20 @@ func (h *FofaHandler) resolveBaseURL(provider string) string {
}
}
func canonicalizeSpaceSearchBaseURL(provider, raw string) string {
v := strings.TrimSpace(raw)
if v == "" {
return v
}
switch normalizeSpaceSearchProvider(provider) {
case "zoomeye":
v = strings.Replace(v, "://api.zoomeye.org", "://api.zoomeye.ai", 1)
case "quake":
v = strings.Replace(v, "://quake.360.cn", "://quake.360.net", 1)
}
return v
}
// ParseNaturalLanguage 将自然语言解析为 FOFA 查询语法(仅生成,不执行查询)
func (h *FofaHandler) ParseNaturalLanguage(c *gin.Context) {
var req fofaParseRequest
@@ -716,23 +751,17 @@ func (h *FofaHandler) searchZoomEye(c *gin.Context, req fofaSearchRequest, apiKe
if fields := strings.TrimSpace(req.Fields); fields != "" {
body["fields"] = fields
}
var apiResp struct {
Code int `json:"code"`
Message string `json:"message"`
Query string `json:"query"`
Total int `json:"total"`
Page int `json:"page"`
PageSize int `json:"pagesize"`
Data []map[string]interface{} `json:"data"`
}
var apiResp spaceSearchEnvelope
if !h.doJSONRequest(c, http.MethodPost, u.String(), apiKey, "API-KEY", body, &apiResp, "ZoomEye") {
return
}
if apiResp.Code != 60000 {
msg := strings.TrimSpace(apiResp.Message)
if msg == "" {
msg = "ZoomEye 返回错误"
}
rows, err := decodeSpaceSearchRows(apiResp.Data)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "解析 ZoomEye 响应失败: " + err.Error()})
return
}
if zoomEyeRequestFailed(apiResp.Code, apiResp.Message) {
msg := firstNonEmptySpaceSearchValue(apiResp.Message, messageFromRawObject(apiResp.Data), "ZoomEye 返回错误")
c.JSON(http.StatusBadGateway, gin.H{"error": msg})
return
}
@@ -744,8 +773,8 @@ func (h *FofaHandler) searchZoomEye(c *gin.Context, req fofaSearchRequest, apiKe
Page: firstPositive(apiResp.Page, req.Page),
Total: apiResp.Total,
Fields: fields,
ResultsCount: len(apiResp.Data),
Results: projectRows(apiResp.Data, fields),
ResultsCount: len(rows),
Results: projectRows(rows, fields),
})
}
@@ -766,25 +795,17 @@ func (h *FofaHandler) searchQuake(c *gin.Context, req fofaSearchRequest, apiKey
if len(fields) > 0 {
body["include"] = fields
}
var apiResp struct {
Code interface{} `json:"code"`
Message string `json:"message"`
TotalCount int `json:"total_count"`
Data []map[string]interface{} `json:"data"`
Meta struct {
Pagination struct {
Total int `json:"total"`
} `json:"pagination"`
} `json:"meta"`
}
var apiResp spaceSearchEnvelope
if !h.doJSONRequest(c, http.MethodPost, u.String(), apiKey, "X-QuakeToken", body, &apiResp, "Quake") {
return
}
rows, err := decodeSpaceSearchRows(apiResp.Data)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "解析 Quake 响应失败: " + err.Error()})
return
}
if !isZeroSpaceSearchCode(apiResp.Code) {
msg := strings.TrimSpace(apiResp.Message)
if msg == "" {
msg = "Quake 返回错误"
}
msg := firstNonEmptySpaceSearchValue(apiResp.Message, messageFromRawObject(apiResp.Data), "Quake 返回错误")
c.JSON(http.StatusBadGateway, gin.H{"error": msg})
return
}
@@ -796,8 +817,8 @@ func (h *FofaHandler) searchQuake(c *gin.Context, req fofaSearchRequest, apiKey
Page: req.Page,
Total: total,
Fields: fields,
ResultsCount: len(apiResp.Data),
Results: projectRows(apiResp.Data, fields),
ResultsCount: len(rows),
Results: projectRows(rows, fields),
})
}
@@ -811,6 +832,9 @@ func isZeroSpaceSearchCode(code interface{}) bool {
return v == 0
case float64:
return v == 0
case json.Number:
n, err := v.Int64()
return err == nil && n == 0
case string:
return strings.TrimSpace(v) == "0"
default:
@@ -818,6 +842,128 @@ func isZeroSpaceSearchCode(code interface{}) bool {
}
}
func isZoomEyeSuccessCode(code interface{}) bool {
switch v := code.(type) {
case int:
return v == 60000
case int64:
return v == 60000
case float64:
return v == 60000
case json.Number:
n, err := v.Int64()
return err == nil && n == 60000
case string:
return strings.TrimSpace(v) == "60000"
default:
return false
}
}
func zoomEyeRequestFailed(code interface{}, message string) bool {
if isZoomEyeSuccessCode(code) {
return false
}
msg := strings.ToLower(strings.TrimSpace(message))
if code == nil || isZeroSpaceSearchCode(code) {
return msg != "" && msg != "success" && msg != "ok" && msg != "successful."
}
return true
}
func decodeSpaceSearchRows(raw json.RawMessage) ([]map[string]interface{}, error) {
raw = bytes.TrimSpace(raw)
if len(raw) == 0 || bytes.Equal(raw, []byte("null")) {
return nil, nil
}
switch raw[0] {
case '[':
var rows []map[string]interface{}
if err := json.Unmarshal(raw, &rows); err != nil {
return nil, err
}
if rows == nil {
return []map[string]interface{}{}, nil
}
return rows, nil
case '{':
var obj map[string]interface{}
if err := json.Unmarshal(raw, &obj); err != nil {
return nil, err
}
if len(obj) == 0 {
return []map[string]interface{}{}, nil
}
for _, key := range []string{"data", "items", "matches", "results", "list", "records"} {
nested, ok := obj[key]
if !ok {
continue
}
switch rows := nested.(type) {
case []map[string]interface{}:
return rows, nil
case []interface{}:
return interfaceSliceToRowMaps(rows), nil
}
}
return []map[string]interface{}{}, nil
default:
return nil, fmt.Errorf("unexpected JSON value")
}
}
func interfaceSliceToRowMaps(items []interface{}) []map[string]interface{} {
out := make([]map[string]interface{}, 0, len(items))
for _, item := range items {
if row, ok := item.(map[string]interface{}); ok {
out = append(out, row)
}
}
return out
}
func messageFromRawObject(raw json.RawMessage) string {
raw = bytes.TrimSpace(raw)
if len(raw) == 0 || raw[0] != '{' {
return ""
}
var obj map[string]interface{}
if err := json.Unmarshal(raw, &obj); err != nil {
return ""
}
for _, key := range []string{"message", "error", "errmsg", "msg"} {
if s, ok := obj[key].(string); ok {
if msg := strings.TrimSpace(s); msg != "" {
return msg
}
}
}
return ""
}
func extractRemoteAPIError(body []byte, statusCode int, label string) string {
trimmed := bytes.TrimSpace(body)
if len(trimmed) > 0 && trimmed[0] == '{' {
var obj map[string]interface{}
if err := json.Unmarshal(trimmed, &obj); err == nil {
for _, key := range []string{"error", "message", "errmsg", "msg"} {
if s, ok := obj[key].(string); ok {
if msg := strings.TrimSpace(s); msg != "" {
return msg
}
}
}
}
}
if len(trimmed) > 0 && trimmed[0] == '<' {
return fmt.Sprintf("%s 返回了网页而不是 JSONHTTP %d),请检查 Base URL 或网络是否被拦截", label, statusCode)
}
if statusCode < 200 || statusCode >= 300 {
return fmt.Sprintf("%s 返回非 2xx: %d", label, statusCode)
}
return ""
}
func (h *FofaHandler) searchShodan(c *gin.Context, req fofaSearchRequest, apiKey string) {
baseURL := strings.TrimRight(h.resolveBaseURL("shodan"), "/") + "/shodan/host/search"
u, err := url.Parse(baseURL)
@@ -826,11 +972,7 @@ func (h *FofaHandler) searchShodan(c *gin.Context, req fofaSearchRequest, apiKey
return
}
var apiResp struct {
Total int `json:"total"`
Matches []map[string]interface{} `json:"matches"`
Error string `json:"error"`
}
var apiResp spaceSearchEnvelope
targetSize := req.Size
if targetSize <= 0 {
targetSize = 100
@@ -852,19 +994,23 @@ func (h *FofaHandler) searchShodan(c *gin.Context, req fofaSearchRequest, apiKey
params.Set("fields", fields)
}
pageURL.RawQuery = params.Encode()
apiResp.Matches = nil
apiResp.Error = ""
apiResp = spaceSearchEnvelope{}
if !h.doJSONRequest(c, http.MethodGet, pageURL.String(), "", "", nil, &apiResp, "Shodan") {
return
}
if strings.TrimSpace(apiResp.Error) != "" {
c.JSON(http.StatusBadGateway, gin.H{"error": apiResp.Error})
if errMsg := firstNonEmptySpaceSearchValue(apiResp.Error, apiResp.Message); errMsg != "" {
c.JSON(http.StatusBadGateway, gin.H{"error": errMsg})
return
}
if len(apiResp.Matches) == 0 {
pageMatches, err := decodeSpaceSearchRows(apiResp.Matches)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "解析 Shodan 响应失败: " + err.Error()})
return
}
if len(pageMatches) == 0 {
break
}
matches = append(matches, apiResp.Matches...)
matches = append(matches, pageMatches...)
if len(matches) >= targetSize {
matches = matches[:targetSize]
break
@@ -947,11 +1093,21 @@ func (h *FofaHandler) doJSONRequest(c *gin.Context, method, endpoint, apiKey, he
return false
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
c.JSON(http.StatusBadGateway, gin.H{"error": fmt.Sprintf("%s 返回非 2xx: %d", label, resp.StatusCode)})
respBody, err := io.ReadAll(io.LimitReader(resp.Body, 32<<20))
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "读取 " + label + " 响应失败: " + err.Error()})
return false
}
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
msg := extractRemoteAPIError(respBody, resp.StatusCode, label)
c.JSON(http.StatusBadGateway, gin.H{"error": msg})
return false
}
if err := json.Unmarshal(respBody, out); err != nil {
if msg := extractRemoteAPIError(respBody, resp.StatusCode, label); strings.Contains(msg, "网页") {
c.JSON(http.StatusBadGateway, gin.H{"error": msg})
return false
}
c.JSON(http.StatusBadGateway, gin.H{"error": "解析 " + label + " 响应失败: " + err.Error()})
return false
}
+139 -1
View File
@@ -164,7 +164,7 @@ func TestQuakeSearchHandlesStringErrorCode(t *testing.T) {
t.Fatalf("Quake token = %q, want test-quake-key", got)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"code":"q5000","message":"查询语法错误"}`))
_, _ = w.Write([]byte(`{"code":"q5000","message":"查询语法错误","data":{}}`))
}))
defer quakeServer.Close()
@@ -195,6 +195,144 @@ func TestQuakeSearchHandlesStringErrorCode(t *testing.T) {
}
}
func TestQuakeSearchAcceptsArrayData(t *testing.T) {
gin.SetMode(gin.TestMode)
t.Setenv("QUAKE_API_KEY", "")
quakeServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"code":0,"message":"Successful.","data":[{"ip":"1.1.1.1","port":53}],"meta":{"pagination":{"total":1}}}`))
}))
defer quakeServer.Close()
h := NewFofaHandler(&config.Config{
Quake: config.SpaceSearchConfig{
BaseURL: quakeServer.URL,
APIKey: "test-quake-key",
},
}, zap.NewNop())
recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
body := `{"provider":"quake","query":"ip:\"1.1.1.1\"","fields":"ip,port","size":10,"page":1}`
ctx.Request = httptest.NewRequest(http.MethodPost, "/api/fofa/search", strings.NewReader(body))
ctx.Request.Header.Set("Content-Type", "application/json")
h.Search(ctx)
if recorder.Code != http.StatusOK {
t.Fatalf("Search() status = %d, body = %s", recorder.Code, recorder.Body.String())
}
var response fofaSearchResponse
if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
t.Fatalf("decode response: %v", err)
}
if response.ResultsCount != 1 || response.Total != 1 {
t.Fatalf("results_count=%d total=%d, want 1/1", response.ResultsCount, response.Total)
}
}
func TestZoomEyeSearchHandlesObjectDataError(t *testing.T) {
gin.SetMode(gin.TestMode)
t.Setenv("ZOOMEYE_API_KEY", "")
zoomeyeServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("API-KEY"); got != "test-zoomeye-key" {
t.Fatalf("ZoomEye API-KEY = %q, want test-zoomeye-key", got)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"code":40001,"message":"invalid api key","data":{}}`))
}))
defer zoomeyeServer.Close()
h := NewFofaHandler(&config.Config{
ZoomEye: config.SpaceSearchConfig{
BaseURL: zoomeyeServer.URL,
APIKey: "test-zoomeye-key",
},
}, zap.NewNop())
recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
body := `{"provider":"zoomeye","query":"ip=\"1.1.1.1\"","fields":"ip,port","size":10,"page":1}`
ctx.Request = httptest.NewRequest(http.MethodPost, "/api/fofa/search", strings.NewReader(body))
ctx.Request.Header.Set("Content-Type", "application/json")
h.Search(ctx)
if recorder.Code != http.StatusBadGateway {
t.Fatalf("Search() status = %d, want %d, body = %s", recorder.Code, http.StatusBadGateway, recorder.Body.String())
}
bodyText := recorder.Body.String()
if !strings.Contains(bodyText, "invalid api key") {
t.Fatalf("response should include ZoomEye error message, got %s", bodyText)
}
if strings.Contains(bodyText, "cannot unmarshal") {
t.Fatalf("response exposed JSON type decoding failure: %s", bodyText)
}
}
func TestShodanSearchSurfacesJSONErrorOnUnauthorized(t *testing.T) {
gin.SetMode(gin.TestMode)
t.Setenv("SHODAN_API_KEY", "")
shodanServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"error":"Invalid API key"}`))
}))
defer shodanServer.Close()
h := NewFofaHandler(&config.Config{
Shodan: config.SpaceSearchConfig{
BaseURL: shodanServer.URL,
APIKey: "test-shodan-key",
},
}, zap.NewNop())
recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
body := `{"provider":"shodan","query":"product:nginx","fields":"ip_str,port","size":10,"page":1}`
ctx.Request = httptest.NewRequest(http.MethodPost, "/api/fofa/search", strings.NewReader(body))
ctx.Request.Header.Set("Content-Type", "application/json")
h.Search(ctx)
if recorder.Code != http.StatusBadGateway {
t.Fatalf("Search() status = %d, want %d, body = %s", recorder.Code, http.StatusBadGateway, recorder.Body.String())
}
bodyText := recorder.Body.String()
if !strings.Contains(bodyText, "Invalid API key") {
t.Fatalf("response should include Shodan error message, got %s", bodyText)
}
if strings.Contains(bodyText, "非 2xx") {
t.Fatalf("response should not hide Shodan error behind generic status, got %s", bodyText)
}
}
func TestCanonicalizeSpaceSearchBaseURLMigratesLegacyHosts(t *testing.T) {
t.Parallel()
cases := []struct {
provider string
in string
want string
}{
{provider: "zoomeye", in: "https://api.zoomeye.org/v2/search", want: "https://api.zoomeye.ai/v2/search"},
{provider: "quake", in: "https://quake.360.cn/api/v3/search/quake_service", want: "https://quake.360.net/api/v3/search/quake_service"},
{provider: "shodan", in: "https://api.shodan.io", want: "https://api.shodan.io"},
}
for _, tc := range cases {
tc := tc
t.Run(tc.provider, func(t *testing.T) {
t.Parallel()
got := canonicalizeSpaceSearchBaseURL(tc.provider, tc.in)
if got != tc.want {
t.Fatalf("canonicalizeSpaceSearchBaseURL() = %q, want %q", got, tc.want)
}
})
}
}
func TestExtractInfoCollectJSONObject(t *testing.T) {
t.Parallel()
cases := []struct {
+280 -57
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),
}
m.mu.Lock()
m.pending[id] = p
m.mu.Unlock()
// 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" {
emitHITL("hitl_rejected", "审计 Agent 拒绝本次工具调用", map[string]interface{}{
"conversationId": conversationID,
"interruptId": p.InterruptID,
"toolName": toolName,
"toolCallId": toolCallID,
"mode": cfg.Mode,
"decision": ad.Decision,
"decision": "reject",
"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{}{
"conversationId": conversationID,
"interruptId": p.InterruptID,
"toolName": toolName,
"comment": ad.Comment,
"decidedBy": "audit_agent",
})
}
return &ad, nil
}
if sendEventFunc != nil {
sendEventFunc("hitl_resumed", "审计 Agent 已通过,继续执行", map[string]interface{}{
"conversationId": conversationID,
"interruptId": p.InterruptID,
"toolName": toolName,
"comment": ad.Comment,
"editedArgs": ad.EditedArguments,
"decidedBy": "audit_agent",
})
}
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{}{
"conversationId": conversationID,
"interruptId": p.InterruptID,
"mode": cfg.Mode,
"toolName": toolName,
"toolCallId": toolCallID,
"payload": payload,
})
}
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{}{
"conversationId": conversationID,
"interruptId": p.InterruptID,
"toolName": toolName,
"comment": d.Comment,
})
status := "decided"
decidedBy := "human"
if timedOut {
status = "timeout"
decidedBy = "system"
}
return &d, nil
}
if sendEventFunc != nil {
sendEventFunc("hitl_resumed", "人工确认通过,继续执行", map[string]interface{}{
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,
"editedArgs": d.EditedArguments,
"decidedBy": decidedBy,
"reviewer": "human",
})
return &d, nil
}
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")
+87 -1
View File
@@ -3,6 +3,7 @@ package handler
import (
"context"
"errors"
"sort"
"strings"
"sync"
"time"
@@ -46,6 +47,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 +229,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 +463,32 @@ 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)
}
// 「彻底停止」必须同时取消宿主 context:原生 Agent Cancel 即使已受理,
// 也可能只在安全点返回或报告超时,不能据此让整条任务继续存活。
// 中断并继续仍保留原语义:原生取消已处理时由运行时负责恢复。
if cancel != nil && (!runtimeHandled || errors.Is(cause, ErrTaskCancelled)) {
cancel(cause)
}
if toolCanceler != nil {
@@ -515,6 +595,12 @@ func (m *AgentTaskManager) GetActiveTasks() []*AgentTask {
Status: task.Status,
})
}
sort.Slice(result, func(i, j int) bool {
if result[i].StartedAt.Equal(result[j].StartedAt) {
return result[i].ConversationID < result[j].ConversationID
}
return result[i].StartedAt.Before(result[j].StartedAt)
})
return result
}
@@ -0,0 +1,31 @@
package handler
import (
"testing"
"time"
)
func TestGetActiveTasksUsesStableCreationOrder(t *testing.T) {
m := NewAgentTaskManager()
started := time.Date(2026, 8, 19, 10, 0, 0, 0, time.UTC)
m.mu.Lock()
m.tasks = map[string]*AgentTask{
"conversation-z": {ConversationID: "conversation-z", StartedAt: started, Status: "running"},
"conversation-late": {ConversationID: "conversation-late", StartedAt: started.Add(time.Minute), Status: "running"},
"conversation-a": {ConversationID: "conversation-a", StartedAt: started, Status: "running"},
}
m.mu.Unlock()
want := []string{"conversation-a", "conversation-z", "conversation-late"}
for attempt := 0; attempt < 20; attempt++ {
gotTasks := m.GetActiveTasks()
if len(gotTasks) != len(want) {
t.Fatalf("GetActiveTasks() length = %d, want %d", len(gotTasks), len(want))
}
for i, task := range gotTasks {
if task.ConversationID != want[i] {
t.Fatalf("attempt %d order[%d] = %q, want %q", attempt, i, task.ConversationID, want[i])
}
}
}
}
@@ -32,6 +32,101 @@ func TestCancelTaskInvokesToolCancelerOnFullStop(t *testing.T) {
}
}
func TestCancelTaskFullStopCancelsRuntimeAndParentContext(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", "context", "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 TestCancelTaskInterruptContinueKeepsParentWhenRuntimeHandlesIt(t *testing.T) {
tm := NewAgentTaskManager()
ctx, cancel := context.WithCancelCause(context.Background())
if _, err := tm.StartTask("conv-interrupt-native", "hello", cancel); err != nil {
t.Fatalf("StartTask: %v", err)
}
unregister := tm.BindAgentRuntimeCancel("conv-interrupt-native", func(err error) bool {
if !errors.Is(err, multiagent.ErrInterruptContinue) {
t.Fatalf("runtime cancel got %v", err)
}
return true
})
defer unregister()
ok, err := tm.CancelTask("conv-interrupt-native", multiagent.ErrInterruptContinue)
if err != nil || !ok {
t.Fatalf("CancelTask: ok=%v err=%v", ok, err)
}
if cause := context.Cause(ctx); cause != nil {
t.Fatalf("interrupt-continue parent context cause = %v, want nil", cause)
}
}
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 +149,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)
+34 -15
View File
@@ -8,9 +8,11 @@ import (
"time"
"cyberstrike-ai/internal/config"
"cyberstrike-ai/internal/llm"
"cyberstrike-ai/internal/openai"
einoopenai "github.com/cloudwego/eino-ext/components/model/openai"
"github.com/cloudwego/eino/components/model"
"github.com/cloudwego/eino/flow/retriever/multiquery"
"go.uber.org/zap"
)
@@ -29,21 +31,38 @@ func WireRetrieverPipeline(ctx context.Context, r *Retriever, openAI *config.Ope
}
r.wireOpenAI = openAI
httpClient := openai.NewEinoHTTPClient(openAI, &http.Client{Timeout: 120 * time.Second})
maxCompletionTokens := openAI.MaxCompletionTokensEffective()
chatCfg := &einoopenai.ChatModelConfig{
APIKey: strings.TrimSpace(openAI.APIKey),
BaseURL: strings.TrimSuffix(strings.TrimSpace(openAI.BaseURL), "/"),
Model: strings.TrimSpace(openAI.Model),
HTTPClient: httpClient,
MaxCompletionTokens: &maxCompletionTokens,
}
if chatCfg.Model == "" {
chatCfg.Model = "gpt-4o"
}
rewriteLLM, err := einoopenai.NewChatModel(ctx, chatCfg)
if err != nil {
return fmt.Errorf("multi_query rewrite model: %w", err)
baseHTTPClient := &http.Client{Timeout: 120 * time.Second}
var rewriteLLM model.ChatModel
if llm.IsClaudeProvider(openAI.Provider) {
nativeModel, err := llm.NewClaudeAgenticModel(
ctx,
*openAI,
baseHTTPClient,
openAI.MaxCompletionTokensEffective(),
nil,
)
if err != nil {
return fmt.Errorf("multi_query native Claude rewrite model: %w", err)
}
rewriteLLM = llm.NewAgenticChatModelAdapter(nativeModel)
} else {
httpClient := openai.NewEinoHTTPClient(openAI, baseHTTPClient)
maxCompletionTokens := openAI.MaxCompletionTokensEffective()
chatCfg := &einoopenai.ChatModelConfig{
APIKey: strings.TrimSpace(openAI.APIKey),
BaseURL: strings.TrimSuffix(strings.TrimSpace(openAI.BaseURL), "/"),
Model: strings.TrimSpace(openAI.Model),
HTTPClient: httpClient,
MaxCompletionTokens: &maxCompletionTokens,
}
if chatCfg.Model == "" {
chatCfg.Model = "gpt-4o"
}
var err error
rewriteLLM, err = einoopenai.NewChatModel(ctx, chatCfg)
if err != nil {
return fmt.Errorf("multi_query rewrite model: %w", err)
}
}
reranker, err := NewHTTPReranker(&r.config.Rerank, openAI, r.logger)
+125
View File
@@ -0,0 +1,125 @@
package llm
import (
"context"
"fmt"
"github.com/cloudwego/eino/components/model"
"github.com/cloudwego/eino/schema"
)
// AgenticChatModelAdapter exposes a text-oriented AgenticModel as a classic
// BaseChatModel for Eino components that have not adopted AgenticMessage yet.
// It adapts only Eino's in-memory message shape; no HTTP protocol is translated.
type AgenticChatModelAdapter struct {
model model.AgenticModel
tools []*schema.ToolInfo
}
func NewAgenticChatModelAdapter(agenticModel model.AgenticModel) model.ChatModel {
return &AgenticChatModelAdapter{model: agenticModel}
}
func (a *AgenticChatModelAdapter) BindTools(tools []*schema.ToolInfo) error {
if a == nil || a.model == nil {
return fmt.Errorf("agentic chat adapter: model is nil")
}
a.tools = append([]*schema.ToolInfo(nil), tools...)
return nil
}
func (a *AgenticChatModelAdapter) Generate(
ctx context.Context,
input []*schema.Message,
opts ...model.Option,
) (*schema.Message, error) {
if a == nil || a.model == nil {
return nil, fmt.Errorf("agentic chat adapter: model is nil")
}
out, err := a.model.Generate(ctx, classicMessagesToAgentic(input), commonAgenticOptions(a.tools, opts...)...)
if err != nil {
return nil, err
}
return agenticMessageToClassic(out), nil
}
func (a *AgenticChatModelAdapter) Stream(
ctx context.Context,
input []*schema.Message,
opts ...model.Option,
) (*schema.StreamReader[*schema.Message], error) {
if a == nil || a.model == nil {
return nil, fmt.Errorf("agentic chat adapter: model is nil")
}
stream, err := a.model.Stream(ctx, classicMessagesToAgentic(input), commonAgenticOptions(a.tools, opts...)...)
if err != nil {
return nil, err
}
return schema.StreamReaderWithConvert(stream, func(msg *schema.AgenticMessage) (*schema.Message, error) {
return agenticMessageToClassic(msg), nil
}), nil
}
func classicMessagesToAgentic(input []*schema.Message) []*schema.AgenticMessage {
out := make([]*schema.AgenticMessage, 0, len(input))
for _, msg := range input {
if msg == nil {
continue
}
role := schema.AgenticRoleTypeUser
switch msg.Role {
case schema.System:
role = schema.AgenticRoleTypeSystem
case schema.Assistant:
role = schema.AgenticRoleTypeAssistant
}
agentic := &schema.AgenticMessage{Role: role}
if msg.Role == schema.Assistant {
if msg.Content != "" {
agentic.ContentBlocks = append(agentic.ContentBlocks, schema.NewContentBlock(&schema.AssistantGenText{Text: msg.Content}))
}
} else if msg.Content != "" {
agentic.ContentBlocks = append(agentic.ContentBlocks, schema.NewContentBlock(&schema.UserInputText{Text: msg.Content}))
}
out = append(out, agentic)
}
return out
}
func agenticMessageToClassic(msg *schema.AgenticMessage) *schema.Message {
if msg == nil {
return nil
}
content, reasoning := AgenticText(msg)
return &schema.Message{
Role: schema.Assistant,
Content: content,
ReasoningContent: reasoning,
}
}
func commonAgenticOptions(boundTools []*schema.ToolInfo, opts ...model.Option) []model.Option {
common := model.GetCommonOptions(&model.Options{
Tools: append([]*schema.ToolInfo(nil), boundTools...),
}, opts...)
out := make([]model.Option, 0, 6)
if common.Temperature != nil {
out = append(out, model.WithTemperature(*common.Temperature))
}
if common.Model != nil {
out = append(out, model.WithModel(*common.Model))
}
if common.TopP != nil {
out = append(out, model.WithTopP(*common.TopP))
}
if common.MaxTokens != nil {
out = append(out, model.WithMaxTokens(*common.MaxTokens))
}
if len(common.Stop) > 0 {
out = append(out, model.WithStop(common.Stop))
}
if common.Tools != nil {
out = append(out, model.WithTools(common.Tools))
}
return out
}
+60
View File
@@ -0,0 +1,60 @@
package llm
import (
"context"
"net/http"
"strings"
"cyberstrike-ai/internal/config"
agenticclaude "github.com/cloudwego/eino-ext/components/model/agenticclaude"
"github.com/cloudwego/eino/components/model"
"github.com/cloudwego/eino/schema"
)
func IsClaudeProvider(provider string) bool {
provider = strings.ToLower(strings.TrimSpace(provider))
return provider == "claude" || provider == "anthropic"
}
func NewClaudeAgenticModel(
ctx context.Context,
cfg config.OpenAIConfig,
httpClient *http.Client,
maxTokens int,
extraFields map[string]any,
) (model.AgenticModel, error) {
if maxTokens <= 0 {
maxTokens = cfg.MaxCompletionTokensEffective()
}
if cfg.IsDeepSeekEndpointOrModel() {
httpClient = newDeepSeekAnthropicCompatibleClient(httpClient)
}
return agenticclaude.New(ctx, &agenticclaude.Config{
APIKey: strings.TrimSpace(cfg.APIKey),
BaseURL: strings.TrimSuffix(strings.TrimSpace(cfg.BaseURL), "/"),
Model: strings.TrimSpace(cfg.Model),
MaxTokens: maxTokens,
HTTPClient: httpClient,
ExtraFields: extraFields,
})
}
func AgenticText(msg *schema.AgenticMessage) (content, reasoning string) {
if msg == nil {
return "", ""
}
var contentParts, reasoningParts []string
for _, block := range msg.ContentBlocks {
if block == nil {
continue
}
switch {
case block.AssistantGenText != nil:
contentParts = append(contentParts, block.AssistantGenText.Text)
case block.Reasoning != nil:
reasoningParts = append(reasoningParts, block.Reasoning.Text)
}
}
return strings.Join(contentParts, ""), strings.Join(reasoningParts, "")
}
+76
View File
@@ -0,0 +1,76 @@
package llm
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
)
// newDeepSeekAnthropicCompatibleClient compensates for DeepSeek's Anthropic
// endpoint lagging behind the current Anthropic SDK. The SDK emits
// {"type":"custom"} for function tools, while DeepSeek expects the older
// name/input_schema/description shape without that discriminator.
//
// This is a field-level compatibility fix; requests still originate from
// Eino's native agenticclaude model and remain Anthropic Messages API requests.
func newDeepSeekAnthropicCompatibleClient(base *http.Client) *http.Client {
if base == nil {
base = http.DefaultClient
}
cloned := *base
transport := base.Transport
if transport == nil {
transport = http.DefaultTransport
}
cloned.Transport = &deepSeekAnthropicCompatRoundTripper{base: transport}
return &cloned
}
type deepSeekAnthropicCompatRoundTripper struct {
base http.RoundTripper
}
func (rt *deepSeekAnthropicCompatRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
if req == nil || req.Body == nil || req.Method != http.MethodPost {
return rt.base.RoundTrip(req)
}
body, err := io.ReadAll(req.Body)
if err != nil {
return nil, fmt.Errorf("read DeepSeek Anthropic request: %w", err)
}
_ = req.Body.Close()
var payload map[string]any
if err := json.Unmarshal(body, &payload); err != nil {
req.Body = io.NopCloser(bytes.NewReader(body))
return rt.base.RoundTrip(req)
}
tools, ok := payload["tools"].([]any)
if !ok {
req.Body = io.NopCloser(bytes.NewReader(body))
return rt.base.RoundTrip(req)
}
changed := false
for _, rawTool := range tools {
tool, ok := rawTool.(map[string]any)
if !ok || tool["type"] != "custom" {
continue
}
delete(tool, "type")
changed = true
}
if changed {
body, err = json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("marshal DeepSeek Anthropic request: %w", err)
}
}
req.Body = io.NopCloser(bytes.NewReader(body))
req.ContentLength = int64(len(body))
req.GetBody = func() (io.ReadCloser, error) {
return io.NopCloser(bytes.NewReader(body)), nil
}
return rt.base.RoundTrip(req)
}
@@ -0,0 +1,55 @@
package llm
import (
"bytes"
"io"
"net/http"
"strings"
"testing"
)
type captureRoundTripper struct {
body string
}
func (rt *captureRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
body, err := io.ReadAll(req.Body)
if err != nil {
return nil, err
}
rt.body = string(body)
return &http.Response{
StatusCode: http.StatusOK,
Header: make(http.Header),
Body: io.NopCloser(bytes.NewReader(nil)),
Request: req,
}, nil
}
func TestDeepSeekAnthropicCompatStripsOnlyCustomToolType(t *testing.T) {
t.Parallel()
capture := &captureRoundTripper{}
client := newDeepSeekAnthropicCompatibleClient(&http.Client{Transport: capture})
req, err := http.NewRequest(
http.MethodPost,
"https://api.deepseek.com/anthropic/v1/messages",
strings.NewReader(`{"tools":[{"type":"custom","name":"mcp_tool","input_schema":{"type":"object"}},{"type":"web_search_20260209","name":"web_search"}]}`),
)
if err != nil {
t.Fatalf("NewRequest: %v", err)
}
resp, err := client.Do(req)
if err != nil {
t.Fatalf("Do: %v", err)
}
_ = resp.Body.Close()
if strings.Contains(capture.body, `"type":"custom"`) {
t.Fatalf("custom discriminator was not removed: %s", capture.body)
}
if !strings.Contains(capture.body, `"type":"web_search_20260209"`) {
t.Fatalf("server tool discriminator was removed: %s", capture.body)
}
if !strings.Contains(capture.body, `"name":"mcp_tool"`) {
t.Fatalf("custom tool definition was removed: %s", capture.body)
}
}
@@ -0,0 +1,126 @@
package multiagent
import (
"context"
"github.com/cloudwego/eino/adk"
"github.com/cloudwego/eino/schema"
"go.uber.org/zap"
)
// agenticOrphanToolPrunerMiddleware is the AgenticMessage equivalent of
// orphanToolPrunerMiddleware. It removes user-role messages whose content
// blocks are exclusively FunctionToolResult entries with CallIDs that do not
// match any FunctionToolCall in the history.
//
// This is a defense-in-depth layer after agenticToolPairReconcilerMiddleware;
// the reconciler handles the common case (assistant followed by its results)
// while this pruner catches stray results that appear before their assistant
// or in non-adjacent positions (e.g. after summarization rewriting).
type agenticOrphanToolPrunerMiddleware struct {
*adk.TypedBaseChatModelAgentMiddleware[*schema.AgenticMessage]
logger *zap.Logger
phase string
}
func newAgenticOrphanToolPrunerMiddleware(logger *zap.Logger, phase string) adk.TypedChatModelAgentMiddleware[*schema.AgenticMessage] {
return &agenticOrphanToolPrunerMiddleware{
TypedBaseChatModelAgentMiddleware: &adk.TypedBaseChatModelAgentMiddleware[*schema.AgenticMessage]{},
logger: logger,
phase: phase,
}
}
func (m *agenticOrphanToolPrunerMiddleware) 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
}
// Pass 1: collect all provided CallIDs from assistant FunctionToolCall blocks.
provided := make(map[string]struct{}, 8)
for _, msg := range state.Messages {
if msg == nil || msg.Role != schema.AgenticRoleTypeAssistant {
continue
}
for _, block := range msg.ContentBlocks {
if block != nil && block.FunctionToolCall != nil && block.FunctionToolCall.CallID != "" {
provided[block.FunctionToolCall.CallID] = struct{}{}
}
}
}
// Fast path: check if any orphan exists.
hasOrphan := false
for _, msg := range state.Messages {
if msg == nil || !isPureAgenticToolResult(msg) {
continue
}
for _, id := range agenticToolResultCallIDs(msg) {
if _, ok := provided[id]; !ok {
hasOrphan = true
break
}
}
if hasOrphan {
break
}
}
if !hasOrphan {
return ctx, state, nil
}
// Pass 2: build pruned list.
pruned := make([]*schema.AgenticMessage, 0, len(state.Messages))
var droppedIDs []string
var droppedNames []string
for _, msg := range state.Messages {
if msg == nil {
continue
}
if !isPureAgenticToolResult(msg) {
pruned = append(pruned, msg)
continue
}
// Check if ALL result call IDs are orphans. If any is matched, keep the
// message (the reconciler already handled partial mismatches).
allOrphan := true
for _, id := range agenticToolResultCallIDs(msg) {
if _, ok := provided[id]; ok {
allOrphan = false
break
}
}
if allOrphan {
for _, block := range msg.ContentBlocks {
if block != nil && block.FunctionToolResult != nil {
droppedIDs = append(droppedIDs, block.FunctionToolResult.CallID)
droppedNames = append(droppedNames, block.FunctionToolResult.Name)
}
}
continue
}
pruned = append(pruned, msg)
}
if len(droppedIDs) == 0 {
return ctx, state, nil
}
if m.logger != nil {
m.logger.Warn("agentic orphan tool messages pruned before model call",
zap.String("phase", m.phase),
zap.Int("dropped_count", len(droppedIDs)),
zap.Strings("dropped_tool_call_ids", droppedIDs),
zap.Strings("dropped_tool_names", droppedNames),
zap.Int("messages_before", len(state.Messages)),
zap.Int("messages_after", len(pruned)),
)
}
ns := *state
ns.Messages = pruned
return ctx, &ns, nil
}
@@ -0,0 +1,247 @@
package multiagent
import (
"context"
"fmt"
"github.com/cloudwego/eino/adk"
"github.com/cloudwego/eino/schema"
"go.uber.org/zap"
)
// agenticToolPairReconcilerMiddleware is the AgenticMessage equivalent of
// toolPairReconcilerMiddleware. It ensures every assistant FunctionToolCall
// block is followed by a matching FunctionToolResult message, patching or
// dropping as needed so the downstream model never receives an unpaired
// tool-call history.
//
// In the AgenticMessage protocol:
// - Assistant tool calls: Role=AgenticRoleTypeAssistant with FunctionToolCall content blocks.
// - Tool results: Role=AgenticRoleTypeUser with FunctionToolResult content blocks.
//
// This middleware runs after summarization which may truncate history and
// break pairings.
type agenticToolPairReconcilerMiddleware struct {
*adk.TypedBaseChatModelAgentMiddleware[*schema.AgenticMessage]
logger *zap.Logger
phase string
}
func newAgenticToolPairReconcilerMiddleware(logger *zap.Logger, phase string) adk.TypedChatModelAgentMiddleware[*schema.AgenticMessage] {
return &agenticToolPairReconcilerMiddleware{
TypedBaseChatModelAgentMiddleware: &adk.TypedBaseChatModelAgentMiddleware[*schema.AgenticMessage]{},
logger: logger,
phase: phase,
}
}
func (m *agenticToolPairReconcilerMiddleware) 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
}
usedIDs := make(map[string]struct{}, 16)
changed := false
patched := 0
dropped := 0
out := make([]*schema.AgenticMessage, 0, len(state.Messages))
for i := 0; i < len(state.Messages); {
msg := state.Messages[i]
if msg == nil {
changed = true
i++
continue
}
calls := agenticFunctionToolCalls(msg)
// Non-assistant or assistant without tool calls — but check for orphan
// tool-result messages (user role with only FunctionToolResult blocks).
if len(calls) == 0 {
if isPureAgenticToolResult(msg) {
// Orphan tool result not preceded by its assistant; drop it.
changed = true
dropped++
i++
continue
}
out = append(out, msg)
i++
continue
}
// Deduplicate / fix empty call IDs.
idsChanged := false
for ci := range calls {
id := calls[ci].CallID
_, duplicate := usedIDs[id]
if id == "" || duplicate {
base := fmt.Sprintf("patched_agentic_call_%d_%d", i, ci)
id = base
for suffix := 1; ; suffix++ {
if _, exists := usedIDs[id]; !exists {
break
}
id = fmt.Sprintf("%s_%d", base, suffix)
}
calls[ci].CallID = id
idsChanged = true
changed = true
}
usedIDs[id] = struct{}{}
}
assistant := msg
if idsChanged {
assistant = cloneAgenticMessageWithCalls(msg, calls)
}
out = append(out, assistant)
// Build expected set.
expected := make(map[string]*schema.FunctionToolCall, len(calls))
for ci := range calls {
expected[calls[ci].CallID] = calls[ci]
}
// Consume following tool-result messages.
results := make(map[string]*schema.AgenticMessage, len(calls))
j := i + 1
for j < len(state.Messages) {
next := state.Messages[j]
if next == nil {
changed = true
j++
continue
}
if !isPureAgenticToolResult(next) {
break
}
resultCallIDs := agenticToolResultCallIDs(next)
consumed := false
for _, rid := range resultCallIDs {
if _, wanted := expected[rid]; !wanted {
continue
}
if _, dup := results[rid]; dup {
continue
}
results[rid] = next
consumed = true
}
if !consumed {
changed = true
dropped++
}
j++
}
// Emit results in call order, patching missing ones.
for _, tc := range calls {
if result, ok := results[tc.CallID]; ok {
out = append(out, result)
continue
}
out = append(out, makeAgenticPatchedToolResult(tc.CallID, tc.Name))
changed = true
patched++
}
i = j
}
if !changed {
return ctx, state, nil
}
if m.logger != nil {
m.logger.Warn("agentic tool-call/result pairs reconciled before model call",
zap.String("phase", m.phase),
zap.Int("patched_results", patched),
zap.Int("dropped_results", dropped),
zap.Int("messages_before", len(state.Messages)),
zap.Int("messages_after", len(out)),
)
}
ns := *state
ns.Messages = out
return ctx, &ns, nil
}
// agenticFunctionToolCalls extracts FunctionToolCall pointers from an
// assistant message's content blocks. Returns nil for non-assistant messages.
func agenticFunctionToolCalls(msg *schema.AgenticMessage) []*schema.FunctionToolCall {
if msg == nil || msg.Role != schema.AgenticRoleTypeAssistant {
return nil
}
var out []*schema.FunctionToolCall
for _, block := range msg.ContentBlocks {
if block != nil && block.FunctionToolCall != nil {
out = append(out, block.FunctionToolCall)
}
}
return out
}
// isPureAgenticToolResult returns true when the message is a user-role
// message whose content blocks are exclusively FunctionToolResult entries.
func isPureAgenticToolResult(msg *schema.AgenticMessage) bool {
if msg == nil || msg.Role != schema.AgenticRoleTypeUser || len(msg.ContentBlocks) == 0 {
return false
}
for _, block := range msg.ContentBlocks {
if block == nil {
continue
}
if block.FunctionToolResult == nil {
return false
}
}
return true
}
// agenticToolResultCallIDs extracts all CallIDs from FunctionToolResult blocks.
func agenticToolResultCallIDs(msg *schema.AgenticMessage) []string {
if msg == nil {
return nil
}
var ids []string
for _, block := range msg.ContentBlocks {
if block != nil && block.FunctionToolResult != nil && block.FunctionToolResult.CallID != "" {
ids = append(ids, block.FunctionToolResult.CallID)
}
}
return ids
}
func cloneAgenticMessageWithCalls(msg *schema.AgenticMessage, calls []*schema.FunctionToolCall) *schema.AgenticMessage {
cloned := *msg
cloned.ContentBlocks = make([]*schema.ContentBlock, 0, len(msg.ContentBlocks))
callIdx := 0
for _, block := range msg.ContentBlocks {
if block != nil && block.FunctionToolCall != nil && callIdx < len(calls) {
cloned.ContentBlocks = append(cloned.ContentBlocks, schema.NewContentBlock(calls[callIdx]))
callIdx++
} else {
cloned.ContentBlocks = append(cloned.ContentBlocks, block)
}
}
return &cloned
}
func makeAgenticPatchedToolResult(callID, name 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: patchedMissingToolResult},
}},
})},
}
}
@@ -0,0 +1,157 @@
package multiagent
import (
"context"
"testing"
"github.com/cloudwego/eino/adk"
"github.com/cloudwego/eino/schema"
)
func TestAgenticToolPairReconcilerPatchesMissing(t *testing.T) {
t.Parallel()
mw := newAgenticToolPairReconcilerMiddleware(nil, "test")
state := &adk.TypedChatModelAgentState[*schema.AgenticMessage]{
Messages: []*schema.AgenticMessage{
agenticAssistantToolCall("c1", "search", `{"q":"x"}`),
agenticAssistantToolCall("c2", "execute", `{"cmd":"ls"}`),
// c1 result present, c2 missing
agenticToolResult("c1", "search", "found it"),
},
}
_, out, err := mw.BeforeModelRewriteState(context.Background(), state, nil)
if err != nil {
t.Fatal(err)
}
// Expected: assistant(c1) -> result(c1) -> assistant(c2) -> patched_result(c2)
if len(out.Messages) != 4 {
t.Fatalf("messages = %d, want 4", len(out.Messages))
}
// c1 assistant
if calls := agenticFunctionToolCalls(out.Messages[0]); len(calls) != 1 || calls[0].CallID != "c1" {
t.Fatal("msg[0] should be assistant(c1)")
}
// c1 result
if ids := agenticToolResultCallIDs(out.Messages[1]); len(ids) != 1 || ids[0] != "c1" {
t.Fatal("msg[1] should be result(c1)")
}
// c2 assistant
if calls := agenticFunctionToolCalls(out.Messages[2]); len(calls) != 1 || calls[0].CallID != "c2" {
t.Fatal("msg[2] should be assistant(c2)")
}
// c2 patched result
if ids := agenticToolResultCallIDs(out.Messages[3]); len(ids) != 1 || ids[0] != "c2" {
t.Fatal("msg[3] should be patched result(c2)")
}
resultText := out.Messages[3].ContentBlocks[0].FunctionToolResult.Content[0].Text.Text
if resultText != patchedMissingToolResult {
t.Fatalf("patched text = %q", resultText)
}
}
func TestAgenticToolPairReconcilerDropsOrphan(t *testing.T) {
t.Parallel()
mw := newAgenticToolPairReconcilerMiddleware(nil, "test")
state := &adk.TypedChatModelAgentState[*schema.AgenticMessage]{
Messages: []*schema.AgenticMessage{
// Orphan tool result with no preceding assistant
agenticToolResult("orphan", "deleted_tool", "stale data"),
{Role: schema.AgenticRoleTypeUser, ContentBlocks: []*schema.ContentBlock{
schema.NewContentBlock(&schema.UserInputText{Text: "hello"}),
}},
},
}
_, out, err := mw.BeforeModelRewriteState(context.Background(), state, nil)
if err != nil {
t.Fatal(err)
}
if len(out.Messages) != 1 {
t.Fatalf("messages = %d, want 1 (orphan dropped)", len(out.Messages))
}
if out.Messages[0].ContentBlocks[0].UserInputText == nil {
t.Fatal("remaining message should be the user text")
}
}
func TestAgenticToolPairReconcilerNoopWhenPaired(t *testing.T) {
t.Parallel()
mw := newAgenticToolPairReconcilerMiddleware(nil, "test")
state := &adk.TypedChatModelAgentState[*schema.AgenticMessage]{
Messages: []*schema.AgenticMessage{
agenticAssistantToolCall("c1", "search", `{}`),
agenticToolResult("c1", "search", "ok"),
},
}
_, out, err := mw.BeforeModelRewriteState(context.Background(), state, nil)
if err != nil {
t.Fatal(err)
}
// Should return original state unchanged
if &out.Messages[0] == &state.Messages[0] {
// pointer equality on slice — state not cloned
}
if len(out.Messages) != 2 {
t.Fatalf("messages = %d, want 2", len(out.Messages))
}
}
func TestAgenticToolPairReconcilerFixesEmptyCallID(t *testing.T) {
t.Parallel()
mw := newAgenticToolPairReconcilerMiddleware(nil, "test")
state := &adk.TypedChatModelAgentState[*schema.AgenticMessage]{
Messages: []*schema.AgenticMessage{
{
Role: schema.AgenticRoleTypeAssistant,
ContentBlocks: []*schema.ContentBlock{schema.NewContentBlock(&schema.FunctionToolCall{
CallID: "", Name: "search", Arguments: `{}`,
})},
},
},
}
_, out, err := mw.BeforeModelRewriteState(context.Background(), state, nil)
if err != nil {
t.Fatal(err)
}
calls := agenticFunctionToolCalls(out.Messages[0])
if len(calls) != 1 || calls[0].CallID == "" {
t.Fatalf("empty call ID should be patched, got %q", calls[0].CallID)
}
}
func TestAgenticOrphanToolPrunerRemovesOrphan(t *testing.T) {
t.Parallel()
mw := newAgenticOrphanToolPrunerMiddleware(nil, "test")
state := &adk.TypedChatModelAgentState[*schema.AgenticMessage]{
Messages: []*schema.AgenticMessage{
agenticAssistantToolCall("c1", "search", `{}`),
agenticToolResult("c1", "search", "ok"),
// Orphan: no assistant has call_id "c_orphan"
agenticToolResult("c_orphan", "deleted", "stale"),
},
}
_, out, err := mw.BeforeModelRewriteState(context.Background(), state, nil)
if err != nil {
t.Fatal(err)
}
if len(out.Messages) != 2 {
t.Fatalf("messages = %d, want 2 (orphan pruned)", len(out.Messages))
}
}
func TestAgenticOrphanToolPrunerNoopWhenClean(t *testing.T) {
t.Parallel()
mw := newAgenticOrphanToolPrunerMiddleware(nil, "test")
state := &adk.TypedChatModelAgentState[*schema.AgenticMessage]{
Messages: []*schema.AgenticMessage{
agenticAssistantToolCall("c1", "search", `{}`),
agenticToolResult("c1", "search", "ok"),
},
}
_, out, err := mw.BeforeModelRewriteState(context.Background(), state, nil)
if err != nil {
t.Fatal(err)
}
if len(out.Messages) != 2 {
t.Fatalf("messages = %d, want 2", len(out.Messages))
}
}
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,46 @@ 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 TestRecvSchemaToolResultMessages_SplitsParallelIDs(t *testing.T) {
sr, sw := schema.Pipe[*schema.Message](8)
_ = sw.Send(schema.ToolMessage("one-", "tc-1", schema.WithToolName("nmap")), nil)
_ = sw.Send(schema.ToolMessage("two-", "tc-2", schema.WithToolName("nmap")), nil)
_ = sw.Send(schema.ToolMessage("a", "tc-1", schema.WithToolName("nmap")), nil)
_ = sw.Send(schema.ToolMessage("b", "tc-2", schema.WithToolName("nmap")), nil)
sw.Close()
msgs, err := recvSchemaToolResultMessages(context.Background(), sr)
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if len(msgs) != 2 {
t.Fatalf("msgs = %#v, want 2", msgs)
}
if msgs[0].ToolCallID != "tc-1" || msgs[0].Content != "one-a" {
t.Fatalf("msg 0 = %#v", msgs[0])
}
if msgs[1].ToolCallID != "tc-2" || msgs[1].Content != "two-b" {
t.Fatalf("msg 1 = %#v", msgs[1])
}
}
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 +77,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 +89,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 +107,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,214 @@
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, newAgenticToolPairReconcilerMiddleware(cfg.logger, cfg.phase+"_pre_summarization"))
handlers = append(handlers, cfg.agenticSummarization)
}
handlers = append(handlers, newAgenticToolPairReconcilerMiddleware(cfg.logger, cfg.phase))
if !cfg.skipOrphanPruner {
handlers = append(handlers, newAgenticOrphanToolPrunerMiddleware(cfg.logger, cfg.phase))
}
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,113 @@
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,
})
// system + continuation + reconciler + orphan_pruner + trace
if len(handlers) != 5 {
t.Fatalf("handlers = %d, want system + continuation + reconciler + orphan_pruner + trace", len(handlers))
}
}
@@ -0,0 +1,111 @@
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 {
// Tool 流保持 1 event ↔ 1 MessageStream,对齐 ADK EventSenderToolWrapper
// 每个 CallID 在工具包装层就已经是独立事件。这里不能再按 CallID 现场拆成
// 多条 live pipe——drain 会阻塞读完当前流,交错的并行 chunk 会把另一列写满后死锁。
// 若上游仍把 ToolsNode 的 MergeStreamReaders 摊成一条流,由
// concatToolResultChunks 按列 ConcatMessages 恢复。
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)
}
}
+190
View File
@@ -0,0 +1,190 @@
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:
var index *int
if block.StreamingMeta != nil {
i := block.StreamingMeta.Index
index = &i
}
base.Role = schema.Assistant
base.ToolCalls = append(base.ToolCalls, schema.ToolCall{
Index: index,
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,118 @@
package multiagent
import (
"context"
"fmt"
"github.com/cloudwego/eino/components/model"
"github.com/cloudwego/eino/schema"
)
// agenticToolCallingChatModelAdapter lets Eino's classic plan_execute
// Planner/Replanner consume a native AgenticModel without translating the HTTP
// protocol. Only the in-memory Eino message and option shapes are adapted.
type agenticToolCallingChatModelAdapter struct {
model model.AgenticModel
tools []*schema.ToolInfo
}
func newAgenticToolCallingChatModelAdapter(agenticModel model.AgenticModel) model.ToolCallingChatModel {
return &agenticToolCallingChatModelAdapter{model: agenticModel}
}
func (m *agenticToolCallingChatModelAdapter) WithTools(tools []*schema.ToolInfo) (model.ToolCallingChatModel, error) {
if m == nil || m.model == nil {
return nil, fmt.Errorf("agentic tool-calling adapter: model is nil")
}
clonedTools := append([]*schema.ToolInfo(nil), tools...)
return &agenticToolCallingChatModelAdapter{model: m.model, tools: clonedTools}, nil
}
func (m *agenticToolCallingChatModelAdapter) Generate(
ctx context.Context,
input []*schema.Message,
opts ...model.Option,
) (*schema.Message, error) {
if m == nil || m.model == nil {
return nil, fmt.Errorf("agentic tool-calling adapter: model is nil")
}
out, err := m.model.Generate(ctx, EinoMessagesToAgentic(input), m.agenticOptions(opts...)...)
if err != nil {
return nil, err
}
converted := AgenticMessageToEino(out)
if len(converted) == 0 {
return nil, fmt.Errorf("agentic tool-calling adapter: model returned no message")
}
return converted[0], nil
}
func (m *agenticToolCallingChatModelAdapter) Stream(
ctx context.Context,
input []*schema.Message,
opts ...model.Option,
) (*schema.StreamReader[*schema.Message], error) {
if m == nil || m.model == nil {
return nil, fmt.Errorf("agentic tool-calling adapter: model is nil")
}
stream, err := m.model.Stream(ctx, EinoMessagesToAgentic(input), m.agenticOptions(opts...)...)
if err != nil {
return nil, err
}
return agenticStreamToEinoStream(stream), nil
}
func (m *agenticToolCallingChatModelAdapter) agenticOptions(opts ...model.Option) []model.Option {
common := model.GetCommonOptions(&model.Options{
Tools: append([]*schema.ToolInfo(nil), m.tools...),
}, opts...)
out := make([]model.Option, 0, 8)
if common.Temperature != nil {
out = append(out, model.WithTemperature(*common.Temperature))
}
if common.Model != nil {
out = append(out, model.WithModel(*common.Model))
}
if common.TopP != nil {
out = append(out, model.WithTopP(*common.TopP))
}
if common.MaxTokens != nil {
out = append(out, model.WithMaxTokens(*common.MaxTokens))
}
if len(common.Stop) > 0 {
out = append(out, model.WithStop(common.Stop))
}
if common.Tools != nil {
out = append(out, model.WithTools(common.Tools))
}
if common.AgenticToolChoice != nil {
out = append(out, model.WithAgenticToolChoice(common.AgenticToolChoice))
} else if common.ToolChoice != nil {
out = append(out, model.WithAgenticToolChoice(classicToolChoiceToAgentic(
*common.ToolChoice,
common.AllowedToolNames,
)))
}
return out
}
func classicToolChoiceToAgentic(choice schema.ToolChoice, allowedNames []string) *schema.AgenticToolChoice {
allowed := make([]*schema.AllowedTool, 0, len(allowedNames))
for _, name := range allowedNames {
if name != "" {
allowed = append(allowed, &schema.AllowedTool{FunctionName: name})
}
}
out := &schema.AgenticToolChoice{Type: choice}
switch choice {
case schema.ToolChoiceAllowed:
if len(allowed) > 0 {
out.Allowed = &schema.AgenticAllowedToolChoice{Tools: allowed}
}
case schema.ToolChoiceForced:
if len(allowed) > 0 {
out.Forced = &schema.AgenticForcedToolChoice{Tools: allowed}
}
}
return out
}
@@ -0,0 +1,118 @@
package multiagent
import (
"context"
"testing"
"github.com/cloudwego/eino/components/model"
"github.com/cloudwego/eino/schema"
)
type capturingAgenticToolCallingModel struct {
input []*schema.AgenticMessage
options *model.Options
}
func (m *capturingAgenticToolCallingModel) Generate(
_ context.Context,
input []*schema.AgenticMessage,
opts ...model.Option,
) (*schema.AgenticMessage, error) {
m.input = input
m.options = model.GetCommonOptions(nil, opts...)
return &schema.AgenticMessage{
Role: schema.AgenticRoleTypeAssistant,
ContentBlocks: []*schema.ContentBlock{
schema.NewContentBlock(&schema.FunctionToolCall{
CallID: "call-1",
Name: "emit_plan",
Arguments: `{"steps":["inspect"]}`,
}),
},
}, nil
}
func (m *capturingAgenticToolCallingModel) Stream(
context.Context,
[]*schema.AgenticMessage,
...model.Option,
) (*schema.StreamReader[*schema.AgenticMessage], error) {
return schema.StreamReaderFromArray([]*schema.AgenticMessage{
{
Role: schema.AgenticRoleTypeAssistant,
ContentBlocks: []*schema.ContentBlock{
schema.NewContentBlockChunk(&schema.FunctionToolCall{
CallID: "call-1",
Name: "emit_plan",
Arguments: `{"steps":[`,
}, &schema.StreamingMeta{Index: 0}),
},
},
{
Role: schema.AgenticRoleTypeAssistant,
ContentBlocks: []*schema.ContentBlock{
schema.NewContentBlockChunk(&schema.FunctionToolCall{
Arguments: `"inspect"]}`,
}, &schema.StreamingMeta{Index: 0}),
},
},
}), nil
}
func TestAgenticToolCallingAdapterConvertsForcedToolChoice(t *testing.T) {
t.Parallel()
native := &capturingAgenticToolCallingModel{}
adapter, err := newAgenticToolCallingChatModelAdapter(native).WithTools([]*schema.ToolInfo{{
Name: "emit_plan",
Desc: "emit a structured plan",
}})
if err != nil {
t.Fatalf("WithTools: %v", err)
}
out, err := adapter.Generate(
context.Background(),
[]*schema.Message{schema.UserMessage("plan this task")},
model.WithToolChoice(schema.ToolChoiceForced),
)
if err != nil {
t.Fatalf("Generate: %v", err)
}
if len(native.input) != 1 || native.input[0].Role != schema.AgenticRoleTypeUser {
t.Fatalf("native input = %#v", native.input)
}
if native.options == nil || len(native.options.Tools) != 1 || native.options.Tools[0].Name != "emit_plan" {
t.Fatalf("native tools = %#v", native.options)
}
if native.options.AgenticToolChoice == nil || native.options.AgenticToolChoice.Type != schema.ToolChoiceForced {
t.Fatalf("agentic tool choice = %#v", native.options.AgenticToolChoice)
}
if len(out.ToolCalls) != 1 || out.ToolCalls[0].Function.Name != "emit_plan" {
t.Fatalf("classic output = %#v", out)
}
}
func TestAgenticToolCallingAdapterPreservesStreamingToolCallIndex(t *testing.T) {
t.Parallel()
adapter := newAgenticToolCallingChatModelAdapter(&capturingAgenticToolCallingModel{})
stream, err := adapter.Stream(context.Background(), []*schema.Message{
schema.UserMessage("plan this task"),
})
if err != nil {
t.Fatalf("Stream: %v", err)
}
out, err := schema.ConcatMessageStream(stream)
if err != nil {
t.Fatalf("ConcatMessageStream: %v", err)
}
if len(out.ToolCalls) != 1 {
t.Fatalf("tool calls = %#v, want one merged call", out.ToolCalls)
}
call := out.ToolCalls[0]
if call.Index == nil || *call.Index != 0 {
t.Fatalf("tool call index = %#v", call.Index)
}
if call.Function.Name != "emit_plan" || call.Function.Arguments != `{"steps":["inspect"]}` {
t.Fatalf("merged tool call = %#v", call)
}
}
@@ -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,166 @@
package multiagent
import (
"context"
"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 && !isEinoVoluntaryCancelErr(recvErr) && 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"
)
@@ -24,18 +25,19 @@ import (
// 11. telemetry
// 12. model-facing trace snapshot
type einoChatModelTailConfig struct {
logger *zap.Logger
phase string
summarization adk.ChatModelAgentMiddleware
modelName string
maxTotalTokens int
toolMaxBytes int
conversationID string
trace *modelFacingTraceHolder
middlewareConfig *config.MultiAgentEinoMiddlewareConfig
skipOrphanPruner bool
skipTelemetry bool
skipTrace bool
logger *zap.Logger
phase string
summarization adk.ChatModelAgentMiddleware
agenticSummarization adk.TypedChatModelAgentMiddleware[*schema.AgenticMessage]
modelName string
maxTotalTokens int
toolMaxBytes int
conversationID string
trace *modelFacingTraceHolder
middlewareConfig *config.MultiAgentEinoMiddlewareConfig
skipOrphanPruner bool
skipTelemetry bool
skipTrace bool
}
func appendEinoChatModelTailMiddlewares(handlers []adk.ChatModelAgentMiddleware, cfg einoChatModelTailConfig) []adk.ChatModelAgentMiddleware {
@@ -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,538 @@
package multiagent
import (
"context"
"errors"
"fmt"
"net"
"net/http"
"strings"
"sync"
"time"
"cyberstrike-ai/internal/config"
"cyberstrike-ai/internal/llm"
"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 newEinoToolCallingChatModelFactory(
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) {
if isEinoAgenticClaudeProvider(oa.Provider) {
nativeModel, err := newEinoClaudeAgenticChatModel(ctx, oa, mode, baseHTTPClient, reasoningClient)
if err != nil {
return nil, err
}
return newAgenticToolCallingChatModelAdapter(nativeModel), nil
}
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 newEinoAgenticChatModelFactory(
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 !supportsEinoAgenticBackend(oa) {
return nil, fmt.Errorf("eino agentic model: provider %q is not supported", strings.TrimSpace(oa.Provider))
}
if isEinoAgenticClaudeProvider(oa.Provider) {
return newEinoClaudeAgenticChatModel(ctx, oa, mode, baseHTTPClient, reasoningClient)
}
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 newEinoClaudeAgenticChatModel(
ctx context.Context,
oa config.OpenAIConfig,
mode einoModelMode,
httpClient *http.Client,
reasoningClient *reasoning.ClientIntent,
) (model.AgenticModel, error) {
extraFields := reasoning.AgenticOpenAIExtraFields(&oa, reasoningClient)
if mode == einoModelModePlanner {
extraFields = reasoning.AgenticOpenAIPlannerExtraFields(&oa)
}
return llm.NewClaudeAgenticModel(
ctx,
oa,
httpClient,
oa.MaxCompletionTokensEffective(),
extraFields,
)
}
func supportsEinoAgenticBackend(oa config.OpenAIConfig) bool {
provider := strings.ToLower(strings.TrimSpace(oa.Provider))
return provider == "" ||
provider == "openai" ||
provider == "openai_compatible" ||
isEinoAgenticClaudeProvider(provider)
}
func isEinoAgenticClaudeProvider(provider string) bool {
return llm.IsClaudeProvider(provider)
}
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,
})
}

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