mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-09-09 03:08:56 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ac6e04a94c | ||
|
|
cb58b90083 | ||
|
|
5ccabdc3e4 | ||
|
|
286511d0a8 | ||
|
|
1f66b93866 | ||
|
|
c21ba6c419 | ||
|
|
5720de0d27 | ||
|
|
5aa2c382b0 | ||
|
|
98c380ed5f | ||
|
|
11537713ce | ||
|
|
acc2ebd7e2 | ||
|
|
5eaddd9e8c | ||
|
|
3421d8b047 | ||
|
|
5b000d1e3e | ||
|
|
cecbe5a086 | ||
|
|
c7603c2b66 | ||
|
|
d3bfb1c98e | ||
|
|
55ee5fbac9 | ||
|
|
910d07ea0a | ||
|
|
217f5570c2 | ||
|
|
f30a9de01d | ||
|
|
893f1342ae | ||
|
|
6db0975223 | ||
|
|
b935e1f90f | ||
|
|
3b7fd8d582 | ||
|
|
053c75b2cc | ||
|
|
7823fb6b0c | ||
|
|
9c0621819f | ||
|
|
aa11b32ce5 | ||
|
|
4410cfe826 | ||
|
|
409e75f15a | ||
|
|
6fc0004ac3 | ||
|
|
ee4676a591 | ||
|
|
9f38fda15f | ||
|
|
b07a645d34 | ||
|
|
bbc3395ac5 | ||
|
|
799282d0e7 | ||
|
|
8635bf49dc | ||
|
|
befc8acc3a | ||
|
|
964fbd6095 | ||
|
|
01c0750d98 | ||
|
|
7f8093f8b9 | ||
|
|
f564421b4d | ||
|
|
2910289f0b | ||
|
|
5ce66ee2f8 | ||
|
|
20b6fd241e | ||
|
|
3fab05c3b1 | ||
|
|
8bde9d749a | ||
|
|
9b78297442 | ||
|
|
a6631a5cde | ||
|
|
31f80b2f0c | ||
|
|
70b01206e4 | ||
|
|
4fe6defa28 | ||
|
|
44a578e824 | ||
|
|
e591fe4a9b | ||
|
|
e691c5d9c6 | ||
|
|
20c9e871af | ||
|
|
c477c5507a | ||
|
|
43fe8fa7d6 | ||
|
|
d19ffc9ff4 | ||
|
|
c345880765 | ||
|
|
a1615143be | ||
|
|
7c45011074 | ||
|
|
eb6bab574f | ||
|
|
b170f2c4b1 | ||
|
|
02216a4660 | ||
|
|
a53e4a4a64 |
+24
-1
@@ -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
@@ -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 retry;408/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 # P0:Eino 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:
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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
@@ -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` | 单次模型输出上限;未填时使用默认值。 |
|
||||
|
||||
@@ -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 组件。
|
||||
|
||||
建议目录:
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
## 模式选择
|
||||
|
||||
|
||||
@@ -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" 问题
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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 > 0:408/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
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestListConversationPlanTasksSortedAndToleratesMissingDirectory(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
db, err := NewDB(filepath.Join(tmp, "plantask.db"), zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatalf("NewDB: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
|
||||
base := filepath.Join(tmp, "skills", ".eino", "plantask")
|
||||
db.SetEinoConversationDirs(base, "", "", "")
|
||||
missing, err := db.ListConversationPlanTasks("missing")
|
||||
if err != nil || len(missing) != 0 {
|
||||
t.Fatalf("missing task board = %#v, err=%v", missing, err)
|
||||
}
|
||||
|
||||
dir := filepath.Join(base, "conversation-1")
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
files := map[string]string{
|
||||
"10.json": `{"id":"10","subject":"最后检查","status":"pending"}`,
|
||||
"2.json": `{"id":"2","subject":"实现接口","status":"in_progress","activeForm":"正在实现接口"}`,
|
||||
"1.json": `{"id":"1","subject":"梳理需求","status":"completed"}`,
|
||||
"bad.json": `{`,
|
||||
}
|
||||
for name, content := range files {
|
||||
if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile(%s): %v", name, err)
|
||||
}
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, ".highwatermark"), []byte("10"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile(highwatermark): %v", err)
|
||||
}
|
||||
|
||||
tasks, err := db.ListConversationPlanTasks("conversation-1")
|
||||
if err != nil {
|
||||
t.Fatalf("ListConversationPlanTasks: %v", err)
|
||||
}
|
||||
if len(tasks) != 3 {
|
||||
t.Fatalf("tasks = %#v, want 3", tasks)
|
||||
}
|
||||
if tasks[0].ID != "1" || tasks[1].ID != "2" || tasks[2].ID != "10" {
|
||||
t.Fatalf("task order = %q, %q, %q", tasks[0].ID, tasks[1].ID, tasks[2].ID)
|
||||
}
|
||||
if tasks[1].ActiveForm != "正在实现接口" {
|
||||
t.Fatalf("activeForm = %q", tasks[1].ActiveForm)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListConversationPlanTasksSinceHidesPreviousRunUntilTaskCreate(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
db, err := NewDB(filepath.Join(tmp, "plantask-current-run.db"), zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatalf("NewDB: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
|
||||
base := filepath.Join(tmp, "plantask")
|
||||
db.SetEinoConversationDirs(base, "", "", "")
|
||||
dir := filepath.Join(base, "conversation-current-run")
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
oldPath := filepath.Join(dir, "1.json")
|
||||
if err := os.WriteFile(oldPath, []byte(`{"id":"1","subject":"上一轮任务","status":"in_progress"}`), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile(old): %v", err)
|
||||
}
|
||||
runStartedAt := time.Now().Add(-time.Second)
|
||||
oldTime := runStartedAt.Add(-time.Minute)
|
||||
if err := os.Chtimes(oldPath, oldTime, oldTime); err != nil {
|
||||
t.Fatalf("Chtimes(old): %v", err)
|
||||
}
|
||||
|
||||
tasks, err := db.ListConversationPlanTasksSince("conversation-current-run", runStartedAt)
|
||||
if err != nil {
|
||||
t.Fatalf("ListConversationPlanTasksSince(before TaskCreate): %v", err)
|
||||
}
|
||||
if len(tasks) != 0 {
|
||||
t.Fatalf("stale tasks shown before current TaskCreate: %#v", tasks)
|
||||
}
|
||||
|
||||
newPath := filepath.Join(dir, "2.json")
|
||||
if err := os.WriteFile(newPath, []byte(`{"id":"2","subject":"本轮任务","status":"pending"}`), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile(new): %v", err)
|
||||
}
|
||||
tasks, err = db.ListConversationPlanTasksSince("conversation-current-run", runStartedAt)
|
||||
if err != nil {
|
||||
t.Fatalf("ListConversationPlanTasksSince(after TaskCreate): %v", err)
|
||||
}
|
||||
if len(tasks) != 1 || tasks[0].ID != "2" {
|
||||
t.Fatalf("current tasks = %#v, want task 2 only", tasks)
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package database
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
@@ -105,6 +106,63 @@ func TestProcessDetailsSummaryDoesNotReportPersistedOrphanAsRunning(t *testing.T
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessDetailsSummaryIncludesPersistedTurnTiming(t *testing.T) {
|
||||
db, _, messageID := setupProcessDetailsSummaryTest(t)
|
||||
startedAt := "2026-08-10T08:00:00Z"
|
||||
completedAt := "2026-08-10T08:12:59Z"
|
||||
if _, err := db.Exec(
|
||||
"UPDATE messages SET content = ?, created_at = ?, updated_at = ? WHERE id = ?",
|
||||
"done", startedAt, completedAt, messageID,
|
||||
); err != nil {
|
||||
t.Fatalf("update message timing: %v", err)
|
||||
}
|
||||
|
||||
summary, err := db.GetProcessDetailsSummary(messageID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetProcessDetailsSummary: %v", err)
|
||||
}
|
||||
if summary.Status != "completed" {
|
||||
t.Fatalf("status = %q, want completed", summary.Status)
|
||||
}
|
||||
if summary.StartedAt == nil || summary.CompletedAt == nil {
|
||||
t.Fatalf("timing missing: %#v", summary)
|
||||
}
|
||||
if want := int64((12*time.Minute + 59*time.Second) / time.Millisecond); summary.DurationMs != want {
|
||||
t.Fatalf("durationMs = %d, want %d", summary.DurationMs, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessDetailsSummaryTreatsCancelledPlaceholderAsTerminal(t *testing.T) {
|
||||
db, conversationID, messageID := setupProcessDetailsSummaryTest(t)
|
||||
startedAt := "2026-08-10T08:00:00Z"
|
||||
if _, err := db.Exec(
|
||||
"UPDATE messages SET content = ?, created_at = ?, updated_at = ? WHERE id = ?",
|
||||
"处理中...", startedAt, startedAt, messageID,
|
||||
); err != nil {
|
||||
t.Fatalf("update running placeholder: %v", err)
|
||||
}
|
||||
if _, err := db.Exec(`
|
||||
INSERT INTO process_details (id, message_id, conversation_id, event_type, message, data, created_at)
|
||||
VALUES ('cancelled-detail', ?, ?, 'cancelled', 'interrupted', '{}', '2026-08-10T08:02:05Z')`,
|
||||
messageID, conversationID); err != nil {
|
||||
t.Fatalf("insert cancelled detail: %v", err)
|
||||
}
|
||||
|
||||
summary, err := db.GetProcessDetailsSummary(messageID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetProcessDetailsSummary: %v", err)
|
||||
}
|
||||
if summary.Status != "cancelled" {
|
||||
t.Fatalf("status = %q, want cancelled", summary.Status)
|
||||
}
|
||||
if summary.CompletedAt == nil {
|
||||
t.Fatal("cancelled summary should expose a fixed completion time")
|
||||
}
|
||||
if want := int64((2*time.Minute + 5*time.Second) / time.Millisecond); summary.DurationMs != want {
|
||||
t.Fatalf("durationMs = %d, want %d", summary.DurationMs, want)
|
||||
}
|
||||
}
|
||||
|
||||
func setupProcessDetailsSummaryTest(t *testing.T) (*DB, string, string) {
|
||||
t.Helper()
|
||||
db, err := NewDB(filepath.Join(t.TempDir(), "process-details.db"), zap.NewNop())
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// FindNearestToolExecutionArguments returns the arguments for the execution record
|
||||
// closest to a persisted tool_call detail. Eino can persist a tool_call with empty
|
||||
// model arguments while the monitor execution row still has the real command/URL.
|
||||
func (db *DB) FindNearestToolExecutionArguments(conversationID, toolName string, at time.Time, window time.Duration) (string, map[string]interface{}, error) {
|
||||
conversationID = strings.TrimSpace(conversationID)
|
||||
toolName = strings.TrimSpace(toolName)
|
||||
if db == nil || conversationID == "" || toolName == "" || at.IsZero() {
|
||||
return "", nil, sql.ErrNoRows
|
||||
}
|
||||
if window <= 0 {
|
||||
window = 5 * time.Second
|
||||
}
|
||||
names := []string{toolName}
|
||||
if !strings.Contains(toolName, "::") {
|
||||
names = append(names, "eino_fs::"+toolName)
|
||||
}
|
||||
start := at.Add(-window)
|
||||
end := at.Add(window)
|
||||
rows, err := db.Query(`
|
||||
SELECT id, arguments
|
||||
FROM tool_executions
|
||||
WHERE conversation_id = ?
|
||||
AND tool_name IN (?, ?)
|
||||
AND julianday(start_time) BETWEEN julianday(?) AND julianday(?)
|
||||
ORDER BY ABS(julianday(start_time) - julianday(?)) ASC, start_time ASC
|
||||
LIMIT 1`, conversationID, names[0], names[len(names)-1], start, end, at)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
if !rows.Next() {
|
||||
if err := rows.Err(); err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
return "", nil, sql.ErrNoRows
|
||||
}
|
||||
var id string
|
||||
var raw string
|
||||
if err := rows.Scan(&id, &raw); err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
var args map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(raw), &args); err != nil {
|
||||
return "", nil, fmt.Errorf("parse tool execution arguments: %w", err)
|
||||
}
|
||||
return strings.TrimSpace(id), args, nil
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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,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())
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 返回了网页而不是 JSON(HTTP %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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -46,6 +46,14 @@ type AgentTask struct {
|
||||
// hitlCognition 本轮运行中供 HITL/审计 Agent 读取的上下文(用户原话 + 思考,不含会话历史)
|
||||
hitlCognition *hitlCognitionState
|
||||
|
||||
// agentRuntimeCancel 当前 Eino ADK 原生 AgentCancelFunc 包装;取消任务时先触发它,再走 context 兜底。
|
||||
agentRuntimeCancel func(error) bool
|
||||
agentRuntimeCancelVersion uint64
|
||||
|
||||
// agentTurnLoopInterrupt 当前 Eino TurnLoop 用户补充 push hook;中断并继续时优先将补充作为新 turn item 入队。
|
||||
agentTurnLoopInterrupt func(string) bool
|
||||
agentTurnLoopInterruptVersion uint64
|
||||
|
||||
cancel func(error)
|
||||
}
|
||||
|
||||
@@ -220,6 +228,58 @@ func (m *AgentTaskManager) BindTaskCancel(conversationID string, cancel context.
|
||||
}
|
||||
}
|
||||
|
||||
// BindAgentRuntimeCancel 登记当前运行段的 Eino 原生 cancel hook。
|
||||
func (m *AgentTaskManager) BindAgentRuntimeCancel(conversationID string, cancel func(error) bool) func() {
|
||||
conversationID = strings.TrimSpace(conversationID)
|
||||
if conversationID == "" || cancel == nil {
|
||||
return func() {}
|
||||
}
|
||||
m.mu.Lock()
|
||||
t, ok := m.tasks[conversationID]
|
||||
if !ok || t == nil {
|
||||
m.mu.Unlock()
|
||||
return func() {}
|
||||
}
|
||||
t.agentRuntimeCancelVersion++
|
||||
version := t.agentRuntimeCancelVersion
|
||||
t.agentRuntimeCancel = cancel
|
||||
m.mu.Unlock()
|
||||
|
||||
return func() {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if cur, exists := m.tasks[conversationID]; exists && cur != nil && cur.agentRuntimeCancelVersion == version {
|
||||
cur.agentRuntimeCancel = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// BindAgentTurnLoopInterrupt 登记当前运行任务的 Eino TurnLoop 用户补充入队 hook。
|
||||
func (m *AgentTaskManager) BindAgentTurnLoopInterrupt(conversationID string, push func(string) bool) func() {
|
||||
conversationID = strings.TrimSpace(conversationID)
|
||||
if conversationID == "" || push == nil {
|
||||
return func() {}
|
||||
}
|
||||
m.mu.Lock()
|
||||
t, ok := m.tasks[conversationID]
|
||||
if !ok || t == nil {
|
||||
m.mu.Unlock()
|
||||
return func() {}
|
||||
}
|
||||
t.agentTurnLoopInterruptVersion++
|
||||
version := t.agentTurnLoopInterruptVersion
|
||||
t.agentTurnLoopInterrupt = push
|
||||
m.mu.Unlock()
|
||||
|
||||
return func() {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if cur, exists := m.tasks[conversationID]; exists && cur != nil && cur.agentTurnLoopInterruptVersion == version {
|
||||
cur.agentTurnLoopInterrupt = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ActiveMCPExecutionID 返回当前会话进行中的工具 executionId,无则空串。
|
||||
func (m *AgentTaskManager) ActiveMCPExecutionID(conversationID string) string {
|
||||
conversationID = strings.TrimSpace(conversationID)
|
||||
@@ -402,13 +462,29 @@ func (m *AgentTaskManager) CancelTask(conversationID string, cause error) (bool,
|
||||
if cause == nil {
|
||||
cause = ErrTaskCancelled
|
||||
}
|
||||
interruptPush := task.agentTurnLoopInterrupt
|
||||
interruptNote := task.InterruptContinueNote
|
||||
runtimeCancel := task.agentRuntimeCancel
|
||||
var toolCanceler func(string)
|
||||
if errors.Is(cause, ErrTaskCancelled) {
|
||||
toolCanceler = m.toolCanceler
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
if cancel != nil {
|
||||
if errors.Is(cause, multiagent.ErrInterruptContinue) && interruptPush != nil && interruptPush(interruptNote) {
|
||||
m.mu.Lock()
|
||||
if cur, exists := m.tasks[conversationID]; exists && cur != nil {
|
||||
cur.InterruptContinueNote = ""
|
||||
}
|
||||
m.mu.Unlock()
|
||||
return true, nil
|
||||
}
|
||||
|
||||
runtimeHandled := false
|
||||
if runtimeCancel != nil {
|
||||
runtimeHandled = runtimeCancel(cause)
|
||||
}
|
||||
if cancel != nil && !runtimeHandled {
|
||||
cancel(cause)
|
||||
}
|
||||
if toolCanceler != nil {
|
||||
|
||||
@@ -32,6 +32,78 @@ func TestCancelTaskInvokesToolCancelerOnFullStop(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCancelTaskUsesAgentRuntimeCancelAsPrimaryPath(t *testing.T) {
|
||||
tm := NewAgentTaskManager()
|
||||
var order []string
|
||||
tm.SetToolCanceler(func(conversationID string) {
|
||||
if conversationID == "conv-native" {
|
||||
order = append(order, "tool")
|
||||
}
|
||||
})
|
||||
|
||||
_, cancel := context.WithCancelCause(context.Background())
|
||||
if _, err := tm.StartTask("conv-native", "hello", func(err error) {
|
||||
order = append(order, "context")
|
||||
cancel(err)
|
||||
}); err != nil {
|
||||
t.Fatalf("StartTask: %v", err)
|
||||
}
|
||||
unregister := tm.BindAgentRuntimeCancel("conv-native", func(err error) bool {
|
||||
if !errors.Is(err, ErrTaskCancelled) {
|
||||
t.Fatalf("runtime cancel got %v", err)
|
||||
}
|
||||
order = append(order, "runtime")
|
||||
return true
|
||||
})
|
||||
defer unregister()
|
||||
|
||||
ok, err := tm.CancelTask("conv-native", ErrTaskCancelled)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("CancelTask: ok=%v err=%v", ok, err)
|
||||
}
|
||||
want := []string{"runtime", "tool"}
|
||||
if len(order) != len(want) {
|
||||
t.Fatalf("order length got %d want %d: %#v", len(order), len(want), order)
|
||||
}
|
||||
for i := range want {
|
||||
if order[i] != want[i] {
|
||||
t.Fatalf("order[%d] got %q want %q; full=%#v", i, order[i], want[i], order)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCancelTaskFallsBackToContextWhenAgentRuntimeCancelMisses(t *testing.T) {
|
||||
tm := NewAgentTaskManager()
|
||||
var order []string
|
||||
|
||||
_, cancel := context.WithCancelCause(context.Background())
|
||||
if _, err := tm.StartTask("conv-fallback", "hello", func(err error) {
|
||||
order = append(order, "context")
|
||||
cancel(err)
|
||||
}); err != nil {
|
||||
t.Fatalf("StartTask: %v", err)
|
||||
}
|
||||
unregister := tm.BindAgentRuntimeCancel("conv-fallback", func(err error) bool {
|
||||
order = append(order, "runtime")
|
||||
return false
|
||||
})
|
||||
defer unregister()
|
||||
|
||||
ok, err := tm.CancelTask("conv-fallback", ErrTaskCancelled)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("CancelTask: ok=%v err=%v", ok, err)
|
||||
}
|
||||
want := []string{"runtime", "context"}
|
||||
if len(order) != len(want) {
|
||||
t.Fatalf("order length got %d want %d: %#v", len(order), len(want), order)
|
||||
}
|
||||
for i := range want {
|
||||
if order[i] != want[i] {
|
||||
t.Fatalf("order[%d] got %q want %q; full=%#v", i, order[i], want[i], order)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCancelTaskSkipsToolCancelerOnInterruptContinue(t *testing.T) {
|
||||
tm := NewAgentTaskManager()
|
||||
called := false
|
||||
@@ -54,6 +126,80 @@ func TestCancelTaskSkipsToolCancelerOnInterruptContinue(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCancelTaskPushesInterruptContinueToTurnLoopFirst(t *testing.T) {
|
||||
tm := NewAgentTaskManager()
|
||||
ctx, cancel := context.WithCancelCause(context.Background())
|
||||
if _, err := tm.StartTask("conv-turn", "hello", cancel); err != nil {
|
||||
t.Fatalf("StartTask: %v", err)
|
||||
}
|
||||
tm.SetInterruptContinueNote("conv-turn", "focus ssh")
|
||||
|
||||
var gotNote string
|
||||
unregister := tm.BindAgentTurnLoopInterrupt("conv-turn", func(note string) bool {
|
||||
gotNote = note
|
||||
return true
|
||||
})
|
||||
defer unregister()
|
||||
|
||||
ok, err := tm.CancelTask("conv-turn", multiagent.ErrInterruptContinue)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("CancelTask: ok=%v err=%v", ok, err)
|
||||
}
|
||||
if gotNote != "focus ssh" {
|
||||
t.Fatalf("turn loop note = %q, want focus ssh", gotNote)
|
||||
}
|
||||
if cause := context.Cause(ctx); cause != nil {
|
||||
t.Fatalf("context should not be cancelled when turn loop accepted interrupt, got %v", cause)
|
||||
}
|
||||
if note := tm.TakeInterruptContinueNote("conv-turn"); note != "" {
|
||||
t.Fatalf("interrupt note should be consumed after turn loop push, got %q", note)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCancelTaskFallsBackWhenTurnLoopInterruptRejects(t *testing.T) {
|
||||
tm := NewAgentTaskManager()
|
||||
var order []string
|
||||
|
||||
_, cancel := context.WithCancelCause(context.Background())
|
||||
if _, err := tm.StartTask("conv-turn-fallback", "hello", func(err error) {
|
||||
order = append(order, "context")
|
||||
cancel(err)
|
||||
}); err != nil {
|
||||
t.Fatalf("StartTask: %v", err)
|
||||
}
|
||||
tm.SetInterruptContinueNote("conv-turn-fallback", "fallback note")
|
||||
unregisterTurn := tm.BindAgentTurnLoopInterrupt("conv-turn-fallback", func(note string) bool {
|
||||
order = append(order, "turn")
|
||||
if note != "fallback note" {
|
||||
t.Fatalf("turn loop note = %q, want fallback note", note)
|
||||
}
|
||||
return false
|
||||
})
|
||||
defer unregisterTurn()
|
||||
unregisterRuntime := tm.BindAgentRuntimeCancel("conv-turn-fallback", func(err error) bool {
|
||||
order = append(order, "runtime")
|
||||
return false
|
||||
})
|
||||
defer unregisterRuntime()
|
||||
|
||||
ok, err := tm.CancelTask("conv-turn-fallback", multiagent.ErrInterruptContinue)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("CancelTask: ok=%v err=%v", ok, err)
|
||||
}
|
||||
want := []string{"turn", "runtime", "context"}
|
||||
if len(order) != len(want) {
|
||||
t.Fatalf("order length got %d want %d: %#v", len(order), len(want), order)
|
||||
}
|
||||
for i := range want {
|
||||
if order[i] != want[i] {
|
||||
t.Fatalf("order[%d] got %q want %q; full=%#v", i, order[i], want[i], order)
|
||||
}
|
||||
}
|
||||
if note := tm.TakeInterruptContinueNote("conv-turn-fallback"); note != "fallback note" {
|
||||
t.Fatalf("interrupt note should remain for fallback rerun, got %q", note)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCancelTaskDefaultCauseIsTaskCancelled(t *testing.T) {
|
||||
tm := NewAgentTaskManager()
|
||||
var gotCause error
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
|
||||
"cyberstrike-ai/internal/database"
|
||||
"cyberstrike-ai/internal/security"
|
||||
workflowrunner "cyberstrike-ai/internal/workflow"
|
||||
workflowpkg "cyberstrike-ai/internal/workflow/package"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -22,6 +23,7 @@ import (
|
||||
|
||||
func TestWorkflowPackageHandlerInspectionAndCreateImport(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
workflowrunner.SetCheckpointDir(filepath.Join(t.TempDir(), "workflow-checkpoints"))
|
||||
db, err := database.NewDB(filepath.Join(t.TempDir(), "workflow-package-handler.db"), zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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, "")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -15,7 +15,7 @@ func TestRecvSchemaMessageStream_EOF(t *testing.T) {
|
||||
_ = sw.Send(schema.ToolMessage("hello", "tc-1"), nil)
|
||||
sw.Close()
|
||||
|
||||
content, tid, err := recvSchemaMessageStream(context.Background(), sr)
|
||||
content, tid, toolName, err := recvSchemaMessageStream(context.Background(), sr)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
@@ -25,6 +25,23 @@ func TestRecvSchemaMessageStream_EOF(t *testing.T) {
|
||||
if tid != "tc-1" {
|
||||
t.Fatalf("toolCallID=%q want tc-1", tid)
|
||||
}
|
||||
if toolName != "" {
|
||||
t.Fatalf("toolName=%q want empty", toolName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecvSchemaMessageStream_CapturesToolName(t *testing.T) {
|
||||
sr, sw := schema.Pipe[*schema.Message](4)
|
||||
_ = sw.Send(schema.ToolMessage("hello", "tc-1", schema.WithToolName("execute")), nil)
|
||||
sw.Close()
|
||||
|
||||
content, tid, toolName, err := recvSchemaMessageStream(context.Background(), sr)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
if content != "hello" || tid != "tc-1" || toolName != "execute" {
|
||||
t.Fatalf("content=%q tid=%q toolName=%q", content, tid, toolName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecvSchemaMessageStream_ContextCancel(t *testing.T) {
|
||||
@@ -37,7 +54,7 @@ func TestRecvSchemaMessageStream_ContextCancel(t *testing.T) {
|
||||
cancel()
|
||||
}()
|
||||
|
||||
content, _, err := recvSchemaMessageStream(ctx, sr)
|
||||
content, _, _, err := recvSchemaMessageStream(ctx, sr)
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("want context.Canceled, got %v content=%q", err, content)
|
||||
}
|
||||
@@ -49,16 +66,16 @@ func TestRecvSchemaMessageStream_RecvError(t *testing.T) {
|
||||
_ = sw.Send(nil, want)
|
||||
sw.Close()
|
||||
|
||||
_, _, err := recvSchemaMessageStream(context.Background(), sr)
|
||||
_, _, _, err := recvSchemaMessageStream(context.Background(), sr)
|
||||
if !errors.Is(err, want) {
|
||||
t.Fatalf("want %v, got %v", want, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecvSchemaMessageStream_NilStream(t *testing.T) {
|
||||
content, tid, err := recvSchemaMessageStream(context.Background(), nil)
|
||||
if err != nil || content != "" || tid != "" {
|
||||
t.Fatalf("nil stream: content=%q tid=%q err=%v", content, tid, err)
|
||||
content, tid, toolName, err := recvSchemaMessageStream(context.Background(), nil)
|
||||
if err != nil || content != "" || tid != "" || toolName != "" {
|
||||
t.Fatalf("nil stream: content=%q tid=%q toolName=%q err=%v", content, tid, toolName, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,8 +84,39 @@ func TestRecvSchemaMessageStream_EOFViaEmptyRead(t *testing.T) {
|
||||
_ = sw.Send(nil, io.EOF)
|
||||
sw.Close()
|
||||
|
||||
_, _, err := recvSchemaMessageStream(context.Background(), sr)
|
||||
_, _, _, err := recvSchemaMessageStream(context.Background(), sr)
|
||||
if err != nil {
|
||||
t.Fatalf("EOF should not surface as error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecvEinoSchemaMessageStreamWithContext_SkipsNilChunks(t *testing.T) {
|
||||
sr, sw := schema.Pipe[*schema.Message](4)
|
||||
_ = sw.Send(nil, nil)
|
||||
_ = sw.Send(schema.AssistantMessage("hello", nil), nil)
|
||||
sw.Close()
|
||||
|
||||
var got []string
|
||||
err := recvEinoSchemaMessageStreamWithContext(context.Background(), sr, 1, func(chunk *schema.Message) {
|
||||
got = append(got, chunk.Content)
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
if len(got) != 1 || got[0] != "hello" {
|
||||
t.Fatalf("chunks = %#v, want [hello]", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecvEinoSchemaMessageStreamWithContext_NilStream(t *testing.T) {
|
||||
called := false
|
||||
err := recvEinoSchemaMessageStreamWithContext(context.Background(), nil, 0, func(*schema.Message) {
|
||||
called = true
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("nil stream should not error, got %v", err)
|
||||
}
|
||||
if called {
|
||||
t.Fatal("nil stream should not call handler")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
package multiagent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/cloudwego/eino/adk"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
type einoAgenticMessageAgentAdapter struct {
|
||||
inner adk.TypedAgent[*schema.AgenticMessage]
|
||||
}
|
||||
|
||||
func newEinoAgenticMessageAgentAdapter(inner adk.TypedAgent[*schema.AgenticMessage]) adk.Agent {
|
||||
if inner == nil {
|
||||
return nil
|
||||
}
|
||||
return &einoAgenticMessageAgentAdapter{inner: inner}
|
||||
}
|
||||
|
||||
func (a *einoAgenticMessageAgentAdapter) Name(ctx context.Context) string {
|
||||
if a == nil || a.inner == nil {
|
||||
return ""
|
||||
}
|
||||
return a.inner.Name(ctx)
|
||||
}
|
||||
|
||||
func (a *einoAgenticMessageAgentAdapter) Description(ctx context.Context) string {
|
||||
if a == nil || a.inner == nil {
|
||||
return ""
|
||||
}
|
||||
return a.inner.Description(ctx)
|
||||
}
|
||||
|
||||
func (a *einoAgenticMessageAgentAdapter) Run(ctx context.Context, input *adk.AgentInput, opts ...adk.AgentRunOption) *adk.AsyncIterator[*adk.AgentEvent] {
|
||||
return a.runTyped(ctx, input, nil, opts...)
|
||||
}
|
||||
|
||||
func (a *einoAgenticMessageAgentAdapter) Resume(ctx context.Context, info *adk.ResumeInfo, opts ...adk.AgentRunOption) *adk.AsyncIterator[*adk.AgentEvent] {
|
||||
return a.runTyped(ctx, nil, info, opts...)
|
||||
}
|
||||
|
||||
func (a *einoAgenticMessageAgentAdapter) runTyped(ctx context.Context, input *adk.AgentInput, resumeInfo *adk.ResumeInfo, opts ...adk.AgentRunOption) *adk.AsyncIterator[*adk.AgentEvent] {
|
||||
iter, gen := adk.NewAsyncIteratorPair[*adk.AgentEvent]()
|
||||
go func() {
|
||||
defer gen.Close()
|
||||
if a == nil || a.inner == nil {
|
||||
gen.Send(&adk.AgentEvent{Err: fmt.Errorf("agentic adapter: inner agent is nil")})
|
||||
return
|
||||
}
|
||||
var agenticIter *adk.AsyncIterator[*adk.TypedAgentEvent[*schema.AgenticMessage]]
|
||||
if resumeInfo != nil {
|
||||
resumable, ok := a.inner.(adk.TypedResumableAgent[*schema.AgenticMessage])
|
||||
if !ok {
|
||||
gen.Send(&adk.AgentEvent{Err: fmt.Errorf("agentic adapter: inner agent does not support resume")})
|
||||
return
|
||||
}
|
||||
agenticIter = resumable.Resume(ctx, resumeInfo, opts...)
|
||||
} else {
|
||||
agenticInput := &adk.TypedAgentInput[*schema.AgenticMessage]{}
|
||||
if input != nil {
|
||||
agenticInput.EnableStreaming = input.EnableStreaming
|
||||
agenticInput.Messages = EinoMessagesToAgentic(input.Messages)
|
||||
}
|
||||
agenticIter = a.inner.Run(ctx, agenticInput, opts...)
|
||||
}
|
||||
for {
|
||||
ev, ok := agenticIter.Next()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
for _, adapted := range adaptAgenticEventToEinoEvents(ev) {
|
||||
if adapted != nil {
|
||||
gen.Send(adapted)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
return iter
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package multiagent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/cloudwego/eino/adk"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
type fakeAgenticMessageAgent struct {
|
||||
name string
|
||||
description string
|
||||
captured *adk.TypedAgentInput[*schema.AgenticMessage]
|
||||
resumeInfo *adk.ResumeInfo
|
||||
events []*adk.TypedAgentEvent[*schema.AgenticMessage]
|
||||
}
|
||||
|
||||
func (f *fakeAgenticMessageAgent) Name(context.Context) string {
|
||||
return f.name
|
||||
}
|
||||
|
||||
func (f *fakeAgenticMessageAgent) Description(context.Context) string {
|
||||
return f.description
|
||||
}
|
||||
|
||||
func (f *fakeAgenticMessageAgent) Run(_ context.Context, input *adk.TypedAgentInput[*schema.AgenticMessage], _ ...adk.AgentRunOption) *adk.AsyncIterator[*adk.TypedAgentEvent[*schema.AgenticMessage]] {
|
||||
f.captured = input
|
||||
iter, gen := adk.NewAsyncIteratorPair[*adk.TypedAgentEvent[*schema.AgenticMessage]]()
|
||||
go func() {
|
||||
defer gen.Close()
|
||||
for _, ev := range f.events {
|
||||
gen.Send(ev)
|
||||
}
|
||||
}()
|
||||
return iter
|
||||
}
|
||||
|
||||
func (f *fakeAgenticMessageAgent) Resume(_ context.Context, info *adk.ResumeInfo, _ ...adk.AgentRunOption) *adk.AsyncIterator[*adk.TypedAgentEvent[*schema.AgenticMessage]] {
|
||||
f.resumeInfo = info
|
||||
iter, gen := adk.NewAsyncIteratorPair[*adk.TypedAgentEvent[*schema.AgenticMessage]]()
|
||||
go func() {
|
||||
defer gen.Close()
|
||||
for _, ev := range f.events {
|
||||
gen.Send(ev)
|
||||
}
|
||||
}()
|
||||
return iter
|
||||
}
|
||||
|
||||
func TestEinoAgenticMessageAgentAdapterConvertsInputAndEvents(t *testing.T) {
|
||||
inner := &fakeAgenticMessageAgent{
|
||||
name: "agentic",
|
||||
description: "typed agent",
|
||||
events: []*adk.TypedAgentEvent[*schema.AgenticMessage]{
|
||||
{
|
||||
AgentName: "agentic",
|
||||
Output: &adk.TypedAgentOutput[*schema.AgenticMessage]{
|
||||
MessageOutput: &adk.TypedMessageVariant[*schema.AgenticMessage]{
|
||||
Message: &schema.AgenticMessage{
|
||||
Role: schema.AgenticRoleTypeAssistant,
|
||||
ContentBlocks: []*schema.ContentBlock{
|
||||
schema.NewContentBlock(&schema.AssistantGenText{Text: "hello"}),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
agent := newEinoAgenticMessageAgentAdapter(inner)
|
||||
|
||||
if agent.Name(context.Background()) != "agentic" || agent.Description(context.Background()) != "typed agent" {
|
||||
t.Fatalf("adapter metadata name=%q desc=%q", agent.Name(context.Background()), agent.Description(context.Background()))
|
||||
}
|
||||
iter := agent.Run(context.Background(), &adk.AgentInput{
|
||||
EnableStreaming: true,
|
||||
Messages: []*schema.Message{
|
||||
schema.UserMessage("hi"),
|
||||
},
|
||||
})
|
||||
|
||||
ev, ok := iter.Next()
|
||||
if !ok {
|
||||
t.Fatal("expected adapted event")
|
||||
}
|
||||
if inner.captured == nil || !inner.captured.EnableStreaming || len(inner.captured.Messages) != 1 {
|
||||
t.Fatalf("captured input = %#v", inner.captured)
|
||||
}
|
||||
if inner.captured.Messages[0].Role != schema.AgenticRoleTypeUser || inner.captured.Messages[0].ContentBlocks[0].UserInputText.Text != "hi" {
|
||||
t.Fatalf("captured message = %#v", inner.captured.Messages[0])
|
||||
}
|
||||
if ev.AgentName != "agentic" || ev.Output == nil || ev.Output.MessageOutput == nil {
|
||||
t.Fatalf("event = %#v", ev)
|
||||
}
|
||||
if ev.Output.MessageOutput.Role != schema.Assistant || ev.Output.MessageOutput.Message.Content != "hello" {
|
||||
t.Fatalf("message output = %#v", ev.Output.MessageOutput)
|
||||
}
|
||||
if _, ok := iter.Next(); ok {
|
||||
t.Fatal("expected iterator to close")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEinoAgenticMessageAgentAdapterNilInnerReturnsNil(t *testing.T) {
|
||||
if got := newEinoAgenticMessageAgentAdapter(nil); got != nil {
|
||||
t.Fatalf("adapter = %#v, want nil", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEinoAgenticMessageAgentAdapterResumeConvertsEvents(t *testing.T) {
|
||||
inner := &fakeAgenticMessageAgent{
|
||||
name: "agentic",
|
||||
events: []*adk.TypedAgentEvent[*schema.AgenticMessage]{
|
||||
{
|
||||
AgentName: "agentic",
|
||||
Output: &adk.TypedAgentOutput[*schema.AgenticMessage]{
|
||||
MessageOutput: &adk.TypedMessageVariant[*schema.AgenticMessage]{
|
||||
Message: &schema.AgenticMessage{
|
||||
Role: schema.AgenticRoleTypeAssistant,
|
||||
ContentBlocks: []*schema.ContentBlock{
|
||||
schema.NewContentBlock(&schema.AssistantGenText{Text: "resumed"}),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
agent, ok := newEinoAgenticMessageAgentAdapter(inner).(adk.ResumableAgent)
|
||||
if !ok {
|
||||
t.Fatal("adapter must implement adk.ResumableAgent")
|
||||
}
|
||||
info := &adk.ResumeInfo{WasInterrupted: true}
|
||||
iter := agent.Resume(context.Background(), info)
|
||||
ev, ok := iter.Next()
|
||||
if !ok {
|
||||
t.Fatal("expected adapted resume event")
|
||||
}
|
||||
if inner.resumeInfo != info {
|
||||
t.Fatalf("resume info = %#v, want original pointer", inner.resumeInfo)
|
||||
}
|
||||
if ev.Output == nil || ev.Output.MessageOutput == nil || ev.Output.MessageOutput.Message.Content != "resumed" {
|
||||
t.Fatalf("resume event = %#v", ev)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package multiagent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/cloudwego/eino/adk"
|
||||
"github.com/cloudwego/eino/components/model"
|
||||
"github.com/cloudwego/eino/components/tool"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
type einoAgenticChatModelAgentConfig struct {
|
||||
Name string
|
||||
Description string
|
||||
Instruction string
|
||||
Model model.AgenticModel
|
||||
ToolsConfig adk.ToolsConfig
|
||||
MaxIterations int
|
||||
Exit tool.BaseTool
|
||||
|
||||
GenModelInput adk.TypedGenModelInput[*schema.AgenticMessage]
|
||||
Handlers []adk.TypedChatModelAgentMiddleware[*schema.AgenticMessage]
|
||||
ModelRetryConfig *adk.TypedModelRetryConfig[*schema.AgenticMessage]
|
||||
ModelFailoverConfig *adk.ModelFailoverConfig[*schema.AgenticMessage]
|
||||
OutputKey string
|
||||
}
|
||||
|
||||
func newEinoAgenticChatModelAgent(ctx context.Context, cfg einoAgenticChatModelAgentConfig) (adk.TypedResumableAgent[*schema.AgenticMessage], error) {
|
||||
if cfg.Model == nil {
|
||||
return nil, fmt.Errorf("eino agentic ChatModelAgent: model is required")
|
||||
}
|
||||
typedCfg := &adk.TypedChatModelAgentConfig[*schema.AgenticMessage]{
|
||||
Name: cfg.Name,
|
||||
Description: cfg.Description,
|
||||
Instruction: cfg.Instruction,
|
||||
Model: cfg.Model,
|
||||
ToolsConfig: cfg.ToolsConfig,
|
||||
MaxIterations: cfg.MaxIterations,
|
||||
Exit: cfg.Exit,
|
||||
GenModelInput: cfg.GenModelInput,
|
||||
Handlers: cfg.Handlers,
|
||||
ModelRetryConfig: cfg.ModelRetryConfig,
|
||||
ModelFailoverConfig: cfg.ModelFailoverConfig,
|
||||
OutputKey: cfg.OutputKey,
|
||||
}
|
||||
typedAgent, err := adk.NewTypedChatModelAgent(ctx, typedCfg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("eino agentic NewTypedChatModelAgent: %w", err)
|
||||
}
|
||||
return typedAgent, nil
|
||||
}
|
||||
|
||||
func newEinoAgenticChatModelAgentAdapter(ctx context.Context, cfg einoAgenticChatModelAgentConfig) (adk.Agent, error) {
|
||||
typedAgent, err := newEinoAgenticChatModelAgent(ctx, cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
agent := newEinoAgenticMessageAgentAdapter(typedAgent)
|
||||
if agent == nil {
|
||||
return nil, fmt.Errorf("eino agentic ChatModelAgent: adapter is nil")
|
||||
}
|
||||
return agent, nil
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package multiagent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/cloudwego/eino/adk"
|
||||
"github.com/cloudwego/eino/components/model"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
type capturingAgenticChatModel struct {
|
||||
mu sync.Mutex
|
||||
inputs [][]*schema.AgenticMessage
|
||||
output *schema.AgenticMessage
|
||||
}
|
||||
|
||||
func (m *capturingAgenticChatModel) Generate(_ context.Context, input []*schema.AgenticMessage, _ ...model.Option) (*schema.AgenticMessage, error) {
|
||||
m.mu.Lock()
|
||||
m.inputs = append(m.inputs, input)
|
||||
m.mu.Unlock()
|
||||
if m.output != nil {
|
||||
return m.output, nil
|
||||
}
|
||||
return &schema.AgenticMessage{
|
||||
Role: schema.AgenticRoleTypeAssistant,
|
||||
ContentBlocks: []*schema.ContentBlock{schema.NewContentBlock(&schema.AssistantGenText{Text: "agentic answer"})},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *capturingAgenticChatModel) Stream(_ context.Context, input []*schema.AgenticMessage, _ ...model.Option) (*schema.StreamReader[*schema.AgenticMessage], error) {
|
||||
msg, err := m.Generate(context.Background(), input)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return schema.StreamReaderFromArray([]*schema.AgenticMessage{msg}), nil
|
||||
}
|
||||
|
||||
func (m *capturingAgenticChatModel) snapshotInputs() [][]*schema.AgenticMessage {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
out := make([][]*schema.AgenticMessage, len(m.inputs))
|
||||
copy(out, m.inputs)
|
||||
return out
|
||||
}
|
||||
|
||||
func TestNewEinoAgenticChatModelAgentAdapterRunsThroughClassicAgentBoundary(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
trace := newModelFacingTraceHolder()
|
||||
fakeModel := &capturingAgenticChatModel{}
|
||||
agent, err := newEinoAgenticChatModelAgentAdapter(ctx, einoAgenticChatModelAgentConfig{
|
||||
Name: "agentic",
|
||||
Description: "agentic adapter test",
|
||||
Instruction: "system instruction",
|
||||
Model: fakeModel,
|
||||
Handlers: appendEinoAgenticChatModelTailMiddlewares(nil, einoChatModelTailConfig{
|
||||
phase: "agentic",
|
||||
trace: trace,
|
||||
}),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("newEinoAgenticChatModelAgentAdapter: %v", err)
|
||||
}
|
||||
|
||||
iter := agent.Run(ctx, &adk.AgentInput{
|
||||
Messages: []*schema.Message{schema.UserMessage("classic input")},
|
||||
})
|
||||
var last *adk.AgentEvent
|
||||
for {
|
||||
ev, ok := iter.Next()
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
if ev.Err != nil {
|
||||
t.Fatalf("agent event error: %v", ev.Err)
|
||||
}
|
||||
last = ev
|
||||
}
|
||||
if last == nil || last.Output == nil || last.Output.MessageOutput == nil {
|
||||
t.Fatalf("last event = %#v, want message output", last)
|
||||
}
|
||||
if got := last.Output.MessageOutput.Message.Content; got != "agentic answer" {
|
||||
t.Fatalf("classic output content = %q, want agentic answer", got)
|
||||
}
|
||||
|
||||
inputs := fakeModel.snapshotInputs()
|
||||
if len(inputs) != 1 {
|
||||
t.Fatalf("model calls = %d, want 1", len(inputs))
|
||||
}
|
||||
if len(inputs[0]) != 2 {
|
||||
t.Fatalf("model input messages = %d, want instruction + user", len(inputs[0]))
|
||||
}
|
||||
if inputs[0][0].Role != schema.AgenticRoleTypeSystem || agenticMessageText(inputs[0][0]) != "system instruction" {
|
||||
t.Fatalf("first agentic input = %#v", inputs[0][0])
|
||||
}
|
||||
if inputs[0][1].Role != schema.AgenticRoleTypeUser || agenticMessageText(inputs[0][1]) != "classic input" {
|
||||
t.Fatalf("second agentic input = %#v", inputs[0][1])
|
||||
}
|
||||
|
||||
snapshot := trace.Snapshot()
|
||||
if len(snapshot) != 2 || snapshot[0].Role != schema.System || snapshot[1].Role != schema.User {
|
||||
t.Fatalf("trace snapshot = %#v, want classic system + user trace", snapshot)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewEinoAgenticChatModelAgentAdapterPreservesTypedToolCallsForToolLayerRecovery(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
fakeModel := &capturingAgenticChatModel{
|
||||
output: &schema.AgenticMessage{
|
||||
Role: schema.AgenticRoleTypeAssistant,
|
||||
ContentBlocks: []*schema.ContentBlock{schema.NewContentBlock(&schema.FunctionToolCall{
|
||||
CallID: "call-1",
|
||||
Name: "exec",
|
||||
Arguments: `{"command":"` + strings.Repeat("x", 20000) + `"}`,
|
||||
})},
|
||||
},
|
||||
}
|
||||
agent, err := newEinoAgenticChatModelAgentAdapter(ctx, einoAgenticChatModelAgentConfig{
|
||||
Name: "agentic",
|
||||
Description: "agentic adapter test",
|
||||
Model: fakeModel,
|
||||
Handlers: appendEinoAgenticChatModelTailMiddlewares(nil, einoChatModelTailConfig{
|
||||
phase: "agentic",
|
||||
}),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("newEinoAgenticChatModelAgentAdapter: %v", err)
|
||||
}
|
||||
iter := agent.Run(ctx, &adk.AgentInput{Messages: []*schema.Message{schema.UserMessage("run")}})
|
||||
var last *adk.AgentEvent
|
||||
for {
|
||||
ev, ok := iter.Next()
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
if ev.Err != nil {
|
||||
t.Fatalf("agent event error: %v", ev.Err)
|
||||
}
|
||||
last = ev
|
||||
}
|
||||
if last == nil || last.Output == nil || last.Output.MessageOutput == nil {
|
||||
t.Fatalf("last event = %#v, want message output", last)
|
||||
}
|
||||
msg := last.Output.MessageOutput.Message
|
||||
if len(msg.ToolCalls) != 1 {
|
||||
t.Fatalf("tool calls = %#v, want one tool call", msg.ToolCalls)
|
||||
}
|
||||
args := msg.ToolCalls[0].Function.Arguments
|
||||
if !strings.Contains(args, strings.Repeat("x", 32)) || strings.Contains(args, modelOutputRecoveryKey) {
|
||||
t.Fatalf("agentic tool args were unexpectedly rewritten: %q", args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewEinoAgenticChatModelAgentAdapterRequiresModel(t *testing.T) {
|
||||
t.Parallel()
|
||||
if _, err := newEinoAgenticChatModelAgentAdapter(context.Background(), einoAgenticChatModelAgentConfig{}); err == nil {
|
||||
t.Fatal("expected missing model error")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
package multiagent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/cloudwego/eino/adk"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// appendEinoAgenticChatModelTailMiddlewares appends protocol-neutral handlers for
|
||||
// TypedChatModelAgent[*schema.AgenticMessage]. Classic ReAct history repair
|
||||
// handlers stay on the schema.Message path because AgenticMessage has native
|
||||
// content blocks for function calls/results.
|
||||
func appendEinoAgenticChatModelTailMiddlewares(
|
||||
handlers []adk.TypedChatModelAgentMiddleware[*schema.AgenticMessage],
|
||||
cfg einoChatModelTailConfig,
|
||||
) []adk.TypedChatModelAgentMiddleware[*schema.AgenticMessage] {
|
||||
handlers = append(handlers, newAgenticSystemMessageNormalizerMiddleware(cfg.logger, cfg.phase))
|
||||
handlers = append(handlers, newAgenticContinuationUserDedupMiddleware(cfg.logger, cfg.phase))
|
||||
if cfg.agenticSummarization != nil {
|
||||
handlers = append(handlers, cfg.agenticSummarization)
|
||||
}
|
||||
if !cfg.skipTrace && cfg.trace != nil {
|
||||
if capMw := newAgenticModelFacingTraceMiddleware(cfg.trace); capMw != nil {
|
||||
handlers = append(handlers, capMw)
|
||||
}
|
||||
}
|
||||
return handlers
|
||||
}
|
||||
|
||||
type agenticSystemMessageNormalizerMiddleware struct {
|
||||
*adk.TypedBaseChatModelAgentMiddleware[*schema.AgenticMessage]
|
||||
logger *zap.Logger
|
||||
phase string
|
||||
}
|
||||
|
||||
func newAgenticSystemMessageNormalizerMiddleware(logger *zap.Logger, phase string) adk.TypedChatModelAgentMiddleware[*schema.AgenticMessage] {
|
||||
return &agenticSystemMessageNormalizerMiddleware{
|
||||
TypedBaseChatModelAgentMiddleware: &adk.TypedBaseChatModelAgentMiddleware[*schema.AgenticMessage]{},
|
||||
logger: logger,
|
||||
phase: phase,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *agenticSystemMessageNormalizerMiddleware) BeforeModelRewriteState(
|
||||
ctx context.Context,
|
||||
state *adk.TypedChatModelAgentState[*schema.AgenticMessage],
|
||||
mc *adk.TypedModelContext[*schema.AgenticMessage],
|
||||
) (context.Context, *adk.TypedChatModelAgentState[*schema.AgenticMessage], error) {
|
||||
_ = mc
|
||||
if m == nil || state == nil || len(state.Messages) == 0 {
|
||||
return ctx, state, nil
|
||||
}
|
||||
before := countAgenticSystemMessages(state.Messages)
|
||||
if before <= 1 {
|
||||
return ctx, state, nil
|
||||
}
|
||||
normalized := normalizeSingleLeadingAgenticSystemMessage(state.Messages)
|
||||
if len(normalized) == len(state.Messages) && countAgenticSystemMessages(normalized) >= before {
|
||||
return ctx, state, nil
|
||||
}
|
||||
if m.logger != nil {
|
||||
m.logger.Info("eino agentic system messages merged",
|
||||
zap.String("phase", m.phase),
|
||||
zap.Int("system_before", before),
|
||||
zap.Int("system_after", countAgenticSystemMessages(normalized)),
|
||||
zap.Int("messages_before", len(state.Messages)),
|
||||
zap.Int("messages_after", len(normalized)),
|
||||
)
|
||||
}
|
||||
out := *state
|
||||
out.Messages = normalized
|
||||
return ctx, &out, nil
|
||||
}
|
||||
|
||||
type agenticContinuationUserDedupMiddleware struct {
|
||||
*adk.TypedBaseChatModelAgentMiddleware[*schema.AgenticMessage]
|
||||
logger *zap.Logger
|
||||
phase string
|
||||
}
|
||||
|
||||
func newAgenticContinuationUserDedupMiddleware(logger *zap.Logger, phase string) adk.TypedChatModelAgentMiddleware[*schema.AgenticMessage] {
|
||||
return &agenticContinuationUserDedupMiddleware{
|
||||
TypedBaseChatModelAgentMiddleware: &adk.TypedBaseChatModelAgentMiddleware[*schema.AgenticMessage]{},
|
||||
logger: logger,
|
||||
phase: phase,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *agenticContinuationUserDedupMiddleware) BeforeModelRewriteState(
|
||||
ctx context.Context,
|
||||
state *adk.TypedChatModelAgentState[*schema.AgenticMessage],
|
||||
mc *adk.TypedModelContext[*schema.AgenticMessage],
|
||||
) (context.Context, *adk.TypedChatModelAgentState[*schema.AgenticMessage], error) {
|
||||
_ = mc
|
||||
if m == nil || state == nil || len(state.Messages) == 0 {
|
||||
return ctx, state, nil
|
||||
}
|
||||
deduped, dropped := dedupAgenticContinuationUserMessages(state.Messages)
|
||||
if dropped == 0 {
|
||||
return ctx, state, nil
|
||||
}
|
||||
if m.logger != nil {
|
||||
m.logger.Info("eino agentic continuation user messages deduplicated",
|
||||
zap.String("phase", m.phase),
|
||||
zap.Int("dropped", dropped),
|
||||
zap.Int("messages_before", len(state.Messages)),
|
||||
zap.Int("messages_after", len(deduped)),
|
||||
)
|
||||
}
|
||||
out := *state
|
||||
out.Messages = deduped
|
||||
return ctx, &out, nil
|
||||
}
|
||||
|
||||
func countAgenticSystemMessages(msgs []*schema.AgenticMessage) int {
|
||||
n := 0
|
||||
for _, msg := range msgs {
|
||||
if msg != nil && msg.Role == schema.AgenticRoleTypeSystem {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func normalizeSingleLeadingAgenticSystemMessage(msgs []*schema.AgenticMessage) []*schema.AgenticMessage {
|
||||
var systemParts []string
|
||||
out := make([]*schema.AgenticMessage, 0, len(msgs))
|
||||
for _, msg := range msgs {
|
||||
if msg == nil {
|
||||
continue
|
||||
}
|
||||
if msg.Role == schema.AgenticRoleTypeSystem {
|
||||
if text := strings.TrimSpace(agenticMessageText(msg)); text != "" {
|
||||
systemParts = append(systemParts, text)
|
||||
}
|
||||
continue
|
||||
}
|
||||
out = append(out, msg)
|
||||
}
|
||||
if len(systemParts) == 0 {
|
||||
return out
|
||||
}
|
||||
merged := schema.SystemAgenticMessage(strings.Join(systemParts, "\n\n"))
|
||||
return append([]*schema.AgenticMessage{merged}, out...)
|
||||
}
|
||||
|
||||
func dedupAgenticContinuationUserMessages(msgs []*schema.AgenticMessage) ([]*schema.AgenticMessage, int) {
|
||||
lastIdx := -1
|
||||
contCount := 0
|
||||
for i, msg := range msgs {
|
||||
if !isAgenticContinuationUserMessage(msg) {
|
||||
continue
|
||||
}
|
||||
contCount++
|
||||
lastIdx = i
|
||||
}
|
||||
if contCount <= 1 {
|
||||
return msgs, 0
|
||||
}
|
||||
out := make([]*schema.AgenticMessage, 0, len(msgs)-(contCount-1))
|
||||
dropped := 0
|
||||
for i, msg := range msgs {
|
||||
if isAgenticContinuationUserMessage(msg) && i != lastIdx {
|
||||
dropped++
|
||||
continue
|
||||
}
|
||||
out = append(out, msg)
|
||||
}
|
||||
return out, dropped
|
||||
}
|
||||
|
||||
func isAgenticContinuationUserMessage(msg *schema.AgenticMessage) bool {
|
||||
if msg == nil || msg.Role != schema.AgenticRoleTypeUser {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(agenticMessageText(msg), continuationSessionMarker)
|
||||
}
|
||||
|
||||
func agenticMessageText(msg *schema.AgenticMessage) string {
|
||||
if msg == nil {
|
||||
return ""
|
||||
}
|
||||
var b strings.Builder
|
||||
for _, block := range msg.ContentBlocks {
|
||||
if block == nil {
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case block.UserInputText != nil:
|
||||
if s := strings.TrimSpace(block.UserInputText.Text); s != "" {
|
||||
if b.Len() > 0 {
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
b.WriteString(s)
|
||||
}
|
||||
case block.AssistantGenText != nil:
|
||||
if s := strings.TrimSpace(block.AssistantGenText.Text); s != "" {
|
||||
if b.Len() > 0 {
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
b.WriteString(s)
|
||||
}
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package multiagent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/cloudwego/eino/adk"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
func TestAgenticSystemMessageNormalizerMiddlewareMergesDuplicates(t *testing.T) {
|
||||
t.Parallel()
|
||||
mw := newAgenticSystemMessageNormalizerMiddleware(nil, "test")
|
||||
state := &adk.TypedChatModelAgentState[*schema.AgenticMessage]{
|
||||
Messages: []*schema.AgenticMessage{
|
||||
schema.SystemAgenticMessage("first"),
|
||||
schema.UserAgenticMessage("hello"),
|
||||
schema.SystemAgenticMessage("second"),
|
||||
},
|
||||
}
|
||||
_, out, err := mw.BeforeModelRewriteState(context.Background(), state, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("BeforeModelRewriteState: %v", err)
|
||||
}
|
||||
if out == state {
|
||||
t.Fatal("expected rewritten state")
|
||||
}
|
||||
if got := countAgenticSystemMessages(out.Messages); got != 1 {
|
||||
t.Fatalf("system messages = %d, want 1", got)
|
||||
}
|
||||
if out.Messages[0].Role != schema.AgenticRoleTypeSystem {
|
||||
t.Fatalf("first role = %s, want system", out.Messages[0].Role)
|
||||
}
|
||||
text := agenticMessageText(out.Messages[0])
|
||||
if !strings.Contains(text, "first") || !strings.Contains(text, "second") {
|
||||
t.Fatalf("merged system text = %q", text)
|
||||
}
|
||||
if len(out.Messages) != 2 || agenticMessageText(out.Messages[1]) != "hello" {
|
||||
t.Fatalf("normalized messages = %#v", out.Messages)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgenticContinuationUserDedupMiddlewareKeepsLatest(t *testing.T) {
|
||||
t.Parallel()
|
||||
mw := newAgenticContinuationUserDedupMiddleware(nil, "test")
|
||||
state := &adk.TypedChatModelAgentState[*schema.AgenticMessage]{
|
||||
Messages: []*schema.AgenticMessage{
|
||||
schema.UserAgenticMessage(continuationSessionMarker + "\nold"),
|
||||
schema.UserAgenticMessage("real user request"),
|
||||
schema.UserAgenticMessage(continuationSessionMarker + "\nnew"),
|
||||
},
|
||||
}
|
||||
_, out, err := mw.BeforeModelRewriteState(context.Background(), state, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("BeforeModelRewriteState: %v", err)
|
||||
}
|
||||
if out == state {
|
||||
t.Fatal("expected rewritten state")
|
||||
}
|
||||
if len(out.Messages) != 2 {
|
||||
t.Fatalf("messages = %d, want 2", len(out.Messages))
|
||||
}
|
||||
if strings.Contains(agenticMessageText(out.Messages[0]), continuationSessionMarker) {
|
||||
t.Fatalf("old continuation was not dropped: %#v", out.Messages)
|
||||
}
|
||||
if !strings.Contains(agenticMessageText(out.Messages[1]), "new") {
|
||||
t.Fatalf("latest continuation not retained: %#v", out.Messages)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgenticModelFacingTraceMiddlewareStoresClassicTrace(t *testing.T) {
|
||||
t.Parallel()
|
||||
holder := newModelFacingTraceHolder()
|
||||
mw := newAgenticModelFacingTraceMiddleware(holder)
|
||||
state := &adk.TypedChatModelAgentState[*schema.AgenticMessage]{
|
||||
Messages: []*schema.AgenticMessage{
|
||||
schema.SystemAgenticMessage("instruction"),
|
||||
{
|
||||
Role: schema.AgenticRoleTypeAssistant,
|
||||
ContentBlocks: []*schema.ContentBlock{
|
||||
schema.NewContentBlock(&schema.AssistantGenText{Text: "answer"}),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
if _, _, err := mw.BeforeModelRewriteState(context.Background(), state, nil); err != nil {
|
||||
t.Fatalf("BeforeModelRewriteState: %v", err)
|
||||
}
|
||||
got := holder.Snapshot()
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("trace len = %d, want 2", len(got))
|
||||
}
|
||||
if got[0].Role != schema.System || got[0].Content != "instruction" {
|
||||
t.Fatalf("system trace = %#v", got[0])
|
||||
}
|
||||
if got[1].Role != schema.Assistant || got[1].Content != "answer" {
|
||||
t.Fatalf("assistant trace = %#v", got[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendEinoAgenticChatModelTailMiddlewares(t *testing.T) {
|
||||
t.Parallel()
|
||||
holder := newModelFacingTraceHolder()
|
||||
handlers := appendEinoAgenticChatModelTailMiddlewares(nil, einoChatModelTailConfig{
|
||||
phase: "agentic",
|
||||
trace: holder,
|
||||
})
|
||||
if len(handlers) != 3 {
|
||||
t.Fatalf("handlers = %d, want system + continuation + trace", len(handlers))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package multiagent
|
||||
|
||||
import (
|
||||
"io"
|
||||
|
||||
"github.com/cloudwego/eino/adk"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
// adaptAgenticEventToEinoEvents converts typed AgenticMessage ADK events into
|
||||
// the classic schema.Message events consumed by the existing SSE/MCP drain.
|
||||
func adaptAgenticEventToEinoEvents(ev *adk.TypedAgentEvent[*schema.AgenticMessage]) []*adk.AgentEvent {
|
||||
if ev == nil {
|
||||
return nil
|
||||
}
|
||||
base := func(output *adk.AgentOutput) *adk.AgentEvent {
|
||||
return &adk.AgentEvent{
|
||||
AgentName: ev.AgentName,
|
||||
RunPath: append([]adk.RunStep(nil), ev.RunPath...),
|
||||
Output: output,
|
||||
Action: ev.Action,
|
||||
Err: ev.Err,
|
||||
}
|
||||
}
|
||||
if ev.Output == nil {
|
||||
return []*adk.AgentEvent{base(nil)}
|
||||
}
|
||||
customized := ev.Output.CustomizedOutput
|
||||
mv := ev.Output.MessageOutput
|
||||
if mv == nil {
|
||||
return []*adk.AgentEvent{base(&adk.AgentOutput{CustomizedOutput: customized})}
|
||||
}
|
||||
if mv.IsStreaming {
|
||||
return []*adk.AgentEvent{base(&adk.AgentOutput{
|
||||
MessageOutput: &adk.MessageVariant{
|
||||
IsStreaming: true,
|
||||
MessageStream: agenticStreamToEinoStream(mv.MessageStream),
|
||||
Role: agenticVariantRole(mv),
|
||||
},
|
||||
CustomizedOutput: customized,
|
||||
})}
|
||||
}
|
||||
|
||||
msgs := AgenticMessageToEino(mv.Message)
|
||||
if len(msgs) == 0 {
|
||||
return []*adk.AgentEvent{base(&adk.AgentOutput{CustomizedOutput: customized})}
|
||||
}
|
||||
out := make([]*adk.AgentEvent, 0, len(msgs))
|
||||
for i, msg := range msgs {
|
||||
eventCustomized := any(nil)
|
||||
if i == 0 {
|
||||
eventCustomized = customized
|
||||
}
|
||||
out = append(out, base(&adk.AgentOutput{
|
||||
MessageOutput: &adk.MessageVariant{
|
||||
Message: msg,
|
||||
Role: msg.Role,
|
||||
ToolName: msg.ToolName,
|
||||
},
|
||||
CustomizedOutput: eventCustomized,
|
||||
}))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func agenticStreamToEinoStream(sr *schema.StreamReader[*schema.AgenticMessage]) *schema.StreamReader[*schema.Message] {
|
||||
out, writer := schema.Pipe[*schema.Message](8)
|
||||
go func() {
|
||||
defer writer.Close()
|
||||
if sr == nil {
|
||||
return
|
||||
}
|
||||
defer sr.Close()
|
||||
for {
|
||||
chunk, err := sr.Recv()
|
||||
if err != nil {
|
||||
if err != io.EOF {
|
||||
writer.Send(nil, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
for _, msg := range AgenticMessageToEino(chunk) {
|
||||
if msg != nil && writer.Send(msg, nil) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
return out
|
||||
}
|
||||
|
||||
func agenticVariantRole(mv *adk.TypedMessageVariant[*schema.AgenticMessage]) schema.RoleType {
|
||||
if mv == nil {
|
||||
return schema.Assistant
|
||||
}
|
||||
switch mv.AgenticRole {
|
||||
case schema.AgenticRoleTypeSystem:
|
||||
return schema.System
|
||||
case schema.AgenticRoleTypeUser:
|
||||
// In Agentic ReAct output, user-role events from the graph are local
|
||||
// FunctionToolResult messages emitted by AgenticToolsNode.
|
||||
return schema.Tool
|
||||
default:
|
||||
return schema.Assistant
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
package multiagent
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
"github.com/cloudwego/eino/adk"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
func TestAdaptAgenticEventToEinoEventsAssistantMessage(t *testing.T) {
|
||||
usage := &schema.TokenUsage{PromptTokens: 10, CompletionTokens: 5, TotalTokens: 15}
|
||||
ev := &adk.TypedAgentEvent[*schema.AgenticMessage]{
|
||||
AgentName: "agentic",
|
||||
Output: &adk.TypedAgentOutput[*schema.AgenticMessage]{
|
||||
MessageOutput: &adk.TypedMessageVariant[*schema.AgenticMessage]{
|
||||
Message: &schema.AgenticMessage{
|
||||
Role: schema.AgenticRoleTypeAssistant,
|
||||
ResponseMeta: &schema.AgenticResponseMeta{TokenUsage: usage},
|
||||
ContentBlocks: []*schema.ContentBlock{
|
||||
schema.NewContentBlock(&schema.Reasoning{Text: "think"}),
|
||||
schema.NewContentBlock(&schema.AssistantGenText{Text: "calling"}),
|
||||
schema.NewContentBlock(&schema.FunctionToolCall{CallID: "call-1", Name: "scan", Arguments: `{"host":"127.0.0.1"}`}),
|
||||
},
|
||||
},
|
||||
},
|
||||
CustomizedOutput: "custom",
|
||||
},
|
||||
}
|
||||
|
||||
got := adaptAgenticEventToEinoEvents(ev)
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("events = %d, want 1", len(got))
|
||||
}
|
||||
mv := got[0].Output.MessageOutput
|
||||
if got[0].AgentName != "agentic" || got[0].Output.CustomizedOutput != "custom" {
|
||||
t.Fatalf("event metadata = %#v", got[0])
|
||||
}
|
||||
if mv.Role != schema.Assistant || mv.Message.Role != schema.Assistant {
|
||||
t.Fatalf("role = %q/%q, want assistant", mv.Role, mv.Message.Role)
|
||||
}
|
||||
if mv.Message.Content != "calling" || mv.Message.ReasoningContent != "think" {
|
||||
t.Fatalf("message text = %#v", mv.Message)
|
||||
}
|
||||
if len(mv.Message.ToolCalls) != 1 || mv.Message.ToolCalls[0].ID != "call-1" || mv.Message.ToolCalls[0].Function.Name != "scan" {
|
||||
t.Fatalf("tool calls = %#v", mv.Message.ToolCalls)
|
||||
}
|
||||
if mv.Message.ResponseMeta == nil || mv.Message.ResponseMeta.Usage != usage {
|
||||
t.Fatalf("usage = %#v, want original usage", mv.Message.ResponseMeta)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdaptAgenticEventToEinoEventsPureToolResult(t *testing.T) {
|
||||
ev := &adk.TypedAgentEvent[*schema.AgenticMessage]{
|
||||
Output: &adk.TypedAgentOutput[*schema.AgenticMessage]{
|
||||
MessageOutput: &adk.TypedMessageVariant[*schema.AgenticMessage]{
|
||||
Message: &schema.AgenticMessage{
|
||||
Role: schema.AgenticRoleTypeUser,
|
||||
ContentBlocks: []*schema.ContentBlock{
|
||||
schema.NewContentBlock(&schema.FunctionToolResult{
|
||||
CallID: "call-2",
|
||||
Name: "execute",
|
||||
Content: []*schema.FunctionToolResultContentBlock{{
|
||||
Type: schema.FunctionToolResultContentBlockTypeText,
|
||||
Text: &schema.UserInputText{Text: "done"},
|
||||
}},
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
got := adaptAgenticEventToEinoEvents(ev)
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("events = %d, want 1", len(got))
|
||||
}
|
||||
msg := got[0].Output.MessageOutput.Message
|
||||
if got[0].Output.MessageOutput.Role != schema.Tool || msg.Role != schema.Tool || msg.ToolName != "execute" || msg.ToolCallID != "call-2" || msg.Content != "done" {
|
||||
t.Fatalf("tool event = %#v message=%#v", got[0].Output.MessageOutput, msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdaptAgenticEventToEinoEventsSplitsMixedToolResult(t *testing.T) {
|
||||
ev := &adk.TypedAgentEvent[*schema.AgenticMessage]{
|
||||
Output: &adk.TypedAgentOutput[*schema.AgenticMessage]{
|
||||
MessageOutput: &adk.TypedMessageVariant[*schema.AgenticMessage]{
|
||||
Message: &schema.AgenticMessage{
|
||||
Role: schema.AgenticRoleTypeAssistant,
|
||||
ContentBlocks: []*schema.ContentBlock{
|
||||
schema.NewContentBlock(&schema.AssistantGenText{Text: "text"}),
|
||||
schema.NewContentBlock(&schema.FunctionToolResult{
|
||||
CallID: "call-3",
|
||||
Name: "grep",
|
||||
Content: []*schema.FunctionToolResultContentBlock{{
|
||||
Type: schema.FunctionToolResultContentBlockTypeText,
|
||||
Text: &schema.UserInputText{Text: "match"},
|
||||
}},
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
got := adaptAgenticEventToEinoEvents(ev)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("events = %d, want assistant + tool", len(got))
|
||||
}
|
||||
if got[0].Output.MessageOutput.Role != schema.Assistant || got[0].Output.MessageOutput.Message.Content != "text" {
|
||||
t.Fatalf("assistant event = %#v", got[0].Output.MessageOutput)
|
||||
}
|
||||
if got[1].Output.MessageOutput.Role != schema.Tool || got[1].Output.MessageOutput.Message.ToolName != "grep" {
|
||||
t.Fatalf("tool event = %#v", got[1].Output.MessageOutput)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdaptAgenticEventToEinoEventsStreamingAssistant(t *testing.T) {
|
||||
stream := schema.StreamReaderFromArray([]*schema.AgenticMessage{
|
||||
{
|
||||
Role: schema.AgenticRoleTypeAssistant,
|
||||
ContentBlocks: []*schema.ContentBlock{
|
||||
schema.NewContentBlock(&schema.AssistantGenText{Text: "hel"}),
|
||||
},
|
||||
},
|
||||
{
|
||||
Role: schema.AgenticRoleTypeAssistant,
|
||||
ContentBlocks: []*schema.ContentBlock{
|
||||
schema.NewContentBlock(&schema.AssistantGenText{Text: "lo"}),
|
||||
},
|
||||
},
|
||||
})
|
||||
ev := &adk.TypedAgentEvent[*schema.AgenticMessage]{
|
||||
Output: &adk.TypedAgentOutput[*schema.AgenticMessage]{
|
||||
MessageOutput: &adk.TypedMessageVariant[*schema.AgenticMessage]{
|
||||
IsStreaming: true,
|
||||
MessageStream: stream,
|
||||
AgenticRole: schema.AgenticRoleTypeAssistant,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
got := adaptAgenticEventToEinoEvents(ev)
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("events = %d, want 1", len(got))
|
||||
}
|
||||
mv := got[0].Output.MessageOutput
|
||||
if !mv.IsStreaming || mv.Role != schema.Assistant {
|
||||
t.Fatalf("stream variant = %#v", mv)
|
||||
}
|
||||
first, err := mv.MessageStream.Recv()
|
||||
if err != nil || first.Content != "hel" {
|
||||
t.Fatalf("first = %#v err=%v", first, err)
|
||||
}
|
||||
second, err := mv.MessageStream.Recv()
|
||||
if err != nil || second.Content != "lo" {
|
||||
t.Fatalf("second = %#v err=%v", second, err)
|
||||
}
|
||||
_, err = mv.MessageStream.Recv()
|
||||
if !errors.Is(err, io.EOF) {
|
||||
t.Fatalf("final err = %v, want EOF", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdaptAgenticStreamingToolResultFeedsClassicToolResultHandler(t *testing.T) {
|
||||
stream := schema.StreamReaderFromArray([]*schema.AgenticMessage{
|
||||
{
|
||||
Role: schema.AgenticRoleTypeUser,
|
||||
ContentBlocks: []*schema.ContentBlock{
|
||||
schema.NewContentBlock(&schema.FunctionToolResult{
|
||||
CallID: "call-agentic-stream",
|
||||
Name: "execute",
|
||||
Content: []*schema.FunctionToolResultContentBlock{{
|
||||
Type: schema.FunctionToolResultContentBlockTypeText,
|
||||
Text: &schema.UserInputText{Text: "partial "},
|
||||
}},
|
||||
}),
|
||||
},
|
||||
},
|
||||
{
|
||||
Role: schema.AgenticRoleTypeUser,
|
||||
ContentBlocks: []*schema.ContentBlock{
|
||||
schema.NewContentBlock(&schema.FunctionToolResult{
|
||||
CallID: "call-agentic-stream",
|
||||
Name: "execute",
|
||||
Content: []*schema.FunctionToolResultContentBlock{{
|
||||
Type: schema.FunctionToolResultContentBlockTypeText,
|
||||
Text: &schema.UserInputText{Text: "done"},
|
||||
}},
|
||||
}),
|
||||
},
|
||||
},
|
||||
})
|
||||
ev := &adk.TypedAgentEvent[*schema.AgenticMessage]{
|
||||
AgentName: "agentic",
|
||||
Output: &adk.TypedAgentOutput[*schema.AgenticMessage]{
|
||||
MessageOutput: &adk.TypedMessageVariant[*schema.AgenticMessage]{
|
||||
IsStreaming: true,
|
||||
MessageStream: stream,
|
||||
AgenticRole: schema.AgenticRoleTypeUser,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
got := adaptAgenticEventToEinoEvents(ev)
|
||||
if len(got) != 1 || got[0].Output == nil || got[0].Output.MessageOutput == nil {
|
||||
t.Fatalf("events = %#v", got)
|
||||
}
|
||||
mv := got[0].Output.MessageOutput
|
||||
if !mv.IsStreaming || mv.Role != schema.Tool {
|
||||
t.Fatalf("streaming variant = %#v, want tool stream", mv)
|
||||
}
|
||||
|
||||
var event map[string]interface{}
|
||||
runMessages := newEinoRunMessageAccumulator(nil)
|
||||
emitter := newEinoToolResultProgressEmitter(einoToolResultProgressEmitterConfig{
|
||||
ConversationID: "conv-agentic",
|
||||
Progress: func(eventType, _ string, data interface{}) {
|
||||
if eventType == "tool_result" {
|
||||
event, _ = data.(map[string]interface{})
|
||||
}
|
||||
},
|
||||
})
|
||||
handler := newEinoToolResultEventHandler(einoToolResultEventHandlerConfig{
|
||||
RunMessages: runMessages,
|
||||
Emitter: emitter,
|
||||
})
|
||||
if !handler.HandleStreaming(mv, "agentic") {
|
||||
t.Fatal("agentic streaming tool result was not handled")
|
||||
}
|
||||
if event["toolName"] != "execute" || event["toolCallId"] != "call-agentic-stream" || event["result"] != "partial done" {
|
||||
t.Fatalf("tool result event = %#v", event)
|
||||
}
|
||||
msgs := runMessages.Messages()
|
||||
if len(msgs) != 1 || msgs[0].ToolName != "execute" || msgs[0].ToolCallID != "call-agentic-stream" || msgs[0].Content != "partial done" {
|
||||
t.Fatalf("run messages = %#v", msgs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdaptAgenticEventToEinoEventsPreservesErrorOnlyEvent(t *testing.T) {
|
||||
wantErr := errors.New("boom")
|
||||
ev := &adk.TypedAgentEvent[*schema.AgenticMessage]{AgentName: "agentic", Err: wantErr}
|
||||
|
||||
got := adaptAgenticEventToEinoEvents(ev)
|
||||
if len(got) != 1 || got[0].AgentName != "agentic" || !errors.Is(got[0].Err, wantErr) {
|
||||
t.Fatalf("events = %#v", got)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,401 @@
|
||||
package multiagent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"cyberstrike-ai/internal/config"
|
||||
|
||||
agenticclaude "github.com/cloudwego/eino-ext/components/model/agenticclaude"
|
||||
"github.com/cloudwego/eino/adk"
|
||||
"github.com/cloudwego/eino/components/model"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
func TestNewEinoModelRetryConfigUsesNativeFieldsFirst(t *testing.T) {
|
||||
t.Parallel()
|
||||
mw := &config.MultiAgentEinoMiddlewareConfig{
|
||||
ModelRetryMaxRetries: 2,
|
||||
ModelRetryMaxBackoffSec: 7,
|
||||
RunRetryMaxAttempts: 9,
|
||||
RunRetryMaxBackoffSec: 11,
|
||||
}
|
||||
cfg := newEinoModelRetryConfig(mw, nil, "test")
|
||||
if cfg.MaxRetries != 2 {
|
||||
t.Fatalf("MaxRetries = %d, want 2", cfg.MaxRetries)
|
||||
}
|
||||
backoff := cfg.BackoffFunc(context.Background(), 1)
|
||||
if backoff < 500*time.Millisecond || backoff > 2*time.Second {
|
||||
t.Fatalf("attempt 1 backoff = %v, want first equal-jitter window", backoff)
|
||||
}
|
||||
if got := einoRunRetryMaxBackoffFromConfig(mw); got != 7*time.Second {
|
||||
t.Fatalf("backoff from config = %v, want 7s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEinoModelRetryPolicyRetriesTransientAndEmptyOutput(t *testing.T) {
|
||||
t.Parallel()
|
||||
cfg := newEinoModelRetryConfig(&config.MultiAgentEinoMiddlewareConfig{ModelRetryMaxRetries: 1}, nil, "test")
|
||||
if got := cfg.ShouldRetry(context.Background(), &adk.RetryContext{Err: errors.New("HTTP 429 Too Many Requests")}); got == nil || !got.Retry {
|
||||
t.Fatal("transient model error should retry")
|
||||
}
|
||||
if got := cfg.ShouldRetry(context.Background(), &adk.RetryContext{OutputMessage: schema.AssistantMessage("", nil)}); got == nil || !got.Retry {
|
||||
t.Fatal("empty assistant output should retry")
|
||||
}
|
||||
if got := cfg.ShouldRetry(context.Background(), &adk.RetryContext{OutputMessage: schema.AssistantMessage("", []schema.ToolCall{{ID: "call_1"}})}); got == nil || got.Retry {
|
||||
t.Fatal("assistant tool call output should not be treated as empty")
|
||||
}
|
||||
if got := cfg.ShouldRetry(context.Background(), &adk.RetryContext{Err: errors.New("invalid api key")}); got == nil || got.Retry {
|
||||
t.Fatal("permanent auth error should not retry")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEinoAgenticModelRetryPolicyRetriesTransientAndEmptyOutput(t *testing.T) {
|
||||
t.Parallel()
|
||||
cfg := newEinoAgenticModelRetryConfig(&config.MultiAgentEinoMiddlewareConfig{ModelRetryMaxRetries: 1}, nil, "agentic")
|
||||
if got := cfg.ShouldRetry(context.Background(), &adk.TypedRetryContext[*schema.AgenticMessage]{Err: errors.New("HTTP 429 Too Many Requests")}); got == nil || !got.Retry {
|
||||
t.Fatal("transient agentic model error should retry")
|
||||
}
|
||||
if got := cfg.ShouldRetry(context.Background(), &adk.TypedRetryContext[*schema.AgenticMessage]{
|
||||
OutputMessage: &schema.AgenticMessage{Role: schema.AgenticRoleTypeAssistant},
|
||||
}); got == nil || !got.Retry {
|
||||
t.Fatal("empty agentic assistant output should retry")
|
||||
}
|
||||
if got := cfg.ShouldRetry(context.Background(), &adk.TypedRetryContext[*schema.AgenticMessage]{
|
||||
OutputMessage: &schema.AgenticMessage{
|
||||
Role: schema.AgenticRoleTypeAssistant,
|
||||
ContentBlocks: []*schema.ContentBlock{schema.NewContentBlock(&schema.AssistantGenText{Text: "ok"})},
|
||||
},
|
||||
}); got == nil || got.Retry {
|
||||
t.Fatal("agentic assistant text should not be treated as empty")
|
||||
}
|
||||
if got := cfg.ShouldRetry(context.Background(), &adk.TypedRetryContext[*schema.AgenticMessage]{
|
||||
OutputMessage: &schema.AgenticMessage{
|
||||
Role: schema.AgenticRoleTypeAssistant,
|
||||
ContentBlocks: []*schema.ContentBlock{schema.NewContentBlock(&schema.FunctionToolCall{
|
||||
CallID: "call_1", Name: "search", Arguments: `{"q":"x"}`,
|
||||
})},
|
||||
},
|
||||
}); got == nil || got.Retry {
|
||||
t.Fatal("agentic tool call output should not be treated as empty")
|
||||
}
|
||||
if got := cfg.ShouldRetry(context.Background(), &adk.TypedRetryContext[*schema.AgenticMessage]{Err: errors.New("invalid api key")}); got == nil || got.Retry {
|
||||
t.Fatal("permanent auth error should not retry")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveEinoFailoverChannelsSkipsPrimaryDuplicateAndUnknown(t *testing.T) {
|
||||
t.Parallel()
|
||||
appCfg := &config.Config{
|
||||
OpenAI: config.OpenAIConfig{Provider: "openai", APIKey: "k1", BaseURL: "https://api.example/v1", Model: "primary"},
|
||||
AI: config.AIConfig{Channels: map[string]config.AIChannelConfig{
|
||||
"same": {Provider: "openai", APIKey: "k1", BaseURL: "https://api.example/v1", Model: "primary"},
|
||||
"fb1": {Provider: "openai", APIKey: "k2", BaseURL: "https://api.example/v1", Model: "fallback-1"},
|
||||
"fb2": {Provider: "claude", APIKey: "k3", BaseURL: "https://api.anthropic.com/v1", Model: "claude-sonnet"},
|
||||
}},
|
||||
}
|
||||
got := resolveEinoFailoverChannels(appCfg, &config.MultiAgentEinoMiddlewareConfig{
|
||||
ModelFailoverChannels: []string{"same", "missing", "fb1", "fb1", "fb2"},
|
||||
ModelFailoverMaxRetries: 1,
|
||||
})
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("resolved channels len = %d, want 2 before max cap is applied by config builder", len(got))
|
||||
}
|
||||
if got[0].id != "fb1" || got[1].id != "fb2" {
|
||||
t.Fatalf("resolved channel order = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewEinoModelFailoverConfigBuildsDistinctFallbackModel(t *testing.T) {
|
||||
t.Parallel()
|
||||
appCfg := &config.Config{
|
||||
OpenAI: config.OpenAIConfig{APIKey: "k1", BaseURL: "https://api.example/v1", Model: "primary"},
|
||||
AI: config.AIConfig{Channels: map[string]config.AIChannelConfig{
|
||||
"fb1": {APIKey: "k2", BaseURL: "https://api.example/v1", Model: "fallback-1"},
|
||||
"fb2": {APIKey: "k3", BaseURL: "https://api.example/v1", Model: "fallback-2"},
|
||||
}},
|
||||
}
|
||||
var built []string
|
||||
cfg, err := newEinoModelFailoverConfig(
|
||||
context.Background(),
|
||||
appCfg,
|
||||
&config.MultiAgentEinoMiddlewareConfig{
|
||||
ModelFailoverChannels: []string{"fb1", "fb2"},
|
||||
ModelFailoverMaxRetries: 1,
|
||||
},
|
||||
einoModelModeNormal,
|
||||
func(_ context.Context, oa config.OpenAIConfig, _ einoModelMode) (model.ToolCallingChatModel, error) {
|
||||
built = append(built, oa.Model)
|
||||
return &streamToolCallIndexFakeModel{}, nil
|
||||
},
|
||||
nil,
|
||||
"test",
|
||||
nil,
|
||||
"deep",
|
||||
"conv-1",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("newEinoModelFailoverConfig: %v", err)
|
||||
}
|
||||
if cfg == nil || cfg.MaxRetries != 1 {
|
||||
t.Fatalf("failover cfg = %#v, want max retries 1", cfg)
|
||||
}
|
||||
m, msgs, err := cfg.GetFailoverModel(context.Background(), &adk.FailoverContext[*schema.Message]{FailoverAttempt: 1})
|
||||
if err != nil || m == nil || msgs != nil {
|
||||
t.Fatalf("GetFailoverModel = (%v, %v, %v)", m, msgs, err)
|
||||
}
|
||||
if len(built) != 1 || built[0] != "fallback-1" {
|
||||
t.Fatalf("built models = %v, want [fallback-1]", built)
|
||||
}
|
||||
if !cfg.ShouldFailover(context.Background(), nil, &adk.RetryExhaustedError{LastErr: errors.New("upstream returned 503"), TotalRetries: 4}) {
|
||||
t.Fatal("retry-exhausted transient error should fail over")
|
||||
}
|
||||
if cfg.ShouldFailover(context.Background(), nil, &adk.RetryExhaustedError{LastErr: errors.New("invalid api key"), TotalRetries: 4}) {
|
||||
t.Fatal("retry-exhausted permanent error should not fail over")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewEinoModelFailoverConfigEmitsProgressEvent(t *testing.T) {
|
||||
t.Parallel()
|
||||
appCfg := &config.Config{
|
||||
OpenAI: config.OpenAIConfig{APIKey: "k1", BaseURL: "https://api.example/v1", Model: "primary"},
|
||||
AI: config.AIConfig{Channels: map[string]config.AIChannelConfig{
|
||||
"fb1": {APIKey: "k2", BaseURL: "https://api.example/v1", Model: "fallback-1"},
|
||||
}},
|
||||
}
|
||||
var events []struct {
|
||||
eventType string
|
||||
message string
|
||||
data interface{}
|
||||
}
|
||||
cfg, err := newEinoModelFailoverConfig(
|
||||
context.Background(),
|
||||
appCfg,
|
||||
&config.MultiAgentEinoMiddlewareConfig{ModelFailoverChannels: []string{"fb1"}},
|
||||
einoModelModeNormal,
|
||||
func(_ context.Context, _ config.OpenAIConfig, _ einoModelMode) (model.ToolCallingChatModel, error) {
|
||||
return &streamToolCallIndexFakeModel{}, nil
|
||||
},
|
||||
nil,
|
||||
"test",
|
||||
func(eventType, message string, data interface{}) {
|
||||
events = append(events, struct {
|
||||
eventType string
|
||||
message string
|
||||
data interface{}
|
||||
}{eventType: eventType, message: message, data: data})
|
||||
},
|
||||
"deep",
|
||||
"conv-1",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("newEinoModelFailoverConfig: %v", err)
|
||||
}
|
||||
if _, _, err := cfg.GetFailoverModel(context.Background(), &adk.FailoverContext[*schema.Message]{FailoverAttempt: 1}); err != nil {
|
||||
t.Fatalf("GetFailoverModel: %v", err)
|
||||
}
|
||||
if len(events) != 1 || events[0].eventType != "eino_model_failover" {
|
||||
t.Fatalf("events = %#v, want one eino_model_failover", events)
|
||||
}
|
||||
payload, ok := events[0].data.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("event payload type = %T", events[0].data)
|
||||
}
|
||||
if payload["conversationId"] != "conv-1" || payload["orchestration"] != "deep" || payload["channel"] != "fb1" || payload["model"] != "fallback-1" {
|
||||
t.Fatalf("payload = %#v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewEinoAgenticModelFailoverConfigBuildsDistinctFallbackModel(t *testing.T) {
|
||||
t.Parallel()
|
||||
appCfg := &config.Config{
|
||||
OpenAI: config.OpenAIConfig{Provider: "openai", APIKey: "k1", BaseURL: "https://api.example/v1", Model: "primary"},
|
||||
AI: config.AIConfig{Channels: map[string]config.AIChannelConfig{
|
||||
"fb1": {Provider: "openai", APIKey: "k2", BaseURL: "https://api.example/v1", Model: "fallback-1"},
|
||||
"fb2": {Provider: "openai", APIKey: "k3", BaseURL: "https://api.example/v1", Model: "fallback-2"},
|
||||
}},
|
||||
}
|
||||
var built []string
|
||||
cfg, err := newEinoAgenticModelFailoverConfig(
|
||||
context.Background(),
|
||||
appCfg,
|
||||
&config.MultiAgentEinoMiddlewareConfig{
|
||||
ModelFailoverChannels: []string{"fb1", "fb2"},
|
||||
ModelFailoverMaxRetries: 1,
|
||||
},
|
||||
einoModelModeNormal,
|
||||
func(_ context.Context, oa config.OpenAIConfig, _ einoModelMode) (model.AgenticModel, error) {
|
||||
built = append(built, oa.Model)
|
||||
return &fakeAgenticGateModel{}, nil
|
||||
},
|
||||
nil,
|
||||
"agentic",
|
||||
nil,
|
||||
"eino_single_agentic",
|
||||
"conv-1",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("newEinoAgenticModelFailoverConfig: %v", err)
|
||||
}
|
||||
if cfg == nil || cfg.MaxRetries != 1 {
|
||||
t.Fatalf("agentic failover cfg = %#v, want max retries 1", cfg)
|
||||
}
|
||||
m, msgs, err := cfg.GetFailoverModel(context.Background(), &adk.FailoverContext[*schema.AgenticMessage]{FailoverAttempt: 1})
|
||||
if err != nil || m == nil || msgs != nil {
|
||||
t.Fatalf("GetFailoverModel = (%v, %v, %v)", m, msgs, err)
|
||||
}
|
||||
if len(built) != 1 || built[0] != "fallback-1" {
|
||||
t.Fatalf("built models = %v, want [fallback-1]", built)
|
||||
}
|
||||
if !cfg.ShouldFailover(context.Background(), nil, &adk.RetryExhaustedError{LastErr: errors.New("upstream returned 503"), TotalRetries: 4}) {
|
||||
t.Fatal("retry-exhausted transient agentic error should fail over")
|
||||
}
|
||||
if cfg.ShouldFailover(context.Background(), nil, &adk.RetryExhaustedError{LastErr: errors.New("invalid api key"), TotalRetries: 4}) {
|
||||
t.Fatal("retry-exhausted permanent agentic error should not fail over")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewEinoAgenticModelFailoverConfigEmitsProgressEvent(t *testing.T) {
|
||||
t.Parallel()
|
||||
appCfg := &config.Config{
|
||||
OpenAI: config.OpenAIConfig{Provider: "openai", APIKey: "k1", BaseURL: "https://api.example/v1", Model: "primary"},
|
||||
AI: config.AIConfig{Channels: map[string]config.AIChannelConfig{
|
||||
"fb1": {Provider: "openai", APIKey: "k2", BaseURL: "https://api.example/v1", Model: "fallback-1"},
|
||||
}},
|
||||
}
|
||||
var events []struct {
|
||||
eventType string
|
||||
message string
|
||||
data interface{}
|
||||
}
|
||||
cfg, err := newEinoAgenticModelFailoverConfig(
|
||||
context.Background(),
|
||||
appCfg,
|
||||
&config.MultiAgentEinoMiddlewareConfig{ModelFailoverChannels: []string{"fb1"}},
|
||||
einoModelModeNormal,
|
||||
func(_ context.Context, _ config.OpenAIConfig, _ einoModelMode) (model.AgenticModel, error) {
|
||||
return &fakeAgenticGateModel{}, nil
|
||||
},
|
||||
nil,
|
||||
"agentic",
|
||||
func(eventType, message string, data interface{}) {
|
||||
events = append(events, struct {
|
||||
eventType string
|
||||
message string
|
||||
data interface{}
|
||||
}{eventType: eventType, message: message, data: data})
|
||||
},
|
||||
"eino_single_agentic",
|
||||
"conv-1",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("newEinoAgenticModelFailoverConfig: %v", err)
|
||||
}
|
||||
if _, _, err := cfg.GetFailoverModel(context.Background(), &adk.FailoverContext[*schema.AgenticMessage]{FailoverAttempt: 1}); err != nil {
|
||||
t.Fatalf("GetFailoverModel: %v", err)
|
||||
}
|
||||
if len(events) != 1 || events[0].eventType != "eino_model_failover" {
|
||||
t.Fatalf("events = %#v, want one eino_model_failover", events)
|
||||
}
|
||||
payload, ok := events[0].data.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("event payload type = %T", events[0].data)
|
||||
}
|
||||
if payload["conversationId"] != "conv-1" || payload["orchestration"] != "eino_single_agentic" || payload["channel"] != "fb1" || payload["model"] != "fallback-1" {
|
||||
t.Fatalf("payload = %#v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewEinoAgenticChatModelFactoryBuildsOpenAIBackend(t *testing.T) {
|
||||
t.Parallel()
|
||||
factory := newEinoAgenticChatModelFactory(newEinoBaseHTTPClient(), nil, nil)
|
||||
m, err := factory(context.Background(), config.OpenAIConfig{
|
||||
Provider: "openai",
|
||||
APIKey: "test-key",
|
||||
BaseURL: "https://api.example/v1",
|
||||
Model: "gpt-4o-mini",
|
||||
Reasoning: config.OpenAIReasoningConfig{
|
||||
Profile: "openai_compat",
|
||||
Mode: "on",
|
||||
Effort: "high",
|
||||
},
|
||||
}, einoModelModeNormal)
|
||||
if err != nil {
|
||||
t.Fatalf("agentic factory: %v", err)
|
||||
}
|
||||
if m == nil {
|
||||
t.Fatal("agentic factory returned nil model")
|
||||
}
|
||||
gate := evaluateEinoAgenticModelGate(agenticModelGateFactory(factory, config.OpenAIConfig{
|
||||
Provider: "openai",
|
||||
APIKey: "test-key",
|
||||
BaseURL: "https://api.example/v1",
|
||||
Model: "gpt-4o-mini",
|
||||
}, einoModelModeNormal), einoAgenticRuntimeSupportV0914())
|
||||
if !gate.Ready {
|
||||
t.Fatalf("gate = %#v, want ready with buildable agentic backend", gate)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewEinoAgenticChatModelFactoryBuildsNativeClaudeBackend(t *testing.T) {
|
||||
t.Parallel()
|
||||
factory := newEinoAgenticChatModelFactory(newEinoBaseHTTPClient(), nil, nil)
|
||||
m, err := factory(context.Background(), config.OpenAIConfig{
|
||||
Provider: "claude",
|
||||
APIKey: "test-key",
|
||||
BaseURL: "https://api.anthropic.com/v1",
|
||||
Model: "claude-sonnet-4",
|
||||
}, einoModelModeNormal)
|
||||
if err != nil {
|
||||
t.Fatalf("claude agentic factory: %v", err)
|
||||
}
|
||||
if m == nil {
|
||||
t.Fatal("claude agentic factory returned nil model")
|
||||
}
|
||||
if _, ok := m.(*agenticclaude.Model); !ok {
|
||||
t.Fatalf("claude agentic factory returned %T, want native agenticclaude.Model", m)
|
||||
}
|
||||
gate := evaluateEinoAgenticModelGate(agenticModelGateFactory(factory, config.OpenAIConfig{
|
||||
Provider: "claude",
|
||||
APIKey: "test-key",
|
||||
BaseURL: "https://api.anthropic.com/v1",
|
||||
Model: "claude-sonnet-4",
|
||||
}, einoModelModeNormal), einoAgenticRuntimeSupportV0914())
|
||||
if !gate.Ready {
|
||||
t.Fatalf("gate = %#v, want ready with native Claude backend", gate)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewEinoToolCallingChatModelFactoryUsesNativeClaudeAdapter(t *testing.T) {
|
||||
t.Parallel()
|
||||
factory := newEinoToolCallingChatModelFactory(newEinoBaseHTTPClient(), nil, nil)
|
||||
m, err := factory(context.Background(), config.OpenAIConfig{
|
||||
Provider: "claude",
|
||||
APIKey: "test-key",
|
||||
BaseURL: "https://api.anthropic.com",
|
||||
Model: "claude-sonnet-4",
|
||||
}, einoModelModePlanner)
|
||||
if err != nil {
|
||||
t.Fatalf("claude planner factory: %v", err)
|
||||
}
|
||||
if _, ok := m.(*agenticToolCallingChatModelAdapter); !ok {
|
||||
t.Fatalf("claude planner factory returned %T, want native agentic adapter", m)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEinoNativeRetryErrorsDoNotTriggerRunLevelTransientRetry(t *testing.T) {
|
||||
t.Parallel()
|
||||
err := &adk.WillRetryError{ErrStr: "HTTP 429 Too Many Requests", RetryAttempt: 1}
|
||||
if isEinoTransientRunError(err) {
|
||||
t.Fatal("WillRetryError should be observed, not treated as a run-level transient failure")
|
||||
}
|
||||
exhausted := &adk.RetryExhaustedError{LastErr: errors.New("HTTP 429 Too Many Requests"), TotalRetries: 4}
|
||||
if isEinoTransientRunError(exhausted) {
|
||||
t.Fatal("RetryExhaustedError should not trigger a second run-level retry layer")
|
||||
}
|
||||
if got := unwrapEinoRetryExhausted(exhausted); got == exhausted {
|
||||
t.Fatal("unwrapEinoRetryExhausted should return the underlying model error")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package multiagent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/cloudwego/eino/adk"
|
||||
)
|
||||
|
||||
const (
|
||||
einoNativeCancelImmediateWait = 1200 * time.Millisecond
|
||||
einoNativeCancelSafePointWait = 3500 * time.Millisecond
|
||||
einoNativeCancelSafePointTTL = 3 * time.Second
|
||||
)
|
||||
|
||||
type agentRuntimeCancelRegistrarKey struct{}
|
||||
type agentTurnLoopInterruptRegistrarKey struct{}
|
||||
|
||||
// AgentRuntimeCancelRegistrar binds the currently active Eino ADK cancel hook
|
||||
// into the host task manager. The hook returns true when Eino accepted and
|
||||
// handled the cancel request, so the host can avoid canceling the parent context.
|
||||
type AgentRuntimeCancelRegistrar func(cancel func(error) bool) (unregister func())
|
||||
|
||||
// WithAgentRuntimeCancelRegistrar lets the HTTP/task layer trigger Eino's native
|
||||
// Agent Cancel before falling back to the existing context cancellation path.
|
||||
func WithAgentRuntimeCancelRegistrar(ctx context.Context, registrar AgentRuntimeCancelRegistrar) context.Context {
|
||||
if ctx == nil || registrar == nil {
|
||||
return ctx
|
||||
}
|
||||
return context.WithValue(ctx, agentRuntimeCancelRegistrarKey{}, registrar)
|
||||
}
|
||||
|
||||
func agentRuntimeCancelRegistrarFromContext(ctx context.Context) AgentRuntimeCancelRegistrar {
|
||||
if ctx == nil {
|
||||
return nil
|
||||
}
|
||||
if v, ok := ctx.Value(agentRuntimeCancelRegistrarKey{}).(AgentRuntimeCancelRegistrar); ok {
|
||||
return v
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AgentTurnLoopInterruptRegistrar binds a conversation-level TurnLoop interrupt
|
||||
// pusher into the host task manager. The pusher receives the user supplied note
|
||||
// and returns true when the note was accepted by the loop.
|
||||
type AgentTurnLoopInterruptRegistrar func(push func(note string) bool) (unregister func())
|
||||
|
||||
// WithAgentTurnLoopInterruptRegistrar lets the HTTP/task layer enqueue a user
|
||||
// supplement into an active Eino TurnLoop before falling back to cancellation.
|
||||
func WithAgentTurnLoopInterruptRegistrar(ctx context.Context, registrar AgentTurnLoopInterruptRegistrar) context.Context {
|
||||
if ctx == nil || registrar == nil {
|
||||
return ctx
|
||||
}
|
||||
return context.WithValue(ctx, agentTurnLoopInterruptRegistrarKey{}, registrar)
|
||||
}
|
||||
|
||||
func agentTurnLoopInterruptRegistrarFromContext(ctx context.Context) AgentTurnLoopInterruptRegistrar {
|
||||
if ctx == nil {
|
||||
return nil
|
||||
}
|
||||
if v, ok := ctx.Value(agentTurnLoopInterruptRegistrarKey{}).(AgentTurnLoopInterruptRegistrar); ok {
|
||||
return v
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func requestEinoNativeAgentCancel(cancelFn adk.AgentCancelFunc, cause error) (waitErr error, submitted bool, handled bool) {
|
||||
if cancelFn == nil {
|
||||
return nil, false, false
|
||||
}
|
||||
opts, waitFor := einoNativeCancelOptions(cause)
|
||||
handle, submitted := cancelFn(opts...)
|
||||
if !submitted || handle == nil {
|
||||
return nil, submitted, false
|
||||
}
|
||||
waitCh := make(chan error, 1)
|
||||
go func() {
|
||||
waitCh <- handle.Wait()
|
||||
}()
|
||||
select {
|
||||
case err := <-waitCh:
|
||||
handled := err == nil || errors.Is(err, adk.ErrCancelTimeout) || errors.Is(err, adk.ErrExecutionEnded)
|
||||
return err, submitted, handled
|
||||
case <-time.After(waitFor):
|
||||
return context.DeadlineExceeded, submitted, false
|
||||
}
|
||||
}
|
||||
|
||||
func einoNativeCancelOptions(cause error) ([]adk.AgentCancelOption, time.Duration) {
|
||||
if errors.Is(cause, ErrInterruptContinue) {
|
||||
return []adk.AgentCancelOption{
|
||||
adk.WithAgentCancelMode(adk.CancelAfterChatModel | adk.CancelAfterToolCalls),
|
||||
adk.WithAgentCancelTimeout(einoNativeCancelSafePointTTL),
|
||||
adk.WithRecursive(),
|
||||
}, einoNativeCancelSafePointWait
|
||||
}
|
||||
return []adk.AgentCancelOption{
|
||||
adk.WithAgentCancelMode(adk.CancelImmediate),
|
||||
adk.WithRecursive(),
|
||||
}, einoNativeCancelImmediateWait
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package multiagent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEinoNativeCancelOptionsByCause(t *testing.T) {
|
||||
fullStopOpts, fullStopWait := einoNativeCancelOptions(context.Canceled)
|
||||
if len(fullStopOpts) != 2 {
|
||||
t.Fatalf("full stop options: got %d want 2", len(fullStopOpts))
|
||||
}
|
||||
if fullStopWait != einoNativeCancelImmediateWait {
|
||||
t.Fatalf("full stop wait: got %v want %v", fullStopWait, einoNativeCancelImmediateWait)
|
||||
}
|
||||
|
||||
interruptOpts, interruptWait := einoNativeCancelOptions(ErrInterruptContinue)
|
||||
if len(interruptOpts) != 3 {
|
||||
t.Fatalf("interrupt options: got %d want 3", len(interruptOpts))
|
||||
}
|
||||
if interruptWait != einoNativeCancelSafePointWait {
|
||||
t.Fatalf("interrupt wait: got %v want %v", interruptWait, einoNativeCancelSafePointWait)
|
||||
}
|
||||
if interruptWait <= einoNativeCancelSafePointTTL {
|
||||
t.Fatalf("interrupt wait must allow the Eino safe-point timeout to elapse: wait=%v ttl=%v", interruptWait, einoNativeCancelSafePointTTL)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package multiagent
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/cloudwego/eino/adk"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func emitEinoNativeModelRetryProgress(
|
||||
conversationID, orchMode string,
|
||||
willRetry *adk.WillRetryError,
|
||||
progress func(eventType, message string, data interface{}),
|
||||
logger *zap.Logger,
|
||||
runErr error,
|
||||
) bool {
|
||||
if willRetry == nil {
|
||||
return false
|
||||
}
|
||||
if progress != nil {
|
||||
reason := ""
|
||||
if willRetry.RejectReason() != nil {
|
||||
reason = fmt.Sprint(willRetry.RejectReason())
|
||||
}
|
||||
progress("eino_model_retry", "模型调用遇到临时问题,Eino 正在原生重试…", map[string]interface{}{
|
||||
"conversationId": conversationID,
|
||||
"source": "eino",
|
||||
"orchestration": orchMode,
|
||||
"attempt": willRetry.RetryAttempt,
|
||||
"reason": reason,
|
||||
"error": willRetry.Error(),
|
||||
})
|
||||
}
|
||||
if logger != nil {
|
||||
logger.Warn("eino native model retry event",
|
||||
zap.String("orchestration", orchMode),
|
||||
zap.Int("attempt", willRetry.RetryAttempt),
|
||||
zap.Error(runErr))
|
||||
}
|
||||
return true
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user