mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-09-08 18:59:09 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e0a2f01427 | ||
|
|
baff533196 | ||
|
|
474238cfc5 | ||
|
|
b47f8df3b0 | ||
|
|
a67761e843 | ||
|
|
b41596d51f | ||
|
|
d80e27e950 | ||
|
|
e218316c55 | ||
|
|
a34cab431a | ||
|
|
3bcf4458c5 | ||
|
|
bf761e9cd5 | ||
|
|
d640ef09c8 | ||
|
|
d88cfea761 | ||
|
|
bec2d2faf1 | ||
|
|
c7cc0bc9da | ||
|
|
24d06c5220 | ||
|
|
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 |
+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/
|
||||
|
||||
+6
-6
@@ -10,7 +10,7 @@
|
||||
# ============================================
|
||||
|
||||
# 前端显示的版本号(可选,不填则显示默认版本)
|
||||
version: "v1.7.14"
|
||||
version: "v1.7.17"
|
||||
# 服务器配置
|
||||
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:
|
||||
@@ -107,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 # 可选,留空则使用默认
|
||||
@@ -146,7 +146,7 @@ hitl:
|
||||
# 已决策审计日志保留天数(与 MCP 监控一致;省略默认 90;0 表示不自动清理)
|
||||
retention_days: 90
|
||||
# 按你环境里的真实工具名增删(与侧栏一致、小写不敏感);不需要全局免审批可改为 []
|
||||
tool_whitelist: [read_file, list_dir, glob, grep, tool_search, upsert_project_fact, get_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: | # 审查编辑模式;留空使用内置默认
|
||||
|
||||
@@ -304,7 +304,7 @@ multi_agent:
|
||||
plan_execute_executed_steps_budget_ratio: 0.2 # plan_execute 中 executed_steps 预算比例
|
||||
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_*) 是两套机制
|
||||
checkpoint_dir: "" # 聊天链路不再使用 ADK checkpoint;跨轮模型态统一走 conversations.last_react_*,便于排查 stale context
|
||||
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 耗尽后按顺序切换
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
## 模式选择
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ require (
|
||||
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
|
||||
@@ -24,7 +25,7 @@ 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
|
||||
@@ -46,11 +47,29 @@ require (
|
||||
)
|
||||
|
||||
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
|
||||
@@ -60,6 +79,7 @@ require (
|
||||
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.3 // indirect
|
||||
@@ -71,9 +91,14 @@ require (
|
||||
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
|
||||
@@ -86,10 +111,14 @@ require (
|
||||
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/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
|
||||
@@ -100,17 +129,22 @@ require (
|
||||
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.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.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/exp v0.0.0-20250305212735-054e65f0b394 // indirect
|
||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 // 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
|
||||
|
||||
@@ -1,39 +1,82 @@
|
||||
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.9.14 h1:suNVibjtkPMiW7csFBdBqN3FRG0nRlCqoGKE7t0UDwY=
|
||||
@@ -48,14 +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=
|
||||
@@ -67,12 +113,25 @@ 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=
|
||||
@@ -117,33 +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=
|
||||
@@ -155,6 +230,8 @@ 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=
|
||||
@@ -179,6 +256,8 @@ 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=
|
||||
@@ -195,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=
|
||||
@@ -221,16 +300,23 @@ 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=
|
||||
@@ -238,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=
|
||||
@@ -251,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=
|
||||
@@ -272,14 +364,12 @@ github.com/tencent-connect/botgo v0.2.1 h1:+BrTt9Zh+awL28GWC4g5Na3nQaGRWb0N5IctS
|
||||
github.com/tencent-connect/botgo v0.2.1/go.mod h1:oO1sG9ybhXNickvt+CVym5khwQ+uKhTR+IhTqEfOVsI=
|
||||
github.com/tetratelabs/wazero v1.11.0 h1:+gKemEuKCTevU4d7ZTzlsvgd1uaToIDtlQlmNbwqYhA=
|
||||
github.com/tetratelabs/wazero v1.11.0/go.mod h1:eV28rsN8Q+xwjogd7f4/Pp4xFxO7uOGbLcD/LzB1wiU=
|
||||
github.com/tidwall/gjson v1.9.3 h1:hqzS9wAHMO+KVBBkLxYdkEeeFHuqr95GfClRLKlgK0E=
|
||||
github.com/tidwall/gjson v1.9.3/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
|
||||
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
|
||||
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
||||
github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
|
||||
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
|
||||
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
@@ -302,8 +392,14 @@ 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.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=
|
||||
@@ -332,6 +428,8 @@ 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=
|
||||
@@ -343,22 +441,31 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y
|
||||
golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
|
||||
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=
|
||||
@@ -368,10 +475,12 @@ 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.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.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=
|
||||
@@ -379,6 +488,7 @@ golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJ
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
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=
|
||||
@@ -421,6 +531,10 @@ 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=
|
||||
@@ -435,10 +549,22 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8T
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
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=
|
||||
@@ -446,7 +572,10 @@ google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ
|
||||
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.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||
@@ -462,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)
|
||||
|
||||
@@ -65,12 +65,6 @@ func FromRunResult(db *database.DB, result *multiagent.RunResult, in Input) Deci
|
||||
if len(in.MCPExecutionIDs) == 0 {
|
||||
in.MCPExecutionIDs = result.MCPExecutionIDs
|
||||
}
|
||||
if strings.TrimSpace(in.Status) == "" {
|
||||
in.Status = result.Status
|
||||
}
|
||||
if strings.TrimSpace(in.CompletionReason) == "" {
|
||||
in.CompletionReason = result.CompletionReason
|
||||
}
|
||||
}
|
||||
d := Decide(db, in)
|
||||
if result != nil {
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
|
||||
"cyberstrike-ai/internal/database"
|
||||
"cyberstrike-ai/internal/mcp"
|
||||
"cyberstrike-ai/internal/multiagent"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
@@ -130,3 +131,23 @@ func TestDecideAllowsInformationalAnswerWhenExecutionEvidenceIsNotRequired(t *te
|
||||
t.Fatalf("informational response should finalize when execution evidence is not required: %+v", d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFromRunResultDoesNotReusePreviousFinalizationStatusAsRunStatus(t *testing.T) {
|
||||
db := newDecisionTestDB(t)
|
||||
saveDecisionTestExecution(t, db, "run-slow", mcp.ToolExecutionStatusRunning)
|
||||
result := &multiagent.RunResult{
|
||||
Response: "工具已触发,按用户要求直接总结。",
|
||||
MCPExecutionIDs: []string{"run-slow"},
|
||||
}
|
||||
|
||||
first := FromRunResult(db, result, Input{})
|
||||
if first.Finalizable || first.CompletionReason != ReasonPendingTools || result.Status != StatusInProgress {
|
||||
t.Fatalf("first decision should mark pending and write metadata: decision=%+v result=%+v", first, result)
|
||||
}
|
||||
|
||||
saveDecisionTestExecution(t, db, "run-slow", mcp.ToolExecutionStatusCancelled)
|
||||
second := FromRunResult(db, result, Input{})
|
||||
if !second.Finalizable || !second.Finalized || second.Status != StatusCompleted {
|
||||
t.Fatalf("second decision should ignore previous result status after pending cleanup: decision=%+v result=%+v", second, result)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1027,9 +1027,11 @@ func setupRoutes(
|
||||
protected.DELETE("/batch-tasks/:queueId/tasks/:taskId", agentHandler.DeleteBatchTask)
|
||||
|
||||
// 对话历史
|
||||
protected.GET("/usage/tokens", conversationHandler.GetTokenUsageStats)
|
||||
protected.POST("/conversations", conversationHandler.CreateConversation)
|
||||
protected.GET("/conversations", conversationHandler.ListConversations)
|
||||
protected.GET("/conversations/:id", conversationHandler.GetConversation)
|
||||
protected.GET("/conversations/:id/token-usage", conversationHandler.GetConversationTokenUsageStats)
|
||||
protected.GET("/conversations/:id/plan-tasks", conversationHandler.GetConversationPlanTasks)
|
||||
protected.GET("/messages/:id/process-details", conversationHandler.GetMessageProcessDetails)
|
||||
protected.GET("/process-details/:id", conversationHandler.GetProcessDetail)
|
||||
|
||||
@@ -298,7 +298,8 @@ type MultiAgentEinoMiddlewareConfig struct {
|
||||
PlanExecuteMaxStepResultRunes int `yaml:"plan_execute_max_step_result_runes,omitempty" json:"plan_execute_max_step_result_runes,omitempty"`
|
||||
// PlanExecuteKeepLastSteps keeps only the tail steps in prompt view (default 8).
|
||||
PlanExecuteKeepLastSteps int `yaml:"plan_execute_keep_last_steps,omitempty" json:"plan_execute_keep_last_steps,omitempty"`
|
||||
// CheckpointDir when non-empty enables adk.Runner CheckPointStore (file-backed) for interrupt/resume persistence.
|
||||
// CheckpointDir is retained for config compatibility. Chat agent runs do
|
||||
// not consume it; cross-turn recovery is centralized in conversations.last_react_*.
|
||||
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"`
|
||||
@@ -817,7 +818,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"`
|
||||
@@ -959,13 +960,12 @@ func (c OpenAIConfig) MaxCompletionTokensEffective() int {
|
||||
}
|
||||
|
||||
// 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.
|
||||
// official-compatible API endpoint. The historical name is kept for compatibility;
|
||||
// model names alone are not enough to infer DeepSeek wire behavior behind
|
||||
// OpenAI-compatible gateways.
|
||||
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")
|
||||
return strings.Contains(baseURL, "deepseek")
|
||||
}
|
||||
|
||||
// OpenAIReasoningConfig 全局默认与网关 profile(对话页可通过 ChatRequest.reasoning 覆盖,受 AllowClientReasoning 约束)。
|
||||
|
||||
@@ -1350,6 +1350,8 @@ func (db *DB) AddProcessDetailWithID(messageID, conversationID, eventType, messa
|
||||
return "", fmt.Errorf("添加过程详情失败: %w", err)
|
||||
}
|
||||
|
||||
db.maybeRecordModelTokenUsage(messageID, conversationID, id, eventType, data)
|
||||
|
||||
return id, nil
|
||||
}
|
||||
|
||||
@@ -1538,6 +1540,11 @@ LIMIT 1`, messageID).Scan(&terminalEvent, &terminalCreatedAt)
|
||||
return nil, fmt.Errorf("统计工具调用详情失败: %w", err)
|
||||
}
|
||||
|
||||
pendingToolStatus := "result_missing"
|
||||
if summary.Status == "running" {
|
||||
pendingToolStatus = "running"
|
||||
}
|
||||
|
||||
execRows, err := db.Query(
|
||||
"SELECT id, event_type, data FROM process_details WHERE message_id = ? AND event_type IN ('tool_call', 'tool_result') ORDER BY created_at ASC, rowid ASC",
|
||||
messageID,
|
||||
@@ -1548,12 +1555,12 @@ LIMIT 1`, messageID).Scan(&terminalEvent, &terminalCreatedAt)
|
||||
seenExecIDs := make(map[string]bool)
|
||||
// A provider may reuse a fallback toolCallId across streaming rounds. Keep a
|
||||
// FIFO per ID instead of a single index so every persisted call gets at most
|
||||
// one result. Results without a stable ID are kept separate instead of being
|
||||
// guessed by order; showing no link is safer than linking to the wrong tool.
|
||||
// one result. ID-less results still attach to an unmatched call with the same
|
||||
// tool name (parallel nmap 1/2, 2/2 often lose one ID); different tools stay
|
||||
// unlinked so a leftover preview cannot steal another call's slot.
|
||||
toolIndexesByCallID := make(map[string][]int)
|
||||
lastMatchedToolIndexByCallID := make(map[string]int)
|
||||
matchedToolIndexes := make([]bool, 0)
|
||||
nextUnmatchedToolIdx := 0
|
||||
for execRows.Next() {
|
||||
var detailID string
|
||||
var eventType string
|
||||
@@ -1569,33 +1576,19 @@ LIMIT 1`, messageID).Scan(&terminalEvent, &terminalCreatedAt)
|
||||
if err := json.Unmarshal([]byte(dataJSON), &payload); err != nil {
|
||||
continue
|
||||
}
|
||||
toolName, _ := payload["toolName"].(string)
|
||||
toolName = strings.TrimSpace(toolName)
|
||||
toolCallID, _ := payload["toolCallId"].(string)
|
||||
toolCallID = strings.TrimSpace(toolCallID)
|
||||
execID, _ := payload["executionId"].(string)
|
||||
execID = strings.TrimSpace(execID)
|
||||
status := ""
|
||||
if eventType == "tool_result" {
|
||||
if success, ok := payload["success"].(bool); ok {
|
||||
if success {
|
||||
status = "completed"
|
||||
} else {
|
||||
status = "failed"
|
||||
}
|
||||
} else if isErr, ok := payload["isError"].(bool); ok && isErr {
|
||||
status = "failed"
|
||||
}
|
||||
}
|
||||
toolName := processDetailString(payload, "toolName")
|
||||
toolCallID := processDetailString(payload, "toolCallId")
|
||||
execID := processDetailString(payload, "executionId")
|
||||
status := toolResultStatusFromPayload(payload, eventType)
|
||||
if eventType == "tool_call" {
|
||||
summary.ToolExecutions = append(summary.ToolExecutions, ProcessDetailsToolExecution{
|
||||
ProcessDetailID: strings.TrimSpace(detailID),
|
||||
ToolName: toolName,
|
||||
ToolCallID: toolCallID,
|
||||
// This summary is reconstructed from persisted history, not live
|
||||
// execution state. Until a matching result is found the honest state
|
||||
// is "result_missing", never "running".
|
||||
Status: "result_missing",
|
||||
// This summary is reconstructed from persisted history. For an
|
||||
// active assistant turn, a missing result means the call is still
|
||||
// pending; after the turn is terminal it is genuinely incomplete.
|
||||
Status: pendingToolStatus,
|
||||
})
|
||||
matchedToolIndexes = append(matchedToolIndexes, false)
|
||||
if toolCallID != "" {
|
||||
@@ -1603,36 +1596,14 @@ LIMIT 1`, messageID).Scan(&terminalEvent, &terminalCreatedAt)
|
||||
}
|
||||
}
|
||||
if eventType == "tool_result" {
|
||||
idx := -1
|
||||
if toolCallID != "" {
|
||||
queue := toolIndexesByCallID[toolCallID]
|
||||
for len(queue) > 0 {
|
||||
candidate := queue[0]
|
||||
queue = queue[1:]
|
||||
if candidate >= 0 && candidate < len(matchedToolIndexes) && !matchedToolIndexes[candidate] {
|
||||
idx = candidate
|
||||
break
|
||||
}
|
||||
}
|
||||
toolIndexesByCallID[toolCallID] = queue
|
||||
if idx < 0 {
|
||||
// Multiple persisted result events for one call (for example an
|
||||
// agent-facing reduced result replacing an earlier preview) update
|
||||
// that call instead of consuming an unrelated FIFO entry.
|
||||
if previous, ok := lastMatchedToolIndexByCallID[toolCallID]; ok {
|
||||
idx = previous
|
||||
}
|
||||
}
|
||||
}
|
||||
if idx < 0 && toolCallID != "" {
|
||||
for nextUnmatchedToolIdx < len(matchedToolIndexes) && matchedToolIndexes[nextUnmatchedToolIdx] {
|
||||
nextUnmatchedToolIdx++
|
||||
}
|
||||
if nextUnmatchedToolIdx < len(matchedToolIndexes) {
|
||||
idx = nextUnmatchedToolIdx
|
||||
nextUnmatchedToolIdx++
|
||||
}
|
||||
}
|
||||
idx := matchToolExecutionIndex(
|
||||
summary.ToolExecutions,
|
||||
matchedToolIndexes,
|
||||
toolCallID,
|
||||
toolName,
|
||||
toolIndexesByCallID,
|
||||
lastMatchedToolIndexByCallID,
|
||||
)
|
||||
if idx >= 0 && idx < len(summary.ToolExecutions) {
|
||||
matchedToolIndexes[idx] = true
|
||||
if toolCallID != "" {
|
||||
@@ -1648,6 +1619,8 @@ LIMIT 1`, messageID).Scan(&terminalEvent, &terminalCreatedAt)
|
||||
summary.ToolExecutions[idx].ExecutionID = execID
|
||||
if status != "" {
|
||||
summary.ToolExecutions[idx].Status = status
|
||||
} else {
|
||||
summary.ToolExecutions[idx].Status = "completed"
|
||||
}
|
||||
} else {
|
||||
summary.ToolExecutions = append(summary.ToolExecutions, ProcessDetailsToolExecution{
|
||||
@@ -1670,6 +1643,7 @@ LIMIT 1`, messageID).Scan(&terminalEvent, &terminalCreatedAt)
|
||||
return nil, fmt.Errorf("遍历工具执行摘要失败: %w", err)
|
||||
}
|
||||
execRows.Close()
|
||||
db.applyPersistedToolExecutionStatuses(summary.ToolExecutions)
|
||||
|
||||
rows, err := db.Query(
|
||||
"SELECT data FROM process_details WHERE message_id = ? AND event_type = 'iteration' ORDER BY created_at ASC, rowid ASC",
|
||||
@@ -1704,6 +1678,100 @@ LIMIT 1`, messageID).Scan(&terminalEvent, &terminalCreatedAt)
|
||||
return summary, nil
|
||||
}
|
||||
|
||||
func processDetailString(payload map[string]interface{}, key string) string {
|
||||
if payload == nil {
|
||||
return ""
|
||||
}
|
||||
v, ok := payload[key]
|
||||
if !ok || v == nil {
|
||||
return ""
|
||||
}
|
||||
s := strings.TrimSpace(fmt.Sprint(v))
|
||||
if s == "" || s == "<nil>" {
|
||||
return ""
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func toolResultStatusFromPayload(payload map[string]interface{}, eventType string) string {
|
||||
if eventType != "tool_result" {
|
||||
return ""
|
||||
}
|
||||
if status := processDetailString(payload, "status"); strings.EqualFold(status, "background_running") {
|
||||
return "background_running"
|
||||
}
|
||||
if success, ok := payload["success"].(bool); ok {
|
||||
if success {
|
||||
return "completed"
|
||||
}
|
||||
return "failed"
|
||||
}
|
||||
if isErr, ok := payload["isError"].(bool); ok && isErr {
|
||||
return "failed"
|
||||
}
|
||||
return "completed"
|
||||
}
|
||||
|
||||
func (db *DB) applyPersistedToolExecutionStatuses(executions []ProcessDetailsToolExecution) {
|
||||
for i := range executions {
|
||||
execID := strings.TrimSpace(executions[i].ExecutionID)
|
||||
if execID == "" {
|
||||
continue
|
||||
}
|
||||
var status string
|
||||
if err := db.QueryRow(`SELECT status FROM tool_executions WHERE id = ?`, execID).Scan(&status); err != nil {
|
||||
continue
|
||||
}
|
||||
status = strings.ToLower(strings.TrimSpace(status))
|
||||
if status == "" {
|
||||
continue
|
||||
}
|
||||
executions[i].Status = status
|
||||
}
|
||||
}
|
||||
|
||||
func matchToolExecutionIndex(
|
||||
executions []ProcessDetailsToolExecution,
|
||||
matched []bool,
|
||||
toolCallID, toolName string,
|
||||
toolIndexesByCallID map[string][]int,
|
||||
lastMatchedToolIndexByCallID map[string]int,
|
||||
) int {
|
||||
if toolCallID != "" {
|
||||
queue := toolIndexesByCallID[toolCallID]
|
||||
for len(queue) > 0 {
|
||||
candidate := queue[0]
|
||||
queue = queue[1:]
|
||||
if candidate >= 0 && candidate < len(matched) && !matched[candidate] {
|
||||
toolIndexesByCallID[toolCallID] = queue
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
toolIndexesByCallID[toolCallID] = queue
|
||||
if previous, ok := lastMatchedToolIndexByCallID[toolCallID]; ok {
|
||||
return previous
|
||||
}
|
||||
}
|
||||
if toolName != "" {
|
||||
for i := range matched {
|
||||
if matched[i] {
|
||||
continue
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(executions[i].ToolName), toolName) {
|
||||
return i
|
||||
}
|
||||
}
|
||||
}
|
||||
if toolCallID != "" {
|
||||
for i := range matched {
|
||||
if !matched[i] {
|
||||
return i
|
||||
}
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// GetProcessDetailsPage 分页获取消息的过程详情(按时间升序)。
|
||||
func (db *DB) GetProcessDetailsPage(messageID string, limit, offset int) ([]ProcessDetail, int, error) {
|
||||
var total int
|
||||
|
||||
@@ -216,6 +216,32 @@ func (db *DB) initTables() error {
|
||||
FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE CASCADE
|
||||
);`
|
||||
|
||||
// 创建模型 Token 用量表:process_details 负责时间线回放,本表负责结构化聚合统计。
|
||||
createModelTokenUsageTable := `
|
||||
CREATE TABLE IF NOT EXISTS model_token_usage (
|
||||
id TEXT PRIMARY KEY,
|
||||
process_detail_id TEXT NOT NULL UNIQUE,
|
||||
message_id TEXT NOT NULL,
|
||||
conversation_id TEXT NOT NULL,
|
||||
project_id TEXT,
|
||||
source TEXT NOT NULL DEFAULT '',
|
||||
orchestration TEXT NOT NULL DEFAULT '',
|
||||
reason TEXT NOT NULL DEFAULT '',
|
||||
model TEXT NOT NULL DEFAULT '',
|
||||
model_calls INTEGER NOT NULL DEFAULT 0,
|
||||
prompt_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
completion_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
total_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
cached_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
reasoning_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
created_at DATETIME NOT NULL,
|
||||
updated_at DATETIME NOT NULL,
|
||||
FOREIGN KEY (process_detail_id) REFERENCES process_details(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE SET NULL
|
||||
);`
|
||||
|
||||
// 创建工具执行记录表
|
||||
createToolExecutionsTable := `
|
||||
CREATE TABLE IF NOT EXISTS tool_executions (
|
||||
@@ -719,6 +745,10 @@ func (db *DB) initTables() error {
|
||||
CREATE INDEX IF NOT EXISTS idx_conversations_updated_at ON conversations(updated_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_process_details_message_id ON process_details(message_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_process_details_conversation_id ON process_details(conversation_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_model_token_usage_created_at ON model_token_usage(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_model_token_usage_conversation ON model_token_usage(conversation_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_model_token_usage_project ON model_token_usage(project_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_model_token_usage_model ON model_token_usage(model);
|
||||
CREATE INDEX IF NOT EXISTS idx_tool_executions_tool_name ON tool_executions(tool_name);
|
||||
CREATE INDEX IF NOT EXISTS idx_tool_executions_start_time ON tool_executions(start_time);
|
||||
CREATE INDEX IF NOT EXISTS idx_tool_executions_status ON tool_executions(status);
|
||||
@@ -806,6 +836,10 @@ func (db *DB) initTables() error {
|
||||
return fmt.Errorf("创建process_details表失败: %w", err)
|
||||
}
|
||||
|
||||
if _, err := db.Exec(createModelTokenUsageTable); err != nil {
|
||||
return fmt.Errorf("创建model_token_usage表失败: %w", err)
|
||||
}
|
||||
|
||||
if _, err := db.Exec(createToolExecutionsTable); err != nil {
|
||||
return fmt.Errorf("创建tool_executions表失败: %w", err)
|
||||
}
|
||||
@@ -981,6 +1015,10 @@ func (db *DB) initTables() error {
|
||||
if _, err := db.Exec(createIndexes); err != nil {
|
||||
return fmt.Errorf("创建索引失败: %w", err)
|
||||
}
|
||||
|
||||
if err := db.BackfillModelTokenUsageFromProcessDetails(); err != nil {
|
||||
return fmt.Errorf("回填模型Token用量失败: %w", err)
|
||||
}
|
||||
db.logger.Debug("数据库表初始化完成")
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,485 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
const modelTokenUsageEventType = "eino_usage_summary"
|
||||
|
||||
// ModelTokenUsage records one model-usage summary emitted by an Agent run.
|
||||
type ModelTokenUsage struct {
|
||||
ID string `json:"id"`
|
||||
ProcessDetailID string `json:"processDetailId"`
|
||||
MessageID string `json:"messageId"`
|
||||
ConversationID string `json:"conversationId"`
|
||||
ProjectID string `json:"projectId,omitempty"`
|
||||
Source string `json:"source"`
|
||||
Orchestration string `json:"orchestration"`
|
||||
Reason string `json:"reason"`
|
||||
Model string `json:"model,omitempty"`
|
||||
ModelCalls int64 `json:"modelCalls"`
|
||||
PromptTokens int64 `json:"promptTokens"`
|
||||
CompletionTokens int64 `json:"completionTokens"`
|
||||
TotalTokens int64 `json:"totalTokens"`
|
||||
CachedTokens int64 `json:"cachedTokens"`
|
||||
ReasoningTokens int64 `json:"reasoningTokens"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// ModelTokenUsageSummary is the aggregate shape used by dashboard and APIs.
|
||||
type ModelTokenUsageSummary struct {
|
||||
Events int64 `json:"events"`
|
||||
ModelCalls int64 `json:"modelCalls"`
|
||||
PromptTokens int64 `json:"promptTokens"`
|
||||
CompletionTokens int64 `json:"completionTokens"`
|
||||
TotalTokens int64 `json:"totalTokens"`
|
||||
CachedTokens int64 `json:"cachedTokens"`
|
||||
ReasoningTokens int64 `json:"reasoningTokens"`
|
||||
}
|
||||
|
||||
// ModelTokenUsageBreakdown is a grouped aggregate row.
|
||||
type ModelTokenUsageBreakdown struct {
|
||||
Key string `json:"key"`
|
||||
Label string `json:"label,omitempty"`
|
||||
Events int64 `json:"events"`
|
||||
ModelCalls int64 `json:"modelCalls"`
|
||||
PromptTokens int64 `json:"promptTokens"`
|
||||
CompletionTokens int64 `json:"completionTokens"`
|
||||
TotalTokens int64 `json:"totalTokens"`
|
||||
CachedTokens int64 `json:"cachedTokens"`
|
||||
ReasoningTokens int64 `json:"reasoningTokens"`
|
||||
}
|
||||
|
||||
// ModelTokenUsageStats is a compact API response for usage dashboards.
|
||||
type ModelTokenUsageStats struct {
|
||||
Summary ModelTokenUsageSummary `json:"summary"`
|
||||
Today ModelTokenUsageSummary `json:"today"`
|
||||
ByDay []ModelTokenUsageBreakdown `json:"byDay"`
|
||||
ByModel []ModelTokenUsageBreakdown `json:"byModel"`
|
||||
ByOrchestration []ModelTokenUsageBreakdown `json:"byOrchestration"`
|
||||
Recent []ModelTokenUsage `json:"recent"`
|
||||
}
|
||||
|
||||
// ModelTokenUsageFilter scopes usage queries.
|
||||
type ModelTokenUsageFilter struct {
|
||||
ConversationID string
|
||||
ProjectID string
|
||||
Since time.Time
|
||||
Until time.Time
|
||||
Days int
|
||||
Access RBACListAccess
|
||||
Limit int
|
||||
}
|
||||
|
||||
func modelTokenUsageFromProcessDetail(messageID, conversationID, processDetailID string, data interface{}) (ModelTokenUsage, bool) {
|
||||
m := mapFromUsageData(data)
|
||||
if len(m) == 0 {
|
||||
return ModelTokenUsage{}, false
|
||||
}
|
||||
usage := ModelTokenUsage{
|
||||
ID: uuid.New().String(),
|
||||
ProcessDetailID: strings.TrimSpace(processDetailID),
|
||||
MessageID: strings.TrimSpace(messageID),
|
||||
ConversationID: strings.TrimSpace(conversationID),
|
||||
Source: strings.TrimSpace(fmt.Sprint(m["source"])),
|
||||
Orchestration: strings.TrimSpace(fmt.Sprint(m["orchestration"])),
|
||||
Reason: strings.TrimSpace(fmt.Sprint(m["reason"])),
|
||||
Model: strings.TrimSpace(fmt.Sprint(m["model"])),
|
||||
ModelCalls: usageInt64(m["modelCalls"]),
|
||||
PromptTokens: usageInt64(m["promptTokens"]),
|
||||
CompletionTokens: usageInt64(m["completionTokens"]),
|
||||
TotalTokens: usageInt64(m["totalTokens"]),
|
||||
CachedTokens: usageInt64(m["cachedTokens"]),
|
||||
ReasoningTokens: usageInt64(m["reasoningTokens"]),
|
||||
}
|
||||
if usage.TotalTokens == 0 && (usage.PromptTokens > 0 || usage.CompletionTokens > 0) {
|
||||
usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens
|
||||
}
|
||||
if usage.ProcessDetailID == "" || usage.MessageID == "" || usage.ConversationID == "" {
|
||||
return ModelTokenUsage{}, false
|
||||
}
|
||||
if usage.ModelCalls == 0 && usage.TotalTokens == 0 && usage.PromptTokens == 0 && usage.CompletionTokens == 0 && usage.CachedTokens == 0 && usage.ReasoningTokens == 0 {
|
||||
return ModelTokenUsage{}, false
|
||||
}
|
||||
return usage, true
|
||||
}
|
||||
|
||||
func mapFromUsageData(data interface{}) map[string]interface{} {
|
||||
switch v := data.(type) {
|
||||
case nil:
|
||||
return nil
|
||||
case map[string]interface{}:
|
||||
return v
|
||||
case string:
|
||||
var m map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(v), &m); err == nil {
|
||||
return m
|
||||
}
|
||||
case []byte:
|
||||
var m map[string]interface{}
|
||||
if err := json.Unmarshal(v, &m); err == nil {
|
||||
return m
|
||||
}
|
||||
default:
|
||||
raw, err := json.Marshal(v)
|
||||
if err == nil {
|
||||
var m map[string]interface{}
|
||||
if err := json.Unmarshal(raw, &m); err == nil {
|
||||
return m
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func usageInt64(v interface{}) int64 {
|
||||
switch n := v.(type) {
|
||||
case int:
|
||||
return int64(n)
|
||||
case int8:
|
||||
return int64(n)
|
||||
case int16:
|
||||
return int64(n)
|
||||
case int32:
|
||||
return int64(n)
|
||||
case int64:
|
||||
return n
|
||||
case uint:
|
||||
return int64(n)
|
||||
case uint8:
|
||||
return int64(n)
|
||||
case uint16:
|
||||
return int64(n)
|
||||
case uint32:
|
||||
return int64(n)
|
||||
case uint64:
|
||||
if n > math.MaxInt64 {
|
||||
return math.MaxInt64
|
||||
}
|
||||
return int64(n)
|
||||
case float32:
|
||||
return int64(n)
|
||||
case float64:
|
||||
return int64(n)
|
||||
case json.Number:
|
||||
i, _ := n.Int64()
|
||||
return i
|
||||
case string:
|
||||
i, _ := strconv.ParseInt(strings.TrimSpace(n), 10, 64)
|
||||
return i
|
||||
default:
|
||||
i, _ := strconv.ParseInt(strings.TrimSpace(fmt.Sprint(v)), 10, 64)
|
||||
return i
|
||||
}
|
||||
}
|
||||
|
||||
func (db *DB) maybeRecordModelTokenUsage(messageID, conversationID, processDetailID, eventType string, data interface{}) {
|
||||
if db == nil || eventType != modelTokenUsageEventType {
|
||||
return
|
||||
}
|
||||
usage, ok := modelTokenUsageFromProcessDetail(messageID, conversationID, processDetailID, data)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := db.UpsertModelTokenUsage(usage); err != nil && db.logger != nil {
|
||||
db.logger.Warn("保存模型Token用量失败",
|
||||
zap.String("processDetailId", processDetailID),
|
||||
zap.String("conversationId", conversationID),
|
||||
zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
// UpsertModelTokenUsage persists usage with process_detail_id idempotency.
|
||||
func (db *DB) UpsertModelTokenUsage(usage ModelTokenUsage) error {
|
||||
if db == nil {
|
||||
return fmt.Errorf("database is nil")
|
||||
}
|
||||
now := time.Now()
|
||||
createdAt := usage.CreatedAt
|
||||
if createdAt.IsZero() {
|
||||
createdAt = now
|
||||
}
|
||||
if usage.ID == "" {
|
||||
usage.ID = uuid.New().String()
|
||||
}
|
||||
var projectID sql.NullString
|
||||
if err := db.QueryRow(`SELECT project_id FROM conversations WHERE id = ?`, usage.ConversationID).Scan(&projectID); err != nil && err != sql.ErrNoRows {
|
||||
return fmt.Errorf("查询对话项目失败: %w", err)
|
||||
}
|
||||
projectValue := interface{}(nil)
|
||||
if projectID.Valid && strings.TrimSpace(projectID.String) != "" {
|
||||
projectValue = strings.TrimSpace(projectID.String)
|
||||
}
|
||||
_, err := db.Exec(`
|
||||
INSERT INTO model_token_usage (
|
||||
id, process_detail_id, message_id, conversation_id, project_id,
|
||||
source, orchestration, reason, model, model_calls,
|
||||
prompt_tokens, completion_tokens, total_tokens, cached_tokens, reasoning_tokens,
|
||||
created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(process_detail_id) DO UPDATE SET
|
||||
message_id = excluded.message_id,
|
||||
conversation_id = excluded.conversation_id,
|
||||
project_id = excluded.project_id,
|
||||
source = excluded.source,
|
||||
orchestration = excluded.orchestration,
|
||||
reason = excluded.reason,
|
||||
model = excluded.model,
|
||||
model_calls = excluded.model_calls,
|
||||
prompt_tokens = excluded.prompt_tokens,
|
||||
completion_tokens = excluded.completion_tokens,
|
||||
total_tokens = excluded.total_tokens,
|
||||
cached_tokens = excluded.cached_tokens,
|
||||
reasoning_tokens = excluded.reasoning_tokens,
|
||||
created_at = excluded.created_at,
|
||||
updated_at = excluded.updated_at`,
|
||||
usage.ID, usage.ProcessDetailID, usage.MessageID, usage.ConversationID, projectValue,
|
||||
usage.Source, usage.Orchestration, usage.Reason, usage.Model, usage.ModelCalls,
|
||||
usage.PromptTokens, usage.CompletionTokens, usage.TotalTokens, usage.CachedTokens, usage.ReasoningTokens,
|
||||
createdAt, now,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("写入模型Token用量失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// BackfillModelTokenUsageFromProcessDetails makes existing timeline usage events queryable.
|
||||
func (db *DB) BackfillModelTokenUsageFromProcessDetails() error {
|
||||
if db == nil {
|
||||
return nil
|
||||
}
|
||||
rows, err := db.Query(`
|
||||
SELECT pd.id, pd.message_id, pd.conversation_id, pd.data, pd.created_at
|
||||
FROM process_details pd
|
||||
LEFT JOIN model_token_usage mtu ON mtu.process_detail_id = pd.id
|
||||
WHERE pd.event_type = ?
|
||||
AND (mtu.id IS NULL OR mtu.created_at != pd.created_at)`, modelTokenUsageEventType)
|
||||
if err != nil {
|
||||
return fmt.Errorf("查询历史模型Token用量失败: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var processDetailID, messageID, conversationID string
|
||||
var data sql.NullString
|
||||
var createdAt string
|
||||
if err := rows.Scan(&processDetailID, &messageID, &conversationID, &data, &createdAt); err != nil {
|
||||
return fmt.Errorf("扫描历史模型Token用量失败: %w", err)
|
||||
}
|
||||
if !data.Valid {
|
||||
continue
|
||||
}
|
||||
usage, ok := modelTokenUsageFromProcessDetail(messageID, conversationID, processDetailID, data.String)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
usage.CreatedAt = parseModelTokenUsageTime(createdAt)
|
||||
if err := db.UpsertModelTokenUsage(usage); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return fmt.Errorf("遍历历史模型Token用量失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *DB) GetModelTokenUsageStats(filter ModelTokenUsageFilter) (*ModelTokenUsageStats, error) {
|
||||
if db == nil {
|
||||
return nil, fmt.Errorf("database is nil")
|
||||
}
|
||||
if filter.Days <= 0 {
|
||||
filter.Days = 7
|
||||
}
|
||||
if filter.Limit <= 0 {
|
||||
filter.Limit = 10
|
||||
}
|
||||
where, args := buildModelTokenUsageWhere(filter, "mtu", "c")
|
||||
summary, err := db.queryModelTokenUsageSummary("SELECT "+modelTokenUsageSummarySelect("mtu")+" FROM model_token_usage mtu JOIN conversations c ON c.id = mtu.conversation_id"+where, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
todayFilter := filter
|
||||
now := time.Now()
|
||||
todayFilter.Since = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||
todayWhere, todayArgs := buildModelTokenUsageWhere(todayFilter, "mtu", "c")
|
||||
today, err := db.queryModelTokenUsageSummary("SELECT "+modelTokenUsageSummarySelect("mtu")+" FROM model_token_usage mtu JOIN conversations c ON c.id = mtu.conversation_id"+todayWhere, todayArgs...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
byDay, err := db.queryModelTokenUsageBreakdown(
|
||||
"SELECT date(mtu.created_at) AS k, date(mtu.created_at) AS label, "+modelTokenUsageSummarySelect("mtu")+" FROM model_token_usage mtu JOIN conversations c ON c.id = mtu.conversation_id"+where+" GROUP BY date(mtu.created_at) ORDER BY k DESC LIMIT ?",
|
||||
append(args, filter.Days)...,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
byModel, err := db.queryModelTokenUsageBreakdown(
|
||||
"SELECT COALESCE(NULLIF(TRIM(mtu.model), ''), 'unknown') AS k, COALESCE(NULLIF(TRIM(mtu.model), ''), 'Unknown') AS label, "+modelTokenUsageSummarySelect("mtu")+" FROM model_token_usage mtu JOIN conversations c ON c.id = mtu.conversation_id"+where+" GROUP BY k ORDER BY SUM(mtu.total_tokens) DESC LIMIT ?",
|
||||
append(args, filter.Limit)...,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
byOrch, err := db.queryModelTokenUsageBreakdown(
|
||||
"SELECT COALESCE(NULLIF(TRIM(mtu.orchestration), ''), 'unknown') AS k, COALESCE(NULLIF(TRIM(mtu.orchestration), ''), 'Unknown') AS label, "+modelTokenUsageSummarySelect("mtu")+" FROM model_token_usage mtu JOIN conversations c ON c.id = mtu.conversation_id"+where+" GROUP BY k ORDER BY SUM(mtu.total_tokens) DESC LIMIT ?",
|
||||
append(args, filter.Limit)...,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
recent, err := db.ListModelTokenUsage(filter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &ModelTokenUsageStats{
|
||||
Summary: summary,
|
||||
Today: today,
|
||||
ByDay: byDay,
|
||||
ByModel: byModel,
|
||||
ByOrchestration: byOrch,
|
||||
Recent: recent,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func modelTokenUsageSummarySelect(alias string) string {
|
||||
p := ""
|
||||
if alias != "" {
|
||||
p = alias + "."
|
||||
}
|
||||
return fmt.Sprintf(`COUNT(%sid),
|
||||
COALESCE(SUM(%smodel_calls), 0),
|
||||
COALESCE(SUM(%sprompt_tokens), 0),
|
||||
COALESCE(SUM(%scompletion_tokens), 0),
|
||||
COALESCE(SUM(%stotal_tokens), 0),
|
||||
COALESCE(SUM(%scached_tokens), 0),
|
||||
COALESCE(SUM(%sreasoning_tokens), 0)`, p, p, p, p, p, p, p)
|
||||
}
|
||||
|
||||
func buildModelTokenUsageWhere(filter ModelTokenUsageFilter, usageAlias, convAlias string) (string, []interface{}) {
|
||||
where := " WHERE 1=1"
|
||||
args := []interface{}{}
|
||||
uPrefix := ""
|
||||
if usageAlias != "" {
|
||||
uPrefix = usageAlias + "."
|
||||
}
|
||||
if cid := strings.TrimSpace(filter.ConversationID); cid != "" {
|
||||
where += " AND " + uPrefix + "conversation_id = ?"
|
||||
args = append(args, cid)
|
||||
}
|
||||
where, args = appendConversationProjectFilter(where, args, filter.ProjectID, usageAlias)
|
||||
if !filter.Since.IsZero() {
|
||||
where += " AND " + uPrefix + "created_at >= ?"
|
||||
args = append(args, filter.Since)
|
||||
}
|
||||
if !filter.Until.IsZero() {
|
||||
where += " AND " + uPrefix + "created_at <= ?"
|
||||
args = append(args, filter.Until)
|
||||
}
|
||||
where, args = appendConversationAccessFilter(where, args, filter.Access.UserID, filter.Access.Scope, convAlias)
|
||||
return where, args
|
||||
}
|
||||
|
||||
func (db *DB) queryModelTokenUsageSummary(query string, args ...interface{}) (ModelTokenUsageSummary, error) {
|
||||
var s ModelTokenUsageSummary
|
||||
err := db.QueryRow(query, args...).Scan(
|
||||
&s.Events, &s.ModelCalls, &s.PromptTokens, &s.CompletionTokens,
|
||||
&s.TotalTokens, &s.CachedTokens, &s.ReasoningTokens,
|
||||
)
|
||||
if err != nil {
|
||||
return s, fmt.Errorf("查询模型Token用量汇总失败: %w", err)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (db *DB) queryModelTokenUsageBreakdown(query string, args ...interface{}) ([]ModelTokenUsageBreakdown, error) {
|
||||
rows, err := db.Query(query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询模型Token用量分组失败: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []ModelTokenUsageBreakdown{}
|
||||
for rows.Next() {
|
||||
var row ModelTokenUsageBreakdown
|
||||
if err := rows.Scan(
|
||||
&row.Key, &row.Label, &row.Events, &row.ModelCalls, &row.PromptTokens,
|
||||
&row.CompletionTokens, &row.TotalTokens, &row.CachedTokens, &row.ReasoningTokens,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("扫描模型Token用量分组失败: %w", err)
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("遍历模型Token用量分组失败: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (db *DB) ListModelTokenUsage(filter ModelTokenUsageFilter) ([]ModelTokenUsage, error) {
|
||||
if filter.Limit <= 0 {
|
||||
filter.Limit = 20
|
||||
}
|
||||
if filter.Limit > 500 {
|
||||
filter.Limit = 500
|
||||
}
|
||||
where, args := buildModelTokenUsageWhere(filter, "mtu", "c")
|
||||
args = append(args, filter.Limit)
|
||||
rows, err := db.Query(`
|
||||
SELECT mtu.id, mtu.process_detail_id, mtu.message_id, mtu.conversation_id,
|
||||
COALESCE(mtu.project_id, ''), mtu.source, mtu.orchestration, mtu.reason, mtu.model,
|
||||
mtu.model_calls, mtu.prompt_tokens, mtu.completion_tokens, mtu.total_tokens,
|
||||
mtu.cached_tokens, mtu.reasoning_tokens, mtu.created_at, mtu.updated_at
|
||||
FROM model_token_usage mtu
|
||||
JOIN conversations c ON c.id = mtu.conversation_id`+where+`
|
||||
ORDER BY mtu.created_at DESC, mtu.rowid DESC
|
||||
LIMIT ?`, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询模型Token用量明细失败: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []ModelTokenUsage{}
|
||||
for rows.Next() {
|
||||
var u ModelTokenUsage
|
||||
var createdAt, updatedAt string
|
||||
if err := rows.Scan(
|
||||
&u.ID, &u.ProcessDetailID, &u.MessageID, &u.ConversationID, &u.ProjectID,
|
||||
&u.Source, &u.Orchestration, &u.Reason, &u.Model, &u.ModelCalls,
|
||||
&u.PromptTokens, &u.CompletionTokens, &u.TotalTokens, &u.CachedTokens,
|
||||
&u.ReasoningTokens, &createdAt, &updatedAt,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("扫描模型Token用量明细失败: %w", err)
|
||||
}
|
||||
u.CreatedAt = parseModelTokenUsageTime(createdAt)
|
||||
u.UpdatedAt = parseModelTokenUsageTime(updatedAt)
|
||||
out = append(out, u)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("遍历模型Token用量明细失败: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func parseModelTokenUsageTime(s string) time.Time {
|
||||
for _, layout := range []string{
|
||||
"2006-01-02 15:04:05.999999999-07:00",
|
||||
"2006-01-02 15:04:05.999999-07:00",
|
||||
"2006-01-02 15:04:05",
|
||||
time.RFC3339Nano,
|
||||
time.RFC3339,
|
||||
} {
|
||||
if t, err := time.Parse(layout, strings.TrimSpace(s)); err == nil {
|
||||
return t
|
||||
}
|
||||
}
|
||||
return time.Time{}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestModelTokenUsagePersistsFromUsageProcessDetail(t *testing.T) {
|
||||
db := newModelTokenUsageTestDB(t)
|
||||
conv, err := db.CreateConversation("usage", ConversationCreateMeta{})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateConversation: %v", err)
|
||||
}
|
||||
msg, err := db.AddMessage(conv.ID, "assistant", "done", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("AddMessage: %v", err)
|
||||
}
|
||||
if err := db.AddProcessDetail(msg.ID, conv.ID, modelTokenUsageEventType, "usage", map[string]interface{}{
|
||||
"source": "eino",
|
||||
"orchestration": "deep",
|
||||
"reason": "final",
|
||||
"model": "gpt-test",
|
||||
"modelCalls": 2,
|
||||
"promptTokens": 10,
|
||||
"completionTokens": 3,
|
||||
"totalTokens": 13,
|
||||
"cachedTokens": 4,
|
||||
"reasoningTokens": 1,
|
||||
}); err != nil {
|
||||
t.Fatalf("AddProcessDetail: %v", err)
|
||||
}
|
||||
|
||||
stats, err := db.GetModelTokenUsageStats(ModelTokenUsageFilter{})
|
||||
if err != nil {
|
||||
t.Fatalf("GetModelTokenUsageStats: %v", err)
|
||||
}
|
||||
if stats.Summary.Events != 1 || stats.Summary.ModelCalls != 2 || stats.Summary.TotalTokens != 13 || stats.Summary.CachedTokens != 4 || stats.Summary.ReasoningTokens != 1 {
|
||||
t.Fatalf("summary = %#v", stats.Summary)
|
||||
}
|
||||
if len(stats.ByModel) != 1 || stats.ByModel[0].Key != "gpt-test" || stats.ByModel[0].TotalTokens != 13 {
|
||||
t.Fatalf("by model = %#v", stats.ByModel)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelTokenUsageBackfillIsIdempotent(t *testing.T) {
|
||||
db := newModelTokenUsageTestDB(t)
|
||||
conv, err := db.CreateConversation("usage", ConversationCreateMeta{})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateConversation: %v", err)
|
||||
}
|
||||
msg, err := db.AddMessage(conv.ID, "assistant", "done", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("AddMessage: %v", err)
|
||||
}
|
||||
if err := db.AddProcessDetail(msg.ID, conv.ID, modelTokenUsageEventType, "usage", map[string]interface{}{
|
||||
"source": "eino", "modelCalls": 1, "promptTokens": 7, "completionTokens": 5, "totalTokens": 12,
|
||||
}); err != nil {
|
||||
t.Fatalf("AddProcessDetail: %v", err)
|
||||
}
|
||||
if err := db.BackfillModelTokenUsageFromProcessDetails(); err != nil {
|
||||
t.Fatalf("Backfill 1: %v", err)
|
||||
}
|
||||
if err := db.BackfillModelTokenUsageFromProcessDetails(); err != nil {
|
||||
t.Fatalf("Backfill 2: %v", err)
|
||||
}
|
||||
stats, err := db.GetModelTokenUsageStats(ModelTokenUsageFilter{})
|
||||
if err != nil {
|
||||
t.Fatalf("GetModelTokenUsageStats: %v", err)
|
||||
}
|
||||
if stats.Summary.Events != 1 || stats.Summary.TotalTokens != 12 {
|
||||
t.Fatalf("summary after backfill = %#v", stats.Summary)
|
||||
}
|
||||
}
|
||||
|
||||
func newModelTokenUsageTestDB(t *testing.T) *DB {
|
||||
t.Helper()
|
||||
db, err := NewDB(filepath.Join(t.TempDir(), "usage.db"), zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatalf("NewDB: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
return db
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestProcessDetailsSummaryDoesNotGuessIDLessResultsByOrder(t *testing.T) {
|
||||
func TestProcessDetailsSummaryDoesNotGuessIDLessResultsOntoDifferentTool(t *testing.T) {
|
||||
db, conversationID, messageID := setupProcessDetailsSummaryTest(t)
|
||||
for _, id := range []string{"call-1", "call-2", "call-3", "call-4"} {
|
||||
if err := db.AddProcessDetail(messageID, conversationID, "tool_call", "call", map[string]interface{}{
|
||||
@@ -20,8 +20,8 @@ func TestProcessDetailsSummaryDoesNotGuessIDLessResultsByOrder(t *testing.T) {
|
||||
results := []map[string]interface{}{
|
||||
{"toolName": "http-framework-test", "toolCallId": "call-1", "success": true},
|
||||
{"toolName": "http-framework-test", "toolCallId": "call-2", "success": true},
|
||||
{"toolName": "http-framework-test", "success": true},
|
||||
{"toolName": "http-framework-test", "success": true},
|
||||
{"toolName": "other-tool", "success": true},
|
||||
{"toolName": "other-tool", "success": true},
|
||||
}
|
||||
var resultIDs []string
|
||||
for _, result := range results {
|
||||
@@ -53,12 +53,71 @@ func TestProcessDetailsSummaryDoesNotGuessIDLessResultsByOrder(t *testing.T) {
|
||||
}
|
||||
}
|
||||
for i, execution := range summary.ToolExecutions[4:] {
|
||||
if execution.Status != "completed" || execution.ToolCallID != "" {
|
||||
if execution.Status != "completed" || execution.ToolCallID != "" || execution.ToolName != "other-tool" {
|
||||
t.Fatalf("idless result %d = %#v, want separate completed result without toolCallId", i, execution)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessDetailsSummaryPairsIDLessResultsWithSameToolName(t *testing.T) {
|
||||
db, conversationID, messageID := setupProcessDetailsSummaryTest(t)
|
||||
for i, id := range []string{"call-1", "call-2"} {
|
||||
if err := db.AddProcessDetail(messageID, conversationID, "tool_call", "call", map[string]interface{}{
|
||||
"toolName": "nmap", "toolCallId": id, "index": i + 1, "total": 2,
|
||||
}); err != nil {
|
||||
t.Fatalf("AddProcessDetail(tool_call): %v", err)
|
||||
}
|
||||
}
|
||||
var resultIDs []string
|
||||
for i := 0; i < 2; i++ {
|
||||
resultID, err := db.AddProcessDetailWithID(messageID, conversationID, "tool_result", "result", map[string]interface{}{
|
||||
"toolName": "nmap", "success": true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("AddProcessDetail(tool_result): %v", err)
|
||||
}
|
||||
resultIDs = append(resultIDs, resultID)
|
||||
}
|
||||
|
||||
summary, err := db.GetProcessDetailsSummary(messageID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetProcessDetailsSummary: %v", err)
|
||||
}
|
||||
if len(summary.ToolExecutions) != 2 {
|
||||
t.Fatalf("tool executions = %d, want 2", len(summary.ToolExecutions))
|
||||
}
|
||||
for i, execution := range summary.ToolExecutions {
|
||||
if execution.Status != "completed" {
|
||||
t.Fatalf("execution %d status = %q, want completed", i, execution.Status)
|
||||
}
|
||||
if execution.ResultDetailID != resultIDs[i] {
|
||||
t.Fatalf("execution %d result detail id = %q, want %q", i, execution.ResultDetailID, resultIDs[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessDetailsSummaryPairedResultWithoutSuccessIsCompleted(t *testing.T) {
|
||||
db, conversationID, messageID := setupProcessDetailsSummaryTest(t)
|
||||
if err := db.AddProcessDetail(messageID, conversationID, "tool_call", "call", map[string]interface{}{
|
||||
"toolName": "nmap", "toolCallId": "call-1",
|
||||
}); err != nil {
|
||||
t.Fatalf("AddProcessDetail(tool_call): %v", err)
|
||||
}
|
||||
if err := db.AddProcessDetail(messageID, conversationID, "tool_result", "result", map[string]interface{}{
|
||||
"toolName": "nmap", "toolCallId": "call-1", "resultPreview": "open 22",
|
||||
}); err != nil {
|
||||
t.Fatalf("AddProcessDetail(tool_result): %v", err)
|
||||
}
|
||||
|
||||
summary, err := db.GetProcessDetailsSummary(messageID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetProcessDetailsSummary: %v", err)
|
||||
}
|
||||
if len(summary.ToolExecutions) != 1 || summary.ToolExecutions[0].Status != "completed" {
|
||||
t.Fatalf("tool executions = %#v, want completed", summary.ToolExecutions)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessDetailsSummaryPairsRepeatedToolCallIDsFIFO(t *testing.T) {
|
||||
db, conversationID, messageID := setupProcessDetailsSummaryTest(t)
|
||||
for i := 0; i < 2; i++ {
|
||||
@@ -106,6 +165,32 @@ func TestProcessDetailsSummaryDoesNotReportPersistedOrphanAsRunning(t *testing.T
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessDetailsSummaryReportsUnmatchedToolCallAsRunningForActiveTurn(t *testing.T) {
|
||||
db, conversationID, messageID := setupProcessDetailsSummaryTest(t)
|
||||
if _, err := db.Exec(
|
||||
"UPDATE messages SET content = ?, updated_at = ? WHERE id = ?",
|
||||
"处理中...", "2026-08-10T08:00:00Z", messageID,
|
||||
); err != nil {
|
||||
t.Fatalf("update running message: %v", err)
|
||||
}
|
||||
if err := db.AddProcessDetail(messageID, conversationID, "tool_call", "call", map[string]interface{}{
|
||||
"toolName": "execute", "toolCallId": "pending",
|
||||
}); err != nil {
|
||||
t.Fatalf("AddProcessDetail(tool_call): %v", err)
|
||||
}
|
||||
|
||||
summary, err := db.GetProcessDetailsSummary(messageID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetProcessDetailsSummary: %v", err)
|
||||
}
|
||||
if summary.Status != "running" {
|
||||
t.Fatalf("summary status = %q, want running", summary.Status)
|
||||
}
|
||||
if len(summary.ToolExecutions) != 1 || summary.ToolExecutions[0].Status != "running" {
|
||||
t.Fatalf("tool executions = %#v, want running", summary.ToolExecutions)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessDetailsSummaryIncludesPersistedTurnTiming(t *testing.T) {
|
||||
db, _, messageID := setupProcessDetailsSummaryTest(t)
|
||||
startedAt := "2026-08-10T08:00:00Z"
|
||||
|
||||
@@ -20,16 +20,20 @@ func (db *DB) FindNearestToolExecutionArguments(conversationID, toolName string,
|
||||
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 = ?
|
||||
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, toolName, start, end, at)
|
||||
LIMIT 1`, conversationID, names[0], names[len(names)-1], start, end, at)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
+22
-16
@@ -698,18 +698,19 @@ func (h *AgentHandler) mergeAssistantMessagePartialOnCancel(messageID, partial s
|
||||
|
||||
// ChatResponse 聊天响应
|
||||
type ChatResponse struct {
|
||||
Response string `json:"response"`
|
||||
MCPExecutionIDs []string `json:"mcpExecutionIds,omitempty"` // 本次对话中执行的MCP调用ID列表
|
||||
ConversationID string `json:"conversationId"` // 对话ID
|
||||
Time time.Time `json:"time"`
|
||||
Finalizable bool `json:"finalizable"`
|
||||
Finalized bool `json:"finalized"`
|
||||
Status string `json:"status,omitempty"`
|
||||
CompletionReason string `json:"completionReason,omitempty"`
|
||||
EvidenceVerified bool `json:"evidenceVerified"`
|
||||
EvidenceRefs []string `json:"evidenceRefs,omitempty"`
|
||||
PendingExecutionIDs []string `json:"pendingExecutionIds,omitempty"`
|
||||
MissingChecks []string `json:"missingChecks,omitempty"`
|
||||
Response string `json:"response"`
|
||||
MCPExecutionIDs []string `json:"mcpExecutionIds,omitempty"` // 本次对话中执行的MCP调用ID列表
|
||||
ConversationID string `json:"conversationId"` // 对话ID
|
||||
Time time.Time `json:"time"`
|
||||
Finalizable bool `json:"finalizable"`
|
||||
Finalized bool `json:"finalized"`
|
||||
Status string `json:"status,omitempty"`
|
||||
CompletionReason string `json:"completionReason,omitempty"`
|
||||
EvidenceVerified bool `json:"evidenceVerified"`
|
||||
EvidenceRefs []string `json:"evidenceRefs,omitempty"`
|
||||
PendingExecutionIDs []string `json:"pendingExecutionIds,omitempty"`
|
||||
MissingChecks []string `json:"missingChecks,omitempty"`
|
||||
AutoCancelledPendingExecutionIDs []string `json:"autoCancelledPendingExecutionIds,omitempty"`
|
||||
}
|
||||
|
||||
func (h *AgentHandler) finalizeRobotAgentError(ctx context.Context, assistantMessageID, conversationID string, resultMA *multiagent.RunResult, errMA error) (string, string, error) {
|
||||
@@ -724,8 +725,13 @@ func (h *AgentHandler) finalizeRobotAgentError(ctx context.Context, assistantMes
|
||||
return "", conversationID, errMA
|
||||
}
|
||||
|
||||
func (h *AgentHandler) finalizeRobotAgentSuccess(assistantMessageID, conversationID string, resultMA *multiagent.RunResult) (string, string, error) {
|
||||
decision := h.finalizeAgentRunForDeliveryWithPolicy(conversationID, assistantMessageID, "robot", resultMA, resultMA.MCPExecutionIDs, multiagent.AggregatedReasoningFromTraceJSON(resultMA.LastAgentTraceInput), true)
|
||||
func (h *AgentHandler) finalizeRobotAgentSuccess(taskCtx context.Context, assistantMessageID, conversationID string, resultMA *multiagent.RunResult) (string, string, error) {
|
||||
reasoningContent := multiagent.AggregatedReasoningFromTraceJSON(resultMA.LastAgentTraceInput)
|
||||
decision := h.decideAgentRunForDeliveryWithPolicy(conversationID, assistantMessageID, "robot", resultMA, resultMA.MCPExecutionIDs, true)
|
||||
if cancelled := h.cleanupPendingToolExecutionsAfterIteration(taskCtx, conversationID, decision, nil); len(cancelled) > 0 {
|
||||
decision = h.decideAgentRunForDeliveryWithPolicy(conversationID, assistantMessageID, "robot", resultMA, resultMA.MCPExecutionIDs, true)
|
||||
}
|
||||
h.persistFinalizationDecision(conversationID, assistantMessageID, "robot", resultMA.MCPExecutionIDs, reasoningContent, decision)
|
||||
responseText := decision.FinalText
|
||||
if !decision.Finalizable {
|
||||
responseText = finalizationBlockedMessage(decision)
|
||||
@@ -758,7 +764,7 @@ func (h *AgentHandler) runRobotEinoSingleWithRetry(
|
||||
*taskStatus = "failed"
|
||||
return h.finalizeRobotAgentError(taskCtx, assistantMessageID, conversationID, resultMA, errMA)
|
||||
}
|
||||
return h.finalizeRobotAgentSuccess(assistantMessageID, conversationID, resultMA)
|
||||
return h.finalizeRobotAgentSuccess(taskCtx, assistantMessageID, conversationID, resultMA)
|
||||
}
|
||||
|
||||
func (h *AgentHandler) runRobotMultiAgentWithRetry(
|
||||
@@ -779,7 +785,7 @@ func (h *AgentHandler) runRobotMultiAgentWithRetry(
|
||||
*taskStatus = "failed"
|
||||
return h.finalizeRobotAgentError(taskCtx, assistantMessageID, conversationID, resultMA, errMA)
|
||||
}
|
||||
return h.finalizeRobotAgentSuccess(assistantMessageID, conversationID, resultMA)
|
||||
return h.finalizeRobotAgentSuccess(taskCtx, assistantMessageID, conversationID, resultMA)
|
||||
}
|
||||
|
||||
// ProcessMessageForRobot 供机器人(企业微信/钉钉/飞书)调用:Eino 单/多代理执行路径(含 progressCallback、过程详情),仅不发送 SSE,最后返回完整回复
|
||||
|
||||
@@ -281,7 +281,12 @@ func (h *AgentHandler) executeOneBatchSubTask(queueID string, queue *BatchTaskQu
|
||||
if useBatchMulti {
|
||||
agentMode = "batch_eino_" + batchOrch
|
||||
}
|
||||
decision := h.finalizeAgentRunForDeliveryWithPolicy(conversationID, assistantMessageID, agentMode, resultMA, mcpIDs, reasoningContent, true)
|
||||
decision := h.decideAgentRunForDeliveryWithPolicy(conversationID, assistantMessageID, agentMode, resultMA, mcpIDs, true)
|
||||
autoCancelledPendingExecutionIDs := h.cleanupPendingToolExecutionsAfterIteration(taskCtx, conversationID, decision, progressCallback)
|
||||
if len(autoCancelledPendingExecutionIDs) > 0 {
|
||||
decision = h.decideAgentRunForDeliveryWithPolicy(conversationID, assistantMessageID, agentMode, resultMA, mcpIDs, true)
|
||||
}
|
||||
h.persistFinalizationDecision(conversationID, assistantMessageID, agentMode, mcpIDs, reasoningContent, decision)
|
||||
resText := decision.FinalText
|
||||
if !decision.Finalizable {
|
||||
resText = finalizationBlockedMessage(decision)
|
||||
@@ -289,14 +294,15 @@ func (h *AgentHandler) executeOneBatchSubTask(queueID string, queue *BatchTaskQu
|
||||
sendEvent("finalization_check", resText, decision)
|
||||
}
|
||||
sendEvent("response", resText, finalizationResponsePayload(decision, map[string]interface{}{
|
||||
"conversationId": conversationID,
|
||||
"messageId": assistantMessageID,
|
||||
"agentMode": agentMode,
|
||||
"mcpExecutionIds": mcpIDs,
|
||||
"batchQueueId": queueID,
|
||||
"batchTaskId": task.ID,
|
||||
"batchTaskStatus": map[bool]string{true: string(BatchTaskStatusCompleted), false: string(BatchTaskStatusFailed)}[decision.Finalizable],
|
||||
"candidatePreview": safeTruncateString(resultMA.Response, 500),
|
||||
"conversationId": conversationID,
|
||||
"messageId": assistantMessageID,
|
||||
"agentMode": agentMode,
|
||||
"mcpExecutionIds": mcpIDs,
|
||||
"batchQueueId": queueID,
|
||||
"batchTaskId": task.ID,
|
||||
"batchTaskStatus": map[bool]string{true: string(BatchTaskStatusCompleted), false: string(BatchTaskStatusFailed)}[decision.Finalizable],
|
||||
"candidatePreview": safeTruncateString(resultMA.Response, 500),
|
||||
"autoCancelledPendingExecutionIds": autoCancelledPendingExecutionIDs,
|
||||
}))
|
||||
|
||||
if assistantMessageID == "" {
|
||||
|
||||
@@ -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"
|
||||
@@ -1216,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,
|
||||
@@ -1229,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"`
|
||||
|
||||
@@ -497,6 +497,7 @@ func summarizeProcessDetailData(eventType string, data interface{}) interface{}
|
||||
}
|
||||
allow := map[string]bool{
|
||||
"toolName": true, "toolCallId": true, "index": true, "total": true,
|
||||
"arguments": true, "argumentsObj": true,
|
||||
"success": true, "isError": true, "executionId": true,
|
||||
"einoAgent": true, "einoRole": true, "einoScope": true, "orchestration": true,
|
||||
"agentFacing": true,
|
||||
|
||||
@@ -76,6 +76,65 @@ func TestProcessDetailsPageIncludesTerminalToolStatusAcrossPageBoundary(t *testi
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessDetailsPageUsesPersistedExecutionStatusAfterBackgroundCancel(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := database.NewDB(filepath.Join(t.TempDir(), "process-details-cancelled.db"), zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatalf("NewDB: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
conversation, err := db.CreateConversation("cancelled background", 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)
|
||||
}
|
||||
execID := "exec-cancelled-after-background"
|
||||
if err := db.AddProcessDetail(message.ID, conversation.ID, "tool_call", "call", map[string]interface{}{
|
||||
"toolName": "exec", "toolCallId": "call-cancelled", "index": 1, "total": 1,
|
||||
}); err != nil {
|
||||
t.Fatalf("AddProcessDetail(tool_call): %v", err)
|
||||
}
|
||||
if err := db.AddProcessDetail(message.ID, conversation.ID, "tool_result", "background", map[string]interface{}{
|
||||
"toolName": "exec", "toolCallId": "call-cancelled", "executionId": execID, "status": "background_running", "success": true,
|
||||
}); err != nil {
|
||||
t.Fatalf("AddProcessDetail(tool_result): %v", err)
|
||||
}
|
||||
now := time.Now()
|
||||
if err := db.SaveToolExecution(&mcp.ToolExecution{
|
||||
ID: execID,
|
||||
ToolName: "exec",
|
||||
Status: mcp.ToolExecutionStatusCancelled,
|
||||
StartTime: now,
|
||||
EndTime: &now,
|
||||
}); 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=10&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 {
|
||||
ToolExecutions []database.ProcessDetailsToolExecution `json:"toolExecutions"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if len(response.ToolExecutions) != 1 {
|
||||
t.Fatalf("tool executions = %d, want 1", len(response.ToolExecutions))
|
||||
}
|
||||
if got := response.ToolExecutions[0].Status; got != mcp.ToolExecutionStatusCancelled {
|
||||
t.Fatalf("tool execution status = %q, want cancelled", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessDetailsFullBackfillsEmptyToolCallArgumentsFromExecution(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := database.NewDB(filepath.Join(t.TempDir(), "process-details-args.db"), zap.NewNop())
|
||||
@@ -139,3 +198,67 @@ func TestProcessDetailsFullBackfillsEmptyToolCallArgumentsFromExecution(t *testi
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,6 +192,7 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
|
||||
var emptyResponseContinueAttempt int
|
||||
var finalizationAutoContinueAttempt int
|
||||
var decision agentfinalizer.Decision
|
||||
var autoCancelledPendingExecutionIDs []string
|
||||
|
||||
for {
|
||||
segmentMainIterationMax := 0
|
||||
@@ -268,6 +269,10 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
|
||||
continue
|
||||
}
|
||||
decision = h.decideAgentRunForDeliveryWithPolicy(conversationID, assistantMessageID, "eino_single", result, cumulativeMCPExecutionIDs, requestRequiresExecutionEvidence(&req))
|
||||
if cancelled := h.cleanupPendingToolExecutionsAfterIteration(taskCtx, conversationID, decision, progressCallback); len(cancelled) > 0 {
|
||||
autoCancelledPendingExecutionIDs = mergeMCPExecutionIDLists(autoCancelledPendingExecutionIDs, cancelled)
|
||||
decision = h.decideAgentRunForDeliveryWithPolicy(conversationID, assistantMessageID, "eino_single", result, cumulativeMCPExecutionIDs, requestRequiresExecutionEvidence(&req))
|
||||
}
|
||||
if h.tryAutoContinueAfterFinalization(taskCtx, conversationID, result, decision, &finalizationAutoContinueAttempt, &curHistory, &curFinalMessage, progressCallback) {
|
||||
mainIterationOffset += segmentMainIterationMax
|
||||
timeoutCancel()
|
||||
@@ -384,6 +389,10 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
|
||||
|
||||
if decision.CompletionReason == "" {
|
||||
decision = h.decideAgentRunForDeliveryWithPolicy(conversationID, assistantMessageID, "eino_single", result, cumulativeMCPExecutionIDs, requestRequiresExecutionEvidence(&req))
|
||||
if cancelled := h.cleanupPendingToolExecutionsAfterIteration(taskCtx, conversationID, decision, nil); len(cancelled) > 0 {
|
||||
autoCancelledPendingExecutionIDs = mergeMCPExecutionIDLists(autoCancelledPendingExecutionIDs, cancelled)
|
||||
decision = h.decideAgentRunForDeliveryWithPolicy(conversationID, assistantMessageID, "eino_single", result, cumulativeMCPExecutionIDs, requestRequiresExecutionEvidence(&req))
|
||||
}
|
||||
}
|
||||
h.persistFinalizationDecision(conversationID, assistantMessageID, "eino_single", cumulativeMCPExecutionIDs, multiagent.AggregatedReasoningFromTraceJSON(result.LastAgentTraceInput), decision)
|
||||
|
||||
@@ -401,10 +410,11 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
|
||||
h.tasks.UpdateTaskStatus(conversationID, taskStatus)
|
||||
}
|
||||
sendEvent("response", responseText, finalizationResponsePayload(decision, map[string]interface{}{
|
||||
"mcpExecutionIds": cumulativeMCPExecutionIDs,
|
||||
"conversationId": conversationID,
|
||||
"messageId": assistantMessageID,
|
||||
"agentMode": "eino_single",
|
||||
"mcpExecutionIds": cumulativeMCPExecutionIDs,
|
||||
"conversationId": conversationID,
|
||||
"messageId": assistantMessageID,
|
||||
"agentMode": "eino_single",
|
||||
"autoCancelledPendingExecutionIds": autoCancelledPendingExecutionIDs,
|
||||
}))
|
||||
sendEvent("done", "", map[string]interface{}{"conversationId": conversationID})
|
||||
}
|
||||
@@ -464,6 +474,7 @@ func (h *AgentHandler) EinoSingleAgentLoop(c *gin.Context) {
|
||||
var emptyResponseContinueAttempt int
|
||||
var finalizationAutoContinueAttempt int
|
||||
var decision agentfinalizer.Decision
|
||||
var autoCancelledPendingExecutionIDs []string
|
||||
for {
|
||||
result, runErr = multiagent.RunEinoSingleChatModelAgent(
|
||||
taskCtx,
|
||||
@@ -493,6 +504,10 @@ func (h *AgentHandler) EinoSingleAgentLoop(c *gin.Context) {
|
||||
continue
|
||||
}
|
||||
decision = h.decideAgentRunForDeliveryWithPolicy(prep.ConversationID, prep.AssistantMessageID, "eino_single", result, result.MCPExecutionIDs, requestRequiresExecutionEvidence(&req))
|
||||
if cancelled := h.cleanupPendingToolExecutionsAfterIteration(taskCtx, prep.ConversationID, decision, progressCallback); len(cancelled) > 0 {
|
||||
autoCancelledPendingExecutionIDs = mergeMCPExecutionIDLists(autoCancelledPendingExecutionIDs, cancelled)
|
||||
decision = h.decideAgentRunForDeliveryWithPolicy(prep.ConversationID, prep.AssistantMessageID, "eino_single", result, result.MCPExecutionIDs, requestRequiresExecutionEvidence(&req))
|
||||
}
|
||||
if h.tryAutoContinueAfterFinalization(taskCtx, prep.ConversationID, result, decision, &finalizationAutoContinueAttempt, &curHist, &curMsg, progressCallback) {
|
||||
continue
|
||||
}
|
||||
@@ -509,18 +524,19 @@ func (h *AgentHandler) EinoSingleAgentLoop(c *gin.Context) {
|
||||
responseText = finalizationBlockedMessage(decision)
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"response": responseText,
|
||||
"conversationId": prep.ConversationID,
|
||||
"mcpExecutionIds": result.MCPExecutionIDs,
|
||||
"assistantMessageId": prep.AssistantMessageID,
|
||||
"agentMode": "eino_single",
|
||||
"finalized": decision.Finalized,
|
||||
"finalizable": decision.Finalizable,
|
||||
"status": decision.Status,
|
||||
"completionReason": decision.CompletionReason,
|
||||
"evidenceVerified": decision.EvidenceVerified,
|
||||
"evidenceRefs": decision.EvidenceRefs,
|
||||
"pendingExecutionIds": decision.PendingExecutionIDs,
|
||||
"missingChecks": decision.MissingChecks,
|
||||
"response": responseText,
|
||||
"conversationId": prep.ConversationID,
|
||||
"mcpExecutionIds": result.MCPExecutionIDs,
|
||||
"assistantMessageId": prep.AssistantMessageID,
|
||||
"agentMode": "eino_single",
|
||||
"finalized": decision.Finalized,
|
||||
"finalizable": decision.Finalizable,
|
||||
"status": decision.Status,
|
||||
"completionReason": decision.CompletionReason,
|
||||
"evidenceVerified": decision.EvidenceVerified,
|
||||
"evidenceRefs": decision.EvidenceRefs,
|
||||
"pendingExecutionIds": decision.PendingExecutionIDs,
|
||||
"missingChecks": decision.MissingChecks,
|
||||
"autoCancelledPendingExecutionIds": autoCancelledPendingExecutionIDs,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2,16 +2,21 @@ package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"cyberstrike-ai/internal/agent"
|
||||
"cyberstrike-ai/internal/agentfinalizer"
|
||||
"cyberstrike-ai/internal/mcp"
|
||||
"cyberstrike-ai/internal/multiagent"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
const finalizationAutoContinueMaxAttempts = 2
|
||||
const finalizationPendingToolCancelWait = 2 * time.Second
|
||||
const finalizationPendingToolCancelPoll = 50 * time.Millisecond
|
||||
const finalizationPendingToolCancelNote = "Agent 迭代已结束,最终回复前自动终止未完成的工具执行"
|
||||
|
||||
func shouldAutoContinueAfterFinalization(d agentfinalizer.Decision, attempt int) bool {
|
||||
if d.Finalizable || d.Finalized {
|
||||
@@ -75,3 +80,105 @@ func finalizationAutoContinueBackoff(attempt int) time.Duration {
|
||||
}
|
||||
return time.Duration(attempt) * time.Second
|
||||
}
|
||||
|
||||
func (h *AgentHandler) cleanupPendingToolExecutionsAfterIteration(
|
||||
taskCtx context.Context,
|
||||
conversationID string,
|
||||
decision agentfinalizer.Decision,
|
||||
progressCallback func(eventType, message string, data interface{}),
|
||||
) []string {
|
||||
if h == nil || h.agent == nil || decision.CompletionReason != agentfinalizer.ReasonPendingTools {
|
||||
return nil
|
||||
}
|
||||
pending := uniqueNonEmptyStrings(decision.PendingExecutionIDs)
|
||||
if len(pending) == 0 {
|
||||
return nil
|
||||
}
|
||||
cancelled := make([]string, 0, len(pending))
|
||||
for _, executionID := range pending {
|
||||
if h.agent.CancelMCPToolExecutionWithNote(executionID, finalizationPendingToolCancelNote) {
|
||||
cancelled = append(cancelled, executionID)
|
||||
} else if h.logger != nil {
|
||||
h.logger.Warn("finalization pending tool cleanup could not cancel execution",
|
||||
zap.String("conversationId", conversationID),
|
||||
zap.String("executionId", executionID))
|
||||
}
|
||||
}
|
||||
if len(cancelled) == 0 {
|
||||
return nil
|
||||
}
|
||||
if progressCallback != nil {
|
||||
progressCallback("finalization_pending_tools_cancelled", "迭代结束,已自动终止仍在运行的工具执行。", map[string]interface{}{
|
||||
"conversationId": conversationID,
|
||||
"source": "finalizer",
|
||||
"autoCancelledPendingExecutionIds": cancelled,
|
||||
"pendingExecutionIds": pending,
|
||||
"reason": agentfinalizer.ReasonPendingTools,
|
||||
})
|
||||
}
|
||||
h.waitForToolExecutionsToLeavePending(taskCtx, cancelled, finalizationPendingToolCancelWait)
|
||||
return cancelled
|
||||
}
|
||||
|
||||
func (h *AgentHandler) waitForToolExecutionsToLeavePending(ctx context.Context, executionIDs []string, wait time.Duration) {
|
||||
if h == nil || h.db == nil || len(executionIDs) == 0 || wait <= 0 {
|
||||
return
|
||||
}
|
||||
timer := time.NewTimer(wait)
|
||||
defer timer.Stop()
|
||||
ticker := time.NewTicker(finalizationPendingToolCancelPoll)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
if !h.hasPendingToolExecutions(executionIDs) {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case <-contextDone(ctx):
|
||||
return
|
||||
case <-timer.C:
|
||||
return
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *AgentHandler) hasPendingToolExecutions(executionIDs []string) bool {
|
||||
if h == nil || h.db == nil {
|
||||
return false
|
||||
}
|
||||
for _, executionID := range uniqueNonEmptyStrings(executionIDs) {
|
||||
exec, err := h.db.GetToolExecution(executionID)
|
||||
if err != nil || exec == nil {
|
||||
continue
|
||||
}
|
||||
switch strings.TrimSpace(exec.Status) {
|
||||
case mcp.ToolExecutionStatusQueued, mcp.ToolExecutionStatusRunning:
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func uniqueNonEmptyStrings(values []string) []string {
|
||||
seen := make(map[string]struct{}, len(values))
|
||||
out := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[value]; ok {
|
||||
continue
|
||||
}
|
||||
seen[value] = struct{}{}
|
||||
out = append(out, value)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func contextDone(ctx context.Context) <-chan struct{} {
|
||||
if ctx == nil {
|
||||
return nil
|
||||
}
|
||||
return ctx.Done()
|
||||
}
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
agentpkg "cyberstrike-ai/internal/agent"
|
||||
"cyberstrike-ai/internal/agentfinalizer"
|
||||
"cyberstrike-ai/internal/config"
|
||||
"cyberstrike-ai/internal/database"
|
||||
"cyberstrike-ai/internal/mcp"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestShouldAutoContinueAfterFinalization(t *testing.T) {
|
||||
@@ -57,3 +66,66 @@ func TestRequestRequiresExecutionEvidenceUsesExplicitPolicyOnly(t *testing.T) {
|
||||
t.Fatal("explicit false policy should not require execution evidence")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanupPendingToolExecutionsAfterIterationAllowsFinalization(t *testing.T) {
|
||||
logger := zap.NewNop()
|
||||
db, err := database.NewDB(filepath.Join(t.TempDir(), "cleanup-finalization.db"), logger)
|
||||
if err != nil {
|
||||
t.Fatalf("NewDB: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
|
||||
server := mcp.NewServerWithStorage(logger, db)
|
||||
server.ConfigureToolWaitTimeoutSeconds(1)
|
||||
server.RegisterTool(mcp.Tool{Name: "block", InputSchema: map[string]interface{}{"type": "object"}}, func(ctx context.Context, args map[string]interface{}) (*mcp.ToolResult, error) {
|
||||
<-ctx.Done()
|
||||
return nil, ctx.Err()
|
||||
})
|
||||
ag := agentpkg.NewAgent(&config.OpenAIConfig{}, &config.AgentConfig{}, server, nil, logger, 10)
|
||||
h := &AgentHandler{agent: ag, db: db, logger: logger}
|
||||
|
||||
callCtx := mcp.WithMCPConversationID(context.Background(), "conv-cleanup")
|
||||
result, execID, err := server.CallTool(callCtx, "block", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("CallTool: %v", err)
|
||||
}
|
||||
if result == nil || !result.IsError || execID == "" {
|
||||
t.Fatalf("expected background wait result, result=%#v execID=%q", result, execID)
|
||||
}
|
||||
|
||||
decision := agentfinalizer.Decide(db, agentfinalizer.Input{
|
||||
Response: "基于已完成信息的阶段性总结。",
|
||||
MCPExecutionIDs: []string{execID},
|
||||
})
|
||||
if decision.CompletionReason != agentfinalizer.ReasonPendingTools {
|
||||
t.Fatalf("decision reason = %s, want pending tools: %+v", decision.CompletionReason, decision)
|
||||
}
|
||||
|
||||
var eventType string
|
||||
cancelled := h.cleanupPendingToolExecutionsAfterIteration(context.Background(), "conv-cleanup", decision, func(et, _ string, _ interface{}) {
|
||||
eventType = et
|
||||
})
|
||||
if len(cancelled) != 1 || cancelled[0] != execID {
|
||||
t.Fatalf("cancelled = %#v, want [%s]", cancelled, execID)
|
||||
}
|
||||
if eventType != "finalization_pending_tools_cancelled" {
|
||||
t.Fatalf("event type = %q", eventType)
|
||||
}
|
||||
|
||||
deadline := time.Now().Add(time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
exec, err := db.GetToolExecution(execID)
|
||||
if err == nil && exec != nil && exec.Status == mcp.ToolExecutionStatusCancelled {
|
||||
after := agentfinalizer.Decide(db, agentfinalizer.Input{
|
||||
Response: "基于已完成信息的阶段性总结。",
|
||||
MCPExecutionIDs: []string{execID},
|
||||
})
|
||||
if !after.Finalizable || !after.Finalized {
|
||||
t.Fatalf("decision should finalize after cleanup: %+v", after)
|
||||
}
|
||||
return
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
t.Fatal("execution did not become cancelled")
|
||||
}
|
||||
|
||||
+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 {
|
||||
|
||||
@@ -205,6 +205,7 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
|
||||
}
|
||||
agentMode := "eino_" + effectiveOrch
|
||||
var decision agentfinalizer.Decision
|
||||
var autoCancelledPendingExecutionIDs []string
|
||||
|
||||
for {
|
||||
segmentMainIterationMax := 0
|
||||
@@ -282,6 +283,10 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
|
||||
continue
|
||||
}
|
||||
decision = h.decideAgentRunForDeliveryWithPolicy(conversationID, assistantMessageID, agentMode, result, cumulativeMCPExecutionIDs, requestRequiresExecutionEvidence(&req))
|
||||
if cancelled := h.cleanupPendingToolExecutionsAfterIteration(taskCtx, conversationID, decision, progressCallback); len(cancelled) > 0 {
|
||||
autoCancelledPendingExecutionIDs = mergeMCPExecutionIDLists(autoCancelledPendingExecutionIDs, cancelled)
|
||||
decision = h.decideAgentRunForDeliveryWithPolicy(conversationID, assistantMessageID, agentMode, result, cumulativeMCPExecutionIDs, requestRequiresExecutionEvidence(&req))
|
||||
}
|
||||
if h.tryAutoContinueAfterFinalization(taskCtx, conversationID, result, decision, &finalizationAutoContinueAttempt, &curHistory, &curFinalMessage, progressCallback) {
|
||||
mainIterationOffset += segmentMainIterationMax
|
||||
timeoutCancel()
|
||||
@@ -398,6 +403,10 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
|
||||
|
||||
if decision.CompletionReason == "" {
|
||||
decision = h.decideAgentRunForDeliveryWithPolicy(conversationID, assistantMessageID, agentMode, result, cumulativeMCPExecutionIDs, requestRequiresExecutionEvidence(&req))
|
||||
if cancelled := h.cleanupPendingToolExecutionsAfterIteration(taskCtx, conversationID, decision, nil); len(cancelled) > 0 {
|
||||
autoCancelledPendingExecutionIDs = mergeMCPExecutionIDLists(autoCancelledPendingExecutionIDs, cancelled)
|
||||
decision = h.decideAgentRunForDeliveryWithPolicy(conversationID, assistantMessageID, agentMode, result, cumulativeMCPExecutionIDs, requestRequiresExecutionEvidence(&req))
|
||||
}
|
||||
}
|
||||
h.persistFinalizationDecision(conversationID, assistantMessageID, agentMode, cumulativeMCPExecutionIDs, multiagent.AggregatedReasoningFromTraceJSON(result.LastAgentTraceInput), decision)
|
||||
|
||||
@@ -415,10 +424,11 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
|
||||
h.tasks.UpdateTaskStatus(conversationID, taskStatus)
|
||||
}
|
||||
sendEvent("response", responseText, finalizationResponsePayload(decision, map[string]interface{}{
|
||||
"mcpExecutionIds": cumulativeMCPExecutionIDs,
|
||||
"conversationId": conversationID,
|
||||
"messageId": assistantMessageID,
|
||||
"agentMode": agentMode,
|
||||
"mcpExecutionIds": cumulativeMCPExecutionIDs,
|
||||
"conversationId": conversationID,
|
||||
"messageId": assistantMessageID,
|
||||
"agentMode": agentMode,
|
||||
"autoCancelledPendingExecutionIds": autoCancelledPendingExecutionIDs,
|
||||
}))
|
||||
sendEvent("done", "", map[string]interface{}{"conversationId": conversationID})
|
||||
}
|
||||
@@ -478,6 +488,7 @@ func (h *AgentHandler) MultiAgentLoop(c *gin.Context) {
|
||||
}
|
||||
agentMode := "eino_" + effectiveOrch
|
||||
var decision agentfinalizer.Decision
|
||||
var autoCancelledPendingExecutionIDs []string
|
||||
for {
|
||||
result, runErr = multiagent.RunDeepAgent(
|
||||
taskCtx,
|
||||
@@ -514,6 +525,10 @@ func (h *AgentHandler) MultiAgentLoop(c *gin.Context) {
|
||||
continue
|
||||
}
|
||||
decision = h.decideAgentRunForDeliveryWithPolicy(prep.ConversationID, prep.AssistantMessageID, agentMode, result, result.MCPExecutionIDs, requestRequiresExecutionEvidence(&req))
|
||||
if cancelled := h.cleanupPendingToolExecutionsAfterIteration(taskCtx, prep.ConversationID, decision, progressCallback); len(cancelled) > 0 {
|
||||
autoCancelledPendingExecutionIDs = mergeMCPExecutionIDLists(autoCancelledPendingExecutionIDs, cancelled)
|
||||
decision = h.decideAgentRunForDeliveryWithPolicy(prep.ConversationID, prep.AssistantMessageID, agentMode, result, result.MCPExecutionIDs, requestRequiresExecutionEvidence(&req))
|
||||
}
|
||||
if h.tryAutoContinueAfterFinalization(taskCtx, prep.ConversationID, result, decision, &finalizationAutoContinueAttempt, &curHist, &curMsg, progressCallback) {
|
||||
continue
|
||||
}
|
||||
@@ -533,18 +548,19 @@ func (h *AgentHandler) MultiAgentLoop(c *gin.Context) {
|
||||
responseText = finalizationBlockedMessage(decision)
|
||||
}
|
||||
c.JSON(http.StatusOK, ChatResponse{
|
||||
Response: responseText,
|
||||
MCPExecutionIDs: result.MCPExecutionIDs,
|
||||
ConversationID: prep.ConversationID,
|
||||
Time: time.Now(),
|
||||
Finalizable: decision.Finalizable,
|
||||
Finalized: decision.Finalized,
|
||||
Status: decision.Status,
|
||||
CompletionReason: decision.CompletionReason,
|
||||
EvidenceVerified: decision.EvidenceVerified,
|
||||
EvidenceRefs: decision.EvidenceRefs,
|
||||
PendingExecutionIDs: decision.PendingExecutionIDs,
|
||||
MissingChecks: decision.MissingChecks,
|
||||
Response: responseText,
|
||||
MCPExecutionIDs: result.MCPExecutionIDs,
|
||||
ConversationID: prep.ConversationID,
|
||||
Time: time.Now(),
|
||||
Finalizable: decision.Finalizable,
|
||||
Finalized: decision.Finalized,
|
||||
Status: decision.Status,
|
||||
CompletionReason: decision.CompletionReason,
|
||||
EvidenceVerified: decision.EvidenceVerified,
|
||||
EvidenceRefs: decision.EvidenceRefs,
|
||||
PendingExecutionIDs: decision.PendingExecutionIDs,
|
||||
MissingChecks: decision.MissingChecks,
|
||||
AutoCancelledPendingExecutionIDs: autoCancelledPendingExecutionIDs,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package handler
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -484,7 +485,10 @@ func (m *AgentTaskManager) CancelTask(conversationID string, cause error) (bool,
|
||||
if runtimeCancel != nil {
|
||||
runtimeHandled = runtimeCancel(cause)
|
||||
}
|
||||
if cancel != nil && !runtimeHandled {
|
||||
// 「彻底停止」必须同时取消宿主 context:原生 Agent Cancel 即使已受理,
|
||||
// 也可能只在安全点返回或报告超时,不能据此让整条任务继续存活。
|
||||
// 中断并继续仍保留原语义:原生取消已处理时由运行时负责恢复。
|
||||
if cancel != nil && (!runtimeHandled || errors.Is(cause, ErrTaskCancelled)) {
|
||||
cancel(cause)
|
||||
}
|
||||
if toolCanceler != nil {
|
||||
@@ -591,6 +595,12 @@ func (m *AgentTaskManager) GetActiveTasks() []*AgentTask {
|
||||
Status: task.Status,
|
||||
})
|
||||
}
|
||||
sort.Slice(result, func(i, j int) bool {
|
||||
if result[i].StartedAt.Equal(result[j].StartedAt) {
|
||||
return result[i].ConversationID < result[j].ConversationID
|
||||
}
|
||||
return result[i].StartedAt.Before(result[j].StartedAt)
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestGetActiveTasksUsesStableCreationOrder(t *testing.T) {
|
||||
m := NewAgentTaskManager()
|
||||
started := time.Date(2026, 8, 19, 10, 0, 0, 0, time.UTC)
|
||||
m.mu.Lock()
|
||||
m.tasks = map[string]*AgentTask{
|
||||
"conversation-z": {ConversationID: "conversation-z", StartedAt: started, Status: "running"},
|
||||
"conversation-late": {ConversationID: "conversation-late", StartedAt: started.Add(time.Minute), Status: "running"},
|
||||
"conversation-a": {ConversationID: "conversation-a", StartedAt: started, Status: "running"},
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
want := []string{"conversation-a", "conversation-z", "conversation-late"}
|
||||
for attempt := 0; attempt < 20; attempt++ {
|
||||
gotTasks := m.GetActiveTasks()
|
||||
if len(gotTasks) != len(want) {
|
||||
t.Fatalf("GetActiveTasks() length = %d, want %d", len(gotTasks), len(want))
|
||||
}
|
||||
for i, task := range gotTasks {
|
||||
if task.ConversationID != want[i] {
|
||||
t.Fatalf("attempt %d order[%d] = %q, want %q", attempt, i, task.ConversationID, want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -32,7 +32,7 @@ func TestCancelTaskInvokesToolCancelerOnFullStop(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCancelTaskUsesAgentRuntimeCancelAsPrimaryPath(t *testing.T) {
|
||||
func TestCancelTaskFullStopCancelsRuntimeAndParentContext(t *testing.T) {
|
||||
tm := NewAgentTaskManager()
|
||||
var order []string
|
||||
tm.SetToolCanceler(func(conversationID string) {
|
||||
@@ -61,7 +61,7 @@ func TestCancelTaskUsesAgentRuntimeCancelAsPrimaryPath(t *testing.T) {
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("CancelTask: ok=%v err=%v", ok, err)
|
||||
}
|
||||
want := []string{"runtime", "tool"}
|
||||
want := []string{"runtime", "context", "tool"}
|
||||
if len(order) != len(want) {
|
||||
t.Fatalf("order length got %d want %d: %#v", len(order), len(want), order)
|
||||
}
|
||||
@@ -72,6 +72,29 @@ func TestCancelTaskUsesAgentRuntimeCancelAsPrimaryPath(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCancelTaskInterruptContinueKeepsParentWhenRuntimeHandlesIt(t *testing.T) {
|
||||
tm := NewAgentTaskManager()
|
||||
ctx, cancel := context.WithCancelCause(context.Background())
|
||||
if _, err := tm.StartTask("conv-interrupt-native", "hello", cancel); err != nil {
|
||||
t.Fatalf("StartTask: %v", err)
|
||||
}
|
||||
unregister := tm.BindAgentRuntimeCancel("conv-interrupt-native", func(err error) bool {
|
||||
if !errors.Is(err, multiagent.ErrInterruptContinue) {
|
||||
t.Fatalf("runtime cancel got %v", err)
|
||||
}
|
||||
return true
|
||||
})
|
||||
defer unregister()
|
||||
|
||||
ok, err := tm.CancelTask("conv-interrupt-native", multiagent.ErrInterruptContinue)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("CancelTask: ok=%v err=%v", ok, err)
|
||||
}
|
||||
if cause := context.Cause(ctx); cause != nil {
|
||||
t.Fatalf("interrupt-continue parent context cause = %v, want nil", cause)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCancelTaskFallsBackToContextWhenAgentRuntimeCancelMisses(t *testing.T) {
|
||||
tm := NewAgentTaskManager()
|
||||
var order []string
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"cyberstrike-ai/internal/database"
|
||||
"cyberstrike-ai/internal/security"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// GetTokenUsageStats returns model token usage aggregates for dashboard views.
|
||||
func (h *ConversationHandler) GetTokenUsageStats(c *gin.Context) {
|
||||
filter := tokenUsageFilterFromQuery(c)
|
||||
if session, ok := security.CurrentSession(c); ok {
|
||||
filter.Access = database.RBACListAccess{UserID: session.UserID, Scope: session.Scope}
|
||||
}
|
||||
stats, err := h.db.GetModelTokenUsageStats(filter)
|
||||
if err != nil {
|
||||
h.logger.Error("获取Token用量统计失败", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, stats)
|
||||
}
|
||||
|
||||
// GetConversationTokenUsageStats returns token usage scoped to one conversation.
|
||||
func (h *ConversationHandler) GetConversationTokenUsageStats(c *gin.Context) {
|
||||
filter := tokenUsageFilterFromQuery(c)
|
||||
filter.ConversationID = strings.TrimSpace(c.Param("id"))
|
||||
if session, ok := security.CurrentSession(c); ok {
|
||||
filter.Access = database.RBACListAccess{UserID: session.UserID, Scope: session.Scope}
|
||||
}
|
||||
stats, err := h.db.GetModelTokenUsageStats(filter)
|
||||
if err != nil {
|
||||
h.logger.Error("获取对话Token用量统计失败", zap.Error(err), zap.String("conversationId", filter.ConversationID))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, stats)
|
||||
}
|
||||
|
||||
func tokenUsageFilterFromQuery(c *gin.Context) database.ModelTokenUsageFilter {
|
||||
days, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("days", "7")))
|
||||
if days <= 0 {
|
||||
days = 7
|
||||
}
|
||||
if days > 365 {
|
||||
days = 365
|
||||
}
|
||||
limit, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("limit", "10")))
|
||||
if limit <= 0 {
|
||||
limit = 10
|
||||
}
|
||||
if limit > 500 {
|
||||
limit = 500
|
||||
}
|
||||
filter := database.ModelTokenUsageFilter{
|
||||
ConversationID: strings.TrimSpace(c.Query("conversation_id")),
|
||||
ProjectID: strings.TrimSpace(c.Query("project_id")),
|
||||
Days: days,
|
||||
Limit: limit,
|
||||
}
|
||||
if since := parseTokenUsageQueryTime(c.Query("since")); !since.IsZero() {
|
||||
filter.Since = since
|
||||
} else if days > 0 {
|
||||
now := time.Now()
|
||||
start := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()).AddDate(0, 0, -(days - 1))
|
||||
filter.Since = start
|
||||
}
|
||||
if until := parseTokenUsageQueryTime(c.Query("until")); !until.IsZero() {
|
||||
filter.Until = until
|
||||
}
|
||||
return filter
|
||||
}
|
||||
|
||||
func parseTokenUsageQueryTime(raw string) time.Time {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return time.Time{}
|
||||
}
|
||||
for _, layout := range []string{time.RFC3339Nano, time.RFC3339, "2006-01-02"} {
|
||||
if t, err := time.Parse(layout, raw); err == nil {
|
||||
return t
|
||||
}
|
||||
}
|
||||
return time.Time{}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package multiagent
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/cloudwego/eino/adk"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// agenticOrphanToolPrunerMiddleware is the AgenticMessage equivalent of
|
||||
// orphanToolPrunerMiddleware. It removes user-role messages whose content
|
||||
// blocks are exclusively FunctionToolResult entries with CallIDs that do not
|
||||
// match any FunctionToolCall in the history.
|
||||
//
|
||||
// This is a defense-in-depth layer after agenticToolPairReconcilerMiddleware;
|
||||
// the reconciler handles the common case (assistant followed by its results)
|
||||
// while this pruner catches stray results that appear before their assistant
|
||||
// or in non-adjacent positions (e.g. after summarization rewriting).
|
||||
type agenticOrphanToolPrunerMiddleware struct {
|
||||
*adk.TypedBaseChatModelAgentMiddleware[*schema.AgenticMessage]
|
||||
logger *zap.Logger
|
||||
phase string
|
||||
}
|
||||
|
||||
func newAgenticOrphanToolPrunerMiddleware(logger *zap.Logger, phase string) adk.TypedChatModelAgentMiddleware[*schema.AgenticMessage] {
|
||||
return &agenticOrphanToolPrunerMiddleware{
|
||||
TypedBaseChatModelAgentMiddleware: &adk.TypedBaseChatModelAgentMiddleware[*schema.AgenticMessage]{},
|
||||
logger: logger,
|
||||
phase: phase,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *agenticOrphanToolPrunerMiddleware) BeforeModelRewriteState(
|
||||
ctx context.Context,
|
||||
state *adk.TypedChatModelAgentState[*schema.AgenticMessage],
|
||||
mc *adk.TypedModelContext[*schema.AgenticMessage],
|
||||
) (context.Context, *adk.TypedChatModelAgentState[*schema.AgenticMessage], error) {
|
||||
_ = mc
|
||||
if m == nil || state == nil || len(state.Messages) == 0 {
|
||||
return ctx, state, nil
|
||||
}
|
||||
|
||||
// Pass 1: collect all provided CallIDs from assistant FunctionToolCall blocks.
|
||||
provided := make(map[string]struct{}, 8)
|
||||
for _, msg := range state.Messages {
|
||||
if msg == nil || msg.Role != schema.AgenticRoleTypeAssistant {
|
||||
continue
|
||||
}
|
||||
for _, block := range msg.ContentBlocks {
|
||||
if block != nil && block.FunctionToolCall != nil && block.FunctionToolCall.CallID != "" {
|
||||
provided[block.FunctionToolCall.CallID] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fast path: check if any orphan exists.
|
||||
hasOrphan := false
|
||||
for _, msg := range state.Messages {
|
||||
if msg == nil || !isPureAgenticToolResult(msg) {
|
||||
continue
|
||||
}
|
||||
for _, id := range agenticToolResultCallIDs(msg) {
|
||||
if _, ok := provided[id]; !ok {
|
||||
hasOrphan = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if hasOrphan {
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasOrphan {
|
||||
return ctx, state, nil
|
||||
}
|
||||
|
||||
// Pass 2: build pruned list.
|
||||
pruned := make([]*schema.AgenticMessage, 0, len(state.Messages))
|
||||
var droppedIDs []string
|
||||
var droppedNames []string
|
||||
for _, msg := range state.Messages {
|
||||
if msg == nil {
|
||||
continue
|
||||
}
|
||||
if !isPureAgenticToolResult(msg) {
|
||||
pruned = append(pruned, msg)
|
||||
continue
|
||||
}
|
||||
// Check if ALL result call IDs are orphans. If any is matched, keep the
|
||||
// message (the reconciler already handled partial mismatches).
|
||||
allOrphan := true
|
||||
for _, id := range agenticToolResultCallIDs(msg) {
|
||||
if _, ok := provided[id]; ok {
|
||||
allOrphan = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if allOrphan {
|
||||
for _, block := range msg.ContentBlocks {
|
||||
if block != nil && block.FunctionToolResult != nil {
|
||||
droppedIDs = append(droppedIDs, block.FunctionToolResult.CallID)
|
||||
droppedNames = append(droppedNames, block.FunctionToolResult.Name)
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
pruned = append(pruned, msg)
|
||||
}
|
||||
|
||||
if len(droppedIDs) == 0 {
|
||||
return ctx, state, nil
|
||||
}
|
||||
if m.logger != nil {
|
||||
m.logger.Warn("agentic orphan tool messages pruned before model call",
|
||||
zap.String("phase", m.phase),
|
||||
zap.Int("dropped_count", len(droppedIDs)),
|
||||
zap.Strings("dropped_tool_call_ids", droppedIDs),
|
||||
zap.Strings("dropped_tool_names", droppedNames),
|
||||
zap.Int("messages_before", len(state.Messages)),
|
||||
zap.Int("messages_after", len(pruned)),
|
||||
)
|
||||
}
|
||||
ns := *state
|
||||
ns.Messages = pruned
|
||||
return ctx, &ns, nil
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
package multiagent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/cloudwego/eino/adk"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// agenticToolPairReconcilerMiddleware is the AgenticMessage equivalent of
|
||||
// toolPairReconcilerMiddleware. It ensures every assistant FunctionToolCall
|
||||
// block is followed by a matching FunctionToolResult message, patching or
|
||||
// dropping as needed so the downstream model never receives an unpaired
|
||||
// tool-call history.
|
||||
//
|
||||
// In the AgenticMessage protocol:
|
||||
// - Assistant tool calls: Role=AgenticRoleTypeAssistant with FunctionToolCall content blocks.
|
||||
// - Tool results: Role=AgenticRoleTypeUser with FunctionToolResult content blocks.
|
||||
//
|
||||
// This middleware runs after summarization which may truncate history and
|
||||
// break pairings.
|
||||
type agenticToolPairReconcilerMiddleware struct {
|
||||
*adk.TypedBaseChatModelAgentMiddleware[*schema.AgenticMessage]
|
||||
logger *zap.Logger
|
||||
phase string
|
||||
}
|
||||
|
||||
func newAgenticToolPairReconcilerMiddleware(logger *zap.Logger, phase string) adk.TypedChatModelAgentMiddleware[*schema.AgenticMessage] {
|
||||
return &agenticToolPairReconcilerMiddleware{
|
||||
TypedBaseChatModelAgentMiddleware: &adk.TypedBaseChatModelAgentMiddleware[*schema.AgenticMessage]{},
|
||||
logger: logger,
|
||||
phase: phase,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *agenticToolPairReconcilerMiddleware) BeforeModelRewriteState(
|
||||
ctx context.Context,
|
||||
state *adk.TypedChatModelAgentState[*schema.AgenticMessage],
|
||||
mc *adk.TypedModelContext[*schema.AgenticMessage],
|
||||
) (context.Context, *adk.TypedChatModelAgentState[*schema.AgenticMessage], error) {
|
||||
_ = mc
|
||||
if m == nil || state == nil || len(state.Messages) == 0 {
|
||||
return ctx, state, nil
|
||||
}
|
||||
|
||||
usedIDs := make(map[string]struct{}, 16)
|
||||
changed := false
|
||||
patched := 0
|
||||
dropped := 0
|
||||
out := make([]*schema.AgenticMessage, 0, len(state.Messages))
|
||||
|
||||
for i := 0; i < len(state.Messages); {
|
||||
msg := state.Messages[i]
|
||||
if msg == nil {
|
||||
changed = true
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
calls := agenticFunctionToolCalls(msg)
|
||||
|
||||
// Non-assistant or assistant without tool calls — but check for orphan
|
||||
// tool-result messages (user role with only FunctionToolResult blocks).
|
||||
if len(calls) == 0 {
|
||||
if isPureAgenticToolResult(msg) {
|
||||
// Orphan tool result not preceded by its assistant; drop it.
|
||||
changed = true
|
||||
dropped++
|
||||
i++
|
||||
continue
|
||||
}
|
||||
out = append(out, msg)
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
// Deduplicate / fix empty call IDs.
|
||||
idsChanged := false
|
||||
for ci := range calls {
|
||||
id := calls[ci].CallID
|
||||
_, duplicate := usedIDs[id]
|
||||
if id == "" || duplicate {
|
||||
base := fmt.Sprintf("patched_agentic_call_%d_%d", i, ci)
|
||||
id = base
|
||||
for suffix := 1; ; suffix++ {
|
||||
if _, exists := usedIDs[id]; !exists {
|
||||
break
|
||||
}
|
||||
id = fmt.Sprintf("%s_%d", base, suffix)
|
||||
}
|
||||
calls[ci].CallID = id
|
||||
idsChanged = true
|
||||
changed = true
|
||||
}
|
||||
usedIDs[id] = struct{}{}
|
||||
}
|
||||
|
||||
assistant := msg
|
||||
if idsChanged {
|
||||
assistant = cloneAgenticMessageWithCalls(msg, calls)
|
||||
}
|
||||
out = append(out, assistant)
|
||||
|
||||
// Build expected set.
|
||||
expected := make(map[string]*schema.FunctionToolCall, len(calls))
|
||||
for ci := range calls {
|
||||
expected[calls[ci].CallID] = calls[ci]
|
||||
}
|
||||
|
||||
// Consume following tool-result messages.
|
||||
results := make(map[string]*schema.AgenticMessage, len(calls))
|
||||
j := i + 1
|
||||
for j < len(state.Messages) {
|
||||
next := state.Messages[j]
|
||||
if next == nil {
|
||||
changed = true
|
||||
j++
|
||||
continue
|
||||
}
|
||||
if !isPureAgenticToolResult(next) {
|
||||
break
|
||||
}
|
||||
resultCallIDs := agenticToolResultCallIDs(next)
|
||||
consumed := false
|
||||
for _, rid := range resultCallIDs {
|
||||
if _, wanted := expected[rid]; !wanted {
|
||||
continue
|
||||
}
|
||||
if _, dup := results[rid]; dup {
|
||||
continue
|
||||
}
|
||||
results[rid] = next
|
||||
consumed = true
|
||||
}
|
||||
if !consumed {
|
||||
changed = true
|
||||
dropped++
|
||||
}
|
||||
j++
|
||||
}
|
||||
|
||||
// Emit results in call order, patching missing ones.
|
||||
for _, tc := range calls {
|
||||
if result, ok := results[tc.CallID]; ok {
|
||||
out = append(out, result)
|
||||
continue
|
||||
}
|
||||
out = append(out, makeAgenticPatchedToolResult(tc.CallID, tc.Name))
|
||||
changed = true
|
||||
patched++
|
||||
}
|
||||
i = j
|
||||
}
|
||||
|
||||
if !changed {
|
||||
return ctx, state, nil
|
||||
}
|
||||
if m.logger != nil {
|
||||
m.logger.Warn("agentic tool-call/result pairs reconciled before model call",
|
||||
zap.String("phase", m.phase),
|
||||
zap.Int("patched_results", patched),
|
||||
zap.Int("dropped_results", dropped),
|
||||
zap.Int("messages_before", len(state.Messages)),
|
||||
zap.Int("messages_after", len(out)),
|
||||
)
|
||||
}
|
||||
ns := *state
|
||||
ns.Messages = out
|
||||
return ctx, &ns, nil
|
||||
}
|
||||
|
||||
// agenticFunctionToolCalls extracts FunctionToolCall pointers from an
|
||||
// assistant message's content blocks. Returns nil for non-assistant messages.
|
||||
func agenticFunctionToolCalls(msg *schema.AgenticMessage) []*schema.FunctionToolCall {
|
||||
if msg == nil || msg.Role != schema.AgenticRoleTypeAssistant {
|
||||
return nil
|
||||
}
|
||||
var out []*schema.FunctionToolCall
|
||||
for _, block := range msg.ContentBlocks {
|
||||
if block != nil && block.FunctionToolCall != nil {
|
||||
out = append(out, block.FunctionToolCall)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// isPureAgenticToolResult returns true when the message is a user-role
|
||||
// message whose content blocks are exclusively FunctionToolResult entries.
|
||||
func isPureAgenticToolResult(msg *schema.AgenticMessage) bool {
|
||||
if msg == nil || msg.Role != schema.AgenticRoleTypeUser || len(msg.ContentBlocks) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, block := range msg.ContentBlocks {
|
||||
if block == nil {
|
||||
continue
|
||||
}
|
||||
if block.FunctionToolResult == nil {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// agenticToolResultCallIDs extracts all CallIDs from FunctionToolResult blocks.
|
||||
func agenticToolResultCallIDs(msg *schema.AgenticMessage) []string {
|
||||
if msg == nil {
|
||||
return nil
|
||||
}
|
||||
var ids []string
|
||||
for _, block := range msg.ContentBlocks {
|
||||
if block != nil && block.FunctionToolResult != nil && block.FunctionToolResult.CallID != "" {
|
||||
ids = append(ids, block.FunctionToolResult.CallID)
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func cloneAgenticMessageWithCalls(msg *schema.AgenticMessage, calls []*schema.FunctionToolCall) *schema.AgenticMessage {
|
||||
cloned := *msg
|
||||
cloned.ContentBlocks = make([]*schema.ContentBlock, 0, len(msg.ContentBlocks))
|
||||
callIdx := 0
|
||||
for _, block := range msg.ContentBlocks {
|
||||
if block != nil && block.FunctionToolCall != nil && callIdx < len(calls) {
|
||||
cloned.ContentBlocks = append(cloned.ContentBlocks, schema.NewContentBlock(calls[callIdx]))
|
||||
callIdx++
|
||||
} else {
|
||||
cloned.ContentBlocks = append(cloned.ContentBlocks, block)
|
||||
}
|
||||
}
|
||||
return &cloned
|
||||
}
|
||||
|
||||
func makeAgenticPatchedToolResult(callID, name string) *schema.AgenticMessage {
|
||||
return &schema.AgenticMessage{
|
||||
Role: schema.AgenticRoleTypeUser,
|
||||
ContentBlocks: []*schema.ContentBlock{schema.NewContentBlock(&schema.FunctionToolResult{
|
||||
CallID: callID,
|
||||
Name: name,
|
||||
Content: []*schema.FunctionToolResultContentBlock{{
|
||||
Type: schema.FunctionToolResultContentBlockTypeText,
|
||||
Text: &schema.UserInputText{Text: patchedMissingToolResult},
|
||||
}},
|
||||
})},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package multiagent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/cloudwego/eino/adk"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
func TestAgenticToolPairReconcilerPatchesMissing(t *testing.T) {
|
||||
t.Parallel()
|
||||
mw := newAgenticToolPairReconcilerMiddleware(nil, "test")
|
||||
state := &adk.TypedChatModelAgentState[*schema.AgenticMessage]{
|
||||
Messages: []*schema.AgenticMessage{
|
||||
agenticAssistantToolCall("c1", "search", `{"q":"x"}`),
|
||||
agenticAssistantToolCall("c2", "execute", `{"cmd":"ls"}`),
|
||||
// c1 result present, c2 missing
|
||||
agenticToolResult("c1", "search", "found it"),
|
||||
},
|
||||
}
|
||||
_, out, err := mw.BeforeModelRewriteState(context.Background(), state, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Expected: assistant(c1) -> result(c1) -> assistant(c2) -> patched_result(c2)
|
||||
if len(out.Messages) != 4 {
|
||||
t.Fatalf("messages = %d, want 4", len(out.Messages))
|
||||
}
|
||||
// c1 assistant
|
||||
if calls := agenticFunctionToolCalls(out.Messages[0]); len(calls) != 1 || calls[0].CallID != "c1" {
|
||||
t.Fatal("msg[0] should be assistant(c1)")
|
||||
}
|
||||
// c1 result
|
||||
if ids := agenticToolResultCallIDs(out.Messages[1]); len(ids) != 1 || ids[0] != "c1" {
|
||||
t.Fatal("msg[1] should be result(c1)")
|
||||
}
|
||||
// c2 assistant
|
||||
if calls := agenticFunctionToolCalls(out.Messages[2]); len(calls) != 1 || calls[0].CallID != "c2" {
|
||||
t.Fatal("msg[2] should be assistant(c2)")
|
||||
}
|
||||
// c2 patched result
|
||||
if ids := agenticToolResultCallIDs(out.Messages[3]); len(ids) != 1 || ids[0] != "c2" {
|
||||
t.Fatal("msg[3] should be patched result(c2)")
|
||||
}
|
||||
resultText := out.Messages[3].ContentBlocks[0].FunctionToolResult.Content[0].Text.Text
|
||||
if resultText != patchedMissingToolResult {
|
||||
t.Fatalf("patched text = %q", resultText)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgenticToolPairReconcilerDropsOrphan(t *testing.T) {
|
||||
t.Parallel()
|
||||
mw := newAgenticToolPairReconcilerMiddleware(nil, "test")
|
||||
state := &adk.TypedChatModelAgentState[*schema.AgenticMessage]{
|
||||
Messages: []*schema.AgenticMessage{
|
||||
// Orphan tool result with no preceding assistant
|
||||
agenticToolResult("orphan", "deleted_tool", "stale data"),
|
||||
{Role: schema.AgenticRoleTypeUser, ContentBlocks: []*schema.ContentBlock{
|
||||
schema.NewContentBlock(&schema.UserInputText{Text: "hello"}),
|
||||
}},
|
||||
},
|
||||
}
|
||||
_, out, err := mw.BeforeModelRewriteState(context.Background(), state, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(out.Messages) != 1 {
|
||||
t.Fatalf("messages = %d, want 1 (orphan dropped)", len(out.Messages))
|
||||
}
|
||||
if out.Messages[0].ContentBlocks[0].UserInputText == nil {
|
||||
t.Fatal("remaining message should be the user text")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgenticToolPairReconcilerNoopWhenPaired(t *testing.T) {
|
||||
t.Parallel()
|
||||
mw := newAgenticToolPairReconcilerMiddleware(nil, "test")
|
||||
state := &adk.TypedChatModelAgentState[*schema.AgenticMessage]{
|
||||
Messages: []*schema.AgenticMessage{
|
||||
agenticAssistantToolCall("c1", "search", `{}`),
|
||||
agenticToolResult("c1", "search", "ok"),
|
||||
},
|
||||
}
|
||||
_, out, err := mw.BeforeModelRewriteState(context.Background(), state, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Should return original state unchanged
|
||||
if &out.Messages[0] == &state.Messages[0] {
|
||||
// pointer equality on slice — state not cloned
|
||||
}
|
||||
if len(out.Messages) != 2 {
|
||||
t.Fatalf("messages = %d, want 2", len(out.Messages))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgenticToolPairReconcilerFixesEmptyCallID(t *testing.T) {
|
||||
t.Parallel()
|
||||
mw := newAgenticToolPairReconcilerMiddleware(nil, "test")
|
||||
state := &adk.TypedChatModelAgentState[*schema.AgenticMessage]{
|
||||
Messages: []*schema.AgenticMessage{
|
||||
{
|
||||
Role: schema.AgenticRoleTypeAssistant,
|
||||
ContentBlocks: []*schema.ContentBlock{schema.NewContentBlock(&schema.FunctionToolCall{
|
||||
CallID: "", Name: "search", Arguments: `{}`,
|
||||
})},
|
||||
},
|
||||
},
|
||||
}
|
||||
_, out, err := mw.BeforeModelRewriteState(context.Background(), state, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
calls := agenticFunctionToolCalls(out.Messages[0])
|
||||
if len(calls) != 1 || calls[0].CallID == "" {
|
||||
t.Fatalf("empty call ID should be patched, got %q", calls[0].CallID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgenticOrphanToolPrunerRemovesOrphan(t *testing.T) {
|
||||
t.Parallel()
|
||||
mw := newAgenticOrphanToolPrunerMiddleware(nil, "test")
|
||||
state := &adk.TypedChatModelAgentState[*schema.AgenticMessage]{
|
||||
Messages: []*schema.AgenticMessage{
|
||||
agenticAssistantToolCall("c1", "search", `{}`),
|
||||
agenticToolResult("c1", "search", "ok"),
|
||||
// Orphan: no assistant has call_id "c_orphan"
|
||||
agenticToolResult("c_orphan", "deleted", "stale"),
|
||||
},
|
||||
}
|
||||
_, out, err := mw.BeforeModelRewriteState(context.Background(), state, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(out.Messages) != 2 {
|
||||
t.Fatalf("messages = %d, want 2 (orphan pruned)", len(out.Messages))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgenticOrphanToolPrunerNoopWhenClean(t *testing.T) {
|
||||
t.Parallel()
|
||||
mw := newAgenticOrphanToolPrunerMiddleware(nil, "test")
|
||||
state := &adk.TypedChatModelAgentState[*schema.AgenticMessage]{
|
||||
Messages: []*schema.AgenticMessage{
|
||||
agenticAssistantToolCall("c1", "search", `{}`),
|
||||
agenticToolResult("c1", "search", "ok"),
|
||||
},
|
||||
}
|
||||
_, out, err := mw.BeforeModelRewriteState(context.Background(), state, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(out.Messages) != 2 {
|
||||
t.Fatalf("messages = %d, want 2", len(out.Messages))
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,48 @@ func isInterruptContinue(ctx context.Context) bool {
|
||||
return errors.Is(context.Cause(ctx), ErrInterruptContinue)
|
||||
}
|
||||
|
||||
func isEinoStreamCanceled(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
if errors.Is(err, adk.ErrStreamCanceled) {
|
||||
return true
|
||||
}
|
||||
var streamCanceled *adk.StreamCanceledError
|
||||
return errors.As(err, &streamCanceled)
|
||||
}
|
||||
|
||||
func isEinoCancelError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
var cancelErr *adk.CancelError
|
||||
return errors.As(err, &cancelErr)
|
||||
}
|
||||
|
||||
// isEinoVoluntaryCancelErr reports cancel signals produced by Agent Cancel /
|
||||
// TurnLoop preempt (CancelError, ErrStreamCanceled, context.Canceled).
|
||||
func isEinoVoluntaryCancelErr(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
return isEinoCancelError(err) || isEinoStreamCanceled(err) || errors.Is(err, context.Canceled)
|
||||
}
|
||||
|
||||
// isEinoTurnLoopPreemptErr is true when a cancel/stream-cancel leaked from the
|
||||
// current agent turn while the host task context is still alive. TurnLoop
|
||||
// interrupt-continue does not cancel the parent context; treating that leak as
|
||||
// fatal would abort the whole run instead of starting the queued next turn.
|
||||
func isEinoTurnLoopPreemptErr(ctx context.Context, err error) bool {
|
||||
if err == nil || !isEinoVoluntaryCancelErr(err) {
|
||||
return false
|
||||
}
|
||||
if ctx != nil && ctx.Err() != nil {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func isEinoIterationLimitError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
@@ -401,22 +443,41 @@ func nextAgentEventWithContext(ctx context.Context, iter *adk.AsyncIterator[*adk
|
||||
|
||||
// recvSchemaMessageStream 消费 ADK Tool 流式结果;ctx 取消时立即返回,避免 amass 等无输出时永久阻塞。
|
||||
func recvSchemaMessageStream(ctx context.Context, stream *schema.StreamReader[*schema.Message]) (content, toolCallID, toolName string, recvErr error) {
|
||||
if stream == nil {
|
||||
return "", "", "", nil
|
||||
msgs, recvErr := recvSchemaToolResultMessages(ctx, stream)
|
||||
if len(msgs) == 0 {
|
||||
return "", "", "", recvErr
|
||||
}
|
||||
var buf strings.Builder
|
||||
recvErr = recvEinoSchemaMessageStreamWithContext(ctx, stream, 8, func(chunk *schema.Message) {
|
||||
if chunk.Content != "" {
|
||||
buf.WriteString(chunk.Content)
|
||||
parts := make([]string, 0, len(msgs))
|
||||
for _, msg := range msgs {
|
||||
if msg == nil {
|
||||
continue
|
||||
}
|
||||
if tid := strings.TrimSpace(chunk.ToolCallID); tid != "" {
|
||||
toolCallID = tid
|
||||
parts = append(parts, msg.Content)
|
||||
if id := strings.TrimSpace(msg.ToolCallID); id != "" {
|
||||
toolCallID = id
|
||||
}
|
||||
if name := strings.TrimSpace(chunk.ToolName); name != "" {
|
||||
if name := strings.TrimSpace(msg.ToolName); name != "" {
|
||||
toolName = name
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, ""), toolCallID, toolName, recvErr
|
||||
}
|
||||
|
||||
// recvSchemaToolResultMessages 先收齐 Tool 流,再用 Eino ConcatMessages 合并。
|
||||
// EventSender 一 call 一条流时走 ConcatMessages;并行结果被摊平进同一条流时按 CallID 分列再合并。
|
||||
func recvSchemaToolResultMessages(ctx context.Context, stream *schema.StreamReader[*schema.Message]) (msgs []*schema.Message, recvErr error) {
|
||||
if stream == nil {
|
||||
return nil, nil
|
||||
}
|
||||
var chunks []*schema.Message
|
||||
recvErr = recvEinoSchemaMessageStreamWithContext(ctx, stream, 8, func(chunk *schema.Message) {
|
||||
chunks = append(chunks, chunk)
|
||||
})
|
||||
return buf.String(), toolCallID, toolName, recvErr
|
||||
msgs, concatErr := concatToolResultChunks(chunks)
|
||||
if concatErr != nil && recvErr == nil {
|
||||
return nil, concatErr
|
||||
}
|
||||
return msgs, recvErr
|
||||
}
|
||||
|
||||
func buildEinoCheckpointID(orchMode string) string {
|
||||
|
||||
@@ -30,6 +30,29 @@ func TestRecvSchemaMessageStream_EOF(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecvSchemaToolResultMessages_SplitsParallelIDs(t *testing.T) {
|
||||
sr, sw := schema.Pipe[*schema.Message](8)
|
||||
_ = sw.Send(schema.ToolMessage("one-", "tc-1", schema.WithToolName("nmap")), nil)
|
||||
_ = sw.Send(schema.ToolMessage("two-", "tc-2", schema.WithToolName("nmap")), nil)
|
||||
_ = sw.Send(schema.ToolMessage("a", "tc-1", schema.WithToolName("nmap")), nil)
|
||||
_ = sw.Send(schema.ToolMessage("b", "tc-2", schema.WithToolName("nmap")), nil)
|
||||
sw.Close()
|
||||
|
||||
msgs, err := recvSchemaToolResultMessages(context.Background(), sr)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
if len(msgs) != 2 {
|
||||
t.Fatalf("msgs = %#v, want 2", msgs)
|
||||
}
|
||||
if msgs[0].ToolCallID != "tc-1" || msgs[0].Content != "one-a" {
|
||||
t.Fatalf("msg 0 = %#v", msgs[0])
|
||||
}
|
||||
if msgs[1].ToolCallID != "tc-2" || msgs[1].Content != "two-b" {
|
||||
t.Fatalf("msg 1 = %#v", msgs[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecvSchemaMessageStream_CapturesToolName(t *testing.T) {
|
||||
sr, sw := schema.Pipe[*schema.Message](4)
|
||||
_ = sw.Send(schema.ToolMessage("hello", "tc-1", schema.WithToolName("execute")), nil)
|
||||
|
||||
@@ -20,8 +20,13 @@ func appendEinoAgenticChatModelTailMiddlewares(
|
||||
handlers = append(handlers, newAgenticSystemMessageNormalizerMiddleware(cfg.logger, cfg.phase))
|
||||
handlers = append(handlers, newAgenticContinuationUserDedupMiddleware(cfg.logger, cfg.phase))
|
||||
if cfg.agenticSummarization != nil {
|
||||
handlers = append(handlers, newAgenticToolPairReconcilerMiddleware(cfg.logger, cfg.phase+"_pre_summarization"))
|
||||
handlers = append(handlers, cfg.agenticSummarization)
|
||||
}
|
||||
handlers = append(handlers, newAgenticToolPairReconcilerMiddleware(cfg.logger, cfg.phase))
|
||||
if !cfg.skipOrphanPruner {
|
||||
handlers = append(handlers, newAgenticOrphanToolPrunerMiddleware(cfg.logger, cfg.phase))
|
||||
}
|
||||
if !cfg.skipTrace && cfg.trace != nil {
|
||||
if capMw := newAgenticModelFacingTraceMiddleware(cfg.trace); capMw != nil {
|
||||
handlers = append(handlers, capMw)
|
||||
|
||||
@@ -106,7 +106,8 @@ func TestAppendEinoAgenticChatModelTailMiddlewares(t *testing.T) {
|
||||
phase: "agentic",
|
||||
trace: holder,
|
||||
})
|
||||
if len(handlers) != 3 {
|
||||
t.Fatalf("handlers = %d, want system + continuation + trace", len(handlers))
|
||||
// system + continuation + reconciler + orphan_pruner + trace
|
||||
if len(handlers) != 5 {
|
||||
t.Fatalf("handlers = %d, want system + continuation + reconciler + orphan_pruner + trace", len(handlers))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,11 @@ func adaptAgenticEventToEinoEvents(ev *adk.TypedAgentEvent[*schema.AgenticMessag
|
||||
return []*adk.AgentEvent{base(&adk.AgentOutput{CustomizedOutput: customized})}
|
||||
}
|
||||
if mv.IsStreaming {
|
||||
// Tool 流保持 1 event ↔ 1 MessageStream,对齐 ADK EventSenderToolWrapper:
|
||||
// 每个 CallID 在工具包装层就已经是独立事件。这里不能再按 CallID 现场拆成
|
||||
// 多条 live pipe——drain 会阻塞读完当前流,交错的并行 chunk 会把另一列写满后死锁。
|
||||
// 若上游仍把 ToolsNode 的 MergeStreamReaders 摊成一条流,由
|
||||
// concatToolResultChunks 按列 ConcatMessages 恢复。
|
||||
return []*adk.AgentEvent{base(&adk.AgentOutput{
|
||||
MessageOutput: &adk.MessageVariant{
|
||||
IsStreaming: true,
|
||||
|
||||
@@ -110,10 +110,16 @@ func AgenticMessageToEino(msg *schema.AgenticMessage) []*schema.Message {
|
||||
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{
|
||||
ID: block.FunctionToolCall.CallID,
|
||||
Type: "function",
|
||||
Index: index,
|
||||
ID: block.FunctionToolCall.CallID,
|
||||
Type: "function",
|
||||
Function: schema.FunctionCall{
|
||||
Name: block.FunctionToolCall.Name,
|
||||
Arguments: block.FunctionToolCall.Arguments,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@ package multiagent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/cloudwego/eino/adk"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
@@ -139,7 +138,7 @@ func (h *einoAssistantStreamEventHandler) Handle(mv *adk.MessageVariant, agentNa
|
||||
streamUsage = maxEinoTokenUsage(streamUsage, chunk.ResponseMeta.Usage)
|
||||
}
|
||||
})
|
||||
if recvErr != nil && !errors.Is(recvErr, context.Canceled) && h.logger != nil {
|
||||
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),
|
||||
|
||||
@@ -55,6 +55,70 @@ func TestEinoExtractFallbackAssistantFromMsgs_prefersToolOverEarlierAssistant(t
|
||||
}
|
||||
}
|
||||
|
||||
func TestEinoExtractFallbackAssistantFromMsgs_plainAssistant(t *testing.T) {
|
||||
msgs := []*schema.Message{
|
||||
schema.UserMessage("hi"),
|
||||
schema.AssistantMessage("plain answer", nil),
|
||||
}
|
||||
if got := einoExtractFallbackAssistantFromMsgs(msgs); got != "plain answer" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEinoExtractFallbackAssistantFromMsgs_finalAssistantAfterToolResult(t *testing.T) {
|
||||
msgs := []*schema.Message{
|
||||
schema.UserMessage("hi"),
|
||||
schema.AssistantMessage("", []schema.ToolCall{{
|
||||
ID: "call-1",
|
||||
Type: "function",
|
||||
Function: schema.FunctionCall{
|
||||
Name: "execute",
|
||||
Arguments: `{"command":"pwd"}`,
|
||||
},
|
||||
}}),
|
||||
schema.ToolMessage("/tmp", "call-1", schema.WithToolName("execute")),
|
||||
schema.AssistantMessage("final after tool", nil),
|
||||
}
|
||||
if got := einoExtractFallbackAssistantFromMsgs(msgs); got != "final after tool" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEinoExtractFallbackAssistantFromMsgs_doesNotUseAssistantBeforeUnfinishedToolResult(t *testing.T) {
|
||||
msgs := []*schema.Message{
|
||||
schema.UserMessage("hi"),
|
||||
schema.AssistantMessage("I will inspect that.", nil),
|
||||
schema.AssistantMessage("", []schema.ToolCall{{
|
||||
ID: "call-1",
|
||||
Type: "function",
|
||||
Function: schema.FunctionCall{
|
||||
Name: "execute",
|
||||
Arguments: `{"command":"pwd"}`,
|
||||
},
|
||||
}}),
|
||||
schema.ToolMessage("/tmp", "call-1", schema.WithToolName("execute")),
|
||||
}
|
||||
if got := einoExtractFallbackAssistantFromMsgs(msgs); got != "" {
|
||||
t.Fatalf("got %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEinoRunResultBuilderFinalFallsBackToPlainAssistantTrace(t *testing.T) {
|
||||
runMessages := newEinoRunMessageAccumulator(nil)
|
||||
runMessages.Append(schema.UserMessage("hi"))
|
||||
runMessages.Append(schema.AssistantMessage("plain answer", nil))
|
||||
|
||||
got := newEinoRunResultBuilder(einoRunResultBuilderConfig{
|
||||
OrchMode: "deep",
|
||||
EmptyHint: "empty",
|
||||
RunMessages: runMessages,
|
||||
}).BuildFinal()
|
||||
|
||||
if got.Response != "plain answer" {
|
||||
t.Fatalf("response = %q, want plain answer", got.Response)
|
||||
}
|
||||
}
|
||||
|
||||
func toolExitMsg(content, callID string) *schema.Message {
|
||||
m := schema.ToolMessage(content, callID)
|
||||
m.ToolName = "exit"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package multiagent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"cyberstrike-ai/internal/agent"
|
||||
@@ -10,6 +11,7 @@ import (
|
||||
"cyberstrike-ai/internal/mcp"
|
||||
|
||||
"github.com/cloudwego/eino/adk"
|
||||
"github.com/cloudwego/eino/components/tool"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
@@ -26,7 +28,7 @@ func TestEinoADKFilesystemToolMonitorBindsFinishesAndUpdatesDisplayResult(t *tes
|
||||
recorded = append(recorded, executionID+"|"+toolCallID)
|
||||
})
|
||||
|
||||
beginEinoADKFilesystemToolMonitor(ctx, ag, rec, binder, "call-read", "read_file")
|
||||
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")
|
||||
@@ -38,6 +40,9 @@ func TestEinoADKFilesystemToolMonitorBindsFinishesAndUpdatesDisplayResult(t *tes
|
||||
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{
|
||||
@@ -80,3 +85,86 @@ func TestEinoADKFilesystemToolMonitorBindsFinishesAndUpdatesDisplayResult(t *tes
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"time"
|
||||
|
||||
"cyberstrike-ai/internal/config"
|
||||
"cyberstrike-ai/internal/llm"
|
||||
"cyberstrike-ai/internal/openai"
|
||||
"cyberstrike-ai/internal/reasoning"
|
||||
|
||||
@@ -49,7 +50,7 @@ func newEinoBaseHTTPClient() *http.Client {
|
||||
}
|
||||
}
|
||||
|
||||
func newEinoOpenAIChatModelFactory(
|
||||
func newEinoToolCallingChatModelFactory(
|
||||
baseHTTPClient *http.Client,
|
||||
reasoningClient *reasoning.ClientIntent,
|
||||
logger *zap.Logger,
|
||||
@@ -58,6 +59,13 @@ func newEinoOpenAIChatModelFactory(
|
||||
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()
|
||||
@@ -81,7 +89,7 @@ func newEinoOpenAIChatModelFactory(
|
||||
}
|
||||
}
|
||||
|
||||
func newEinoOpenAIAgenticChatModelFactory(
|
||||
func newEinoAgenticChatModelFactory(
|
||||
baseHTTPClient *http.Client,
|
||||
reasoningClient *reasoning.ClientIntent,
|
||||
logger *zap.Logger,
|
||||
@@ -90,8 +98,11 @@ func newEinoOpenAIAgenticChatModelFactory(
|
||||
baseHTTPClient = newEinoBaseHTTPClient()
|
||||
}
|
||||
return func(ctx context.Context, oa config.OpenAIConfig, mode einoModelMode) (model.AgenticModel, error) {
|
||||
if !supportsEinoAgenticOpenAIBackend(oa) {
|
||||
return nil, fmt.Errorf("eino agentic model: provider %q is not enabled for agenticopenai backend", strings.TrimSpace(oa.Provider))
|
||||
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)
|
||||
@@ -111,9 +122,36 @@ func newEinoOpenAIAgenticChatModelFactory(
|
||||
}
|
||||
}
|
||||
|
||||
func supportsEinoAgenticOpenAIBackend(oa config.OpenAIConfig) bool {
|
||||
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"
|
||||
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 {
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
"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"
|
||||
@@ -307,9 +308,9 @@ func TestNewEinoAgenticModelFailoverConfigEmitsProgressEvent(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewEinoOpenAIAgenticChatModelFactoryBuildsBackend(t *testing.T) {
|
||||
func TestNewEinoAgenticChatModelFactoryBuildsOpenAIBackend(t *testing.T) {
|
||||
t.Parallel()
|
||||
factory := newEinoOpenAIAgenticChatModelFactory(newEinoBaseHTTPClient(), nil, nil)
|
||||
factory := newEinoAgenticChatModelFactory(newEinoBaseHTTPClient(), nil, nil)
|
||||
m, err := factory(context.Background(), config.OpenAIConfig{
|
||||
Provider: "openai",
|
||||
APIKey: "test-key",
|
||||
@@ -338,16 +339,23 @@ func TestNewEinoOpenAIAgenticChatModelFactoryBuildsBackend(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewEinoOpenAIAgenticChatModelFactoryRejectsUnsupportedProvider(t *testing.T) {
|
||||
func TestNewEinoAgenticChatModelFactoryBuildsNativeClaudeBackend(t *testing.T) {
|
||||
t.Parallel()
|
||||
factory := newEinoOpenAIAgenticChatModelFactory(newEinoBaseHTTPClient(), nil, nil)
|
||||
if _, err := factory(context.Background(), config.OpenAIConfig{
|
||||
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); err == nil {
|
||||
t.Fatal("expected unsupported provider error")
|
||||
}, 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",
|
||||
@@ -355,8 +363,25 @@ func TestNewEinoOpenAIAgenticChatModelFactoryRejectsUnsupportedProvider(t *testi
|
||||
BaseURL: "https://api.anthropic.com/v1",
|
||||
Model: "claude-sonnet-4",
|
||||
}, einoModelModeNormal), einoAgenticRuntimeSupportV0914())
|
||||
if gate.Ready || !containsString(gate.Missing, "model.AgenticModel backend") {
|
||||
t.Fatalf("gate = %#v, want backend missing for unsupported provider", gate)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@ package multiagent
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/cloudwego/eino/adk"
|
||||
)
|
||||
@@ -82,12 +84,109 @@ func (h *einoRunErrorHandler) emitError(err error, kind string) {
|
||||
if h == nil || h.progress == nil || err == nil {
|
||||
return
|
||||
}
|
||||
userErr := einoUserFacingRunError(err)
|
||||
data := map[string]interface{}{
|
||||
"conversationId": h.conversationID,
|
||||
"source": "eino",
|
||||
"error": err.Error(),
|
||||
}
|
||||
if kind != "" {
|
||||
data["errorKind"] = kind
|
||||
} else if userErr.kind != "" {
|
||||
data["errorKind"] = userErr.kind
|
||||
}
|
||||
h.progress("error", err.Error(), data)
|
||||
if userErr.summary != "" {
|
||||
data["errorSummary"] = userErr.summary
|
||||
}
|
||||
if userErr.retryExhausted {
|
||||
data["retryExhausted"] = true
|
||||
if userErr.totalRetries > 0 {
|
||||
data["totalRetries"] = userErr.totalRetries
|
||||
}
|
||||
}
|
||||
if userErr.rawLastError != "" {
|
||||
data["lastError"] = userErr.rawLastError
|
||||
}
|
||||
if userErr.technicalError != "" {
|
||||
data["technicalError"] = userErr.technicalError
|
||||
}
|
||||
if userErr.hasModelOriginalError {
|
||||
data["modelOriginalError"] = userErr.rawLastError
|
||||
} else if userErr.retryExhausted {
|
||||
data["hasModelOriginalError"] = false
|
||||
}
|
||||
message := err.Error()
|
||||
if userErr.message != "" {
|
||||
message = userErr.message
|
||||
}
|
||||
h.progress("error", message, data)
|
||||
}
|
||||
|
||||
type einoRunUserError struct {
|
||||
message string
|
||||
kind string
|
||||
summary string
|
||||
rawLastError string
|
||||
technicalError string
|
||||
retryExhausted bool
|
||||
totalRetries int
|
||||
hasModelOriginalError bool
|
||||
}
|
||||
|
||||
func einoUserFacingRunError(err error) einoRunUserError {
|
||||
var out einoRunUserError
|
||||
if err == nil {
|
||||
return out
|
||||
}
|
||||
var retryErr *adk.RetryExhaustedError
|
||||
if !errors.As(err, &retryErr) {
|
||||
return out
|
||||
}
|
||||
out.retryExhausted = true
|
||||
out.totalRetries = retryErr.TotalRetries
|
||||
lastErr := retryErr.LastErr
|
||||
if lastErr == nil {
|
||||
out.kind = "model_retry_exhausted"
|
||||
out.summary = "模型调用多次重试后仍未成功。"
|
||||
out.message = out.summary
|
||||
return out
|
||||
}
|
||||
out.rawLastError = strings.TrimSpace(lastErr.Error())
|
||||
if isEinoShouldRetryOutputRejected(lastErr) {
|
||||
out.kind = "model_output_rejected"
|
||||
out.summary = "模型未返回原始错误;输出被重试策略拒绝。"
|
||||
out.technicalError = out.rawLastError
|
||||
out.message = formatEinoRetryExhaustedMessage(out.summary, retryErr.TotalRetries)
|
||||
return out
|
||||
}
|
||||
kind, summary := einoTransientRunErrorUserDetail(lastErr)
|
||||
if strings.TrimSpace(summary) == "" {
|
||||
summary = einoTrimRetryErrorSummary(lastErr.Error())
|
||||
}
|
||||
if kind == "" {
|
||||
kind = "model_retry_exhausted"
|
||||
}
|
||||
out.kind = kind
|
||||
out.summary = summary
|
||||
out.hasModelOriginalError = out.rawLastError != ""
|
||||
out.message = formatEinoRetryExhaustedMessage(summary, retryErr.TotalRetries)
|
||||
return out
|
||||
}
|
||||
|
||||
func isEinoShouldRetryOutputRejected(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(strings.ToLower(err.Error()), "model output rejected by shouldretry")
|
||||
}
|
||||
|
||||
func formatEinoRetryExhaustedMessage(summary string, totalRetries int) string {
|
||||
summary = strings.TrimSpace(summary)
|
||||
if summary == "" {
|
||||
summary = "模型调用多次重试后仍未成功。"
|
||||
}
|
||||
if totalRetries > 0 {
|
||||
return fmt.Sprintf("模型调用重试已耗尽(已重试 %d 次):%s", totalRetries, summary)
|
||||
}
|
||||
return "模型调用重试已耗尽:" + summary
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package multiagent
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/cloudwego/eino/adk"
|
||||
@@ -61,6 +62,99 @@ func TestEinoRunErrorHandlerTimeoutAndGeneralErrorProgress(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEinoRunErrorHandlerRetryExhaustedEmptyOutputProgress(t *testing.T) {
|
||||
err := &adk.RetryExhaustedError{
|
||||
LastErr: errors.New("model output rejected by ShouldRetry at attempt 5"),
|
||||
TotalRetries: 4,
|
||||
}
|
||||
var message string
|
||||
var data map[string]interface{}
|
||||
|
||||
got := newEinoRunErrorHandler(einoRunErrorHandlerConfig{
|
||||
ConversationID: "conv-1",
|
||||
Progress: func(eventType, msg string, raw interface{}) {
|
||||
if eventType == "error" {
|
||||
message = msg
|
||||
data, _ = raw.(map[string]interface{})
|
||||
}
|
||||
},
|
||||
}).Handle(err)
|
||||
|
||||
if !errors.Is(got, err) {
|
||||
t.Fatalf("err = %v", got)
|
||||
}
|
||||
if !strings.Contains(message, "模型调用重试已耗尽") ||
|
||||
!strings.Contains(message, "模型未返回原始错误;输出被重试策略拒绝。") ||
|
||||
strings.Contains(message, "model output rejected by ShouldRetry at attempt 5") {
|
||||
t.Fatalf("message = %q", message)
|
||||
}
|
||||
if data["errorKind"] != "model_output_rejected" {
|
||||
t.Fatalf("errorKind = %#v", data["errorKind"])
|
||||
}
|
||||
if data["errorSummary"] != "模型未返回原始错误;输出被重试策略拒绝。" {
|
||||
t.Fatalf("errorSummary = %#v", data["errorSummary"])
|
||||
}
|
||||
if data["hasModelOriginalError"] != false {
|
||||
t.Fatalf("hasModelOriginalError = %#v", data["hasModelOriginalError"])
|
||||
}
|
||||
if data["retryExhausted"] != true || data["totalRetries"] != 4 {
|
||||
t.Fatalf("retry metadata = %#v", data)
|
||||
}
|
||||
if data["lastError"] != "model output rejected by ShouldRetry at attempt 5" {
|
||||
t.Fatalf("lastError = %#v", data["lastError"])
|
||||
}
|
||||
if data["technicalError"] != "model output rejected by ShouldRetry at attempt 5" {
|
||||
t.Fatalf("technicalError = %#v", data["technicalError"])
|
||||
}
|
||||
if _, ok := data["modelOriginalError"]; ok {
|
||||
t.Fatalf("modelOriginalError should be absent for ShouldRetry rejection, got %#v", data["modelOriginalError"])
|
||||
}
|
||||
if data["error"] != err.Error() {
|
||||
t.Fatalf("raw error = %#v, want %#v", data["error"], err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEinoRunErrorHandlerRetryExhaustedOriginalErrorProgress(t *testing.T) {
|
||||
err := &adk.RetryExhaustedError{
|
||||
LastErr: errors.New("HTTP 429 Too Many Requests"),
|
||||
TotalRetries: 3,
|
||||
}
|
||||
var message string
|
||||
var data map[string]interface{}
|
||||
|
||||
got := newEinoRunErrorHandler(einoRunErrorHandlerConfig{
|
||||
ConversationID: "conv-1",
|
||||
Progress: func(eventType, msg string, raw interface{}) {
|
||||
if eventType == "error" {
|
||||
message = msg
|
||||
data, _ = raw.(map[string]interface{})
|
||||
}
|
||||
},
|
||||
}).Handle(err)
|
||||
|
||||
if !errors.Is(got, err) {
|
||||
t.Fatalf("err = %v", got)
|
||||
}
|
||||
if !strings.Contains(message, "HTTP 429 Too Many Requests") {
|
||||
t.Fatalf("message = %q", message)
|
||||
}
|
||||
if data["errorKind"] != "rate_limit" {
|
||||
t.Fatalf("errorKind = %#v", data["errorKind"])
|
||||
}
|
||||
if data["errorSummary"] != "HTTP 429 Too Many Requests" {
|
||||
t.Fatalf("errorSummary = %#v", data["errorSummary"])
|
||||
}
|
||||
if data["lastError"] != "HTTP 429 Too Many Requests" {
|
||||
t.Fatalf("lastError = %#v", data["lastError"])
|
||||
}
|
||||
if data["modelOriginalError"] != "HTTP 429 Too Many Requests" {
|
||||
t.Fatalf("modelOriginalError = %#v", data["modelOriginalError"])
|
||||
}
|
||||
if _, ok := data["hasModelOriginalError"]; ok {
|
||||
t.Fatalf("hasModelOriginalError should be absent when original error is present, got %#v", data["hasModelOriginalError"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestEinoRunErrorHandlerIterationLimitProgress(t *testing.T) {
|
||||
var events []string
|
||||
var errorKind interface{}
|
||||
|
||||
@@ -228,6 +228,7 @@ func (d *einoRunEventDrain) markPendingWithMonitor(tc toolCallPendingInfo) {
|
||||
d.cfg.MCPExecutionBinder,
|
||||
tc.ToolCallID,
|
||||
tc.ToolName,
|
||||
tc.Arguments,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -57,6 +57,16 @@ func (a *einoRunMessageAccumulator) Messages() []adk.Message {
|
||||
return a.msgs
|
||||
}
|
||||
|
||||
func (a *einoRunMessageAccumulator) NewMessages() []adk.Message {
|
||||
if a == nil {
|
||||
return nil
|
||||
}
|
||||
if a.baseCount < 0 || a.baseCount >= len(a.msgs) {
|
||||
return nil
|
||||
}
|
||||
return a.msgs[a.baseCount:]
|
||||
}
|
||||
|
||||
func (a *einoRunMessageAccumulator) BaseCount() int {
|
||||
if a == nil {
|
||||
return 0
|
||||
|
||||
@@ -27,6 +27,10 @@ func TestEinoRunMessageAccumulatorTracksBaseAndAppends(t *testing.T) {
|
||||
if len(msgs) != 2 || msgs[1].Role != schema.Assistant || msgs[1].Content != "hello" {
|
||||
t.Fatalf("messages = %#v", msgs)
|
||||
}
|
||||
newMsgs := acc.NewMessages()
|
||||
if len(newMsgs) != 1 || newMsgs[0].Content != "hello" {
|
||||
t.Fatalf("new messages = %#v", newMsgs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEinoRunMessageAccumulatorToolMessage(t *testing.T) {
|
||||
|
||||
@@ -98,6 +98,39 @@ func TestEinoRunProgressTrackerDedupesToolCalls(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEinoRunProgressTrackerDedupesSameToolCallIDsWithDifferentArgs(t *testing.T) {
|
||||
var toolCalls int
|
||||
progress := func(eventType, _ string, _ interface{}) {
|
||||
if eventType == "tool_call" {
|
||||
toolCalls++
|
||||
}
|
||||
}
|
||||
tracker := newEinoRunProgressTracker("deep", "lead", "conv-1", progress, nil, nil)
|
||||
first := &schema.Message{ToolCalls: []schema.ToolCall{{
|
||||
ID: "call-1",
|
||||
Type: "function",
|
||||
Function: schema.FunctionCall{
|
||||
Name: "nmap",
|
||||
Arguments: `{"host":"10.0.0.1"}`,
|
||||
},
|
||||
}}}
|
||||
second := &schema.Message{ToolCalls: []schema.ToolCall{{
|
||||
ID: "call-1",
|
||||
Type: "function",
|
||||
Function: schema.FunctionCall{
|
||||
Name: "nmap",
|
||||
Arguments: `{"host":"10.0.0.1","ports":"1-1024"}`,
|
||||
},
|
||||
}}}
|
||||
|
||||
tracker.EmitToolCalls(first, "lead", nil)
|
||||
tracker.EmitToolCalls(second, "lead", nil)
|
||||
|
||||
if toolCalls != 1 {
|
||||
t.Fatalf("tool call events = %d, want 1", toolCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEinoRunProgressTrackerHidesModelOutputRecoveryToolCalls(t *testing.T) {
|
||||
var eventTypes []string
|
||||
var marked []toolCallPendingInfo
|
||||
|
||||
@@ -45,7 +45,7 @@ func (b *einoRunResultBuilder) BuildFinal() *RunResult {
|
||||
func (b *einoRunResultBuilder) build(partial bool) *RunResult {
|
||||
var runMsgs []adk.Message
|
||||
if b.cfg.RunMessages != nil {
|
||||
runMsgs = b.cfg.RunMessages.Messages()
|
||||
runMsgs = b.cfg.RunMessages.NewMessages()
|
||||
}
|
||||
var lastAssistant string
|
||||
var lastPlanExecuteExecutor string
|
||||
@@ -107,6 +107,9 @@ func buildEinoRunResultFromAccumulated(
|
||||
if cleaned == "" {
|
||||
if fb := strings.TrimSpace(einoExtractFallbackAssistantFromMsgs(runAccumulatedMsgs)); fb != "" {
|
||||
cleaned = fb
|
||||
if orchMode == "plan_execute" {
|
||||
cleaned = UnwrapPlanExecuteUserText(cleaned)
|
||||
}
|
||||
}
|
||||
}
|
||||
cleaned = dedupeRepeatedParagraphs(cleaned, 80)
|
||||
@@ -146,32 +149,38 @@ func markModelFacingTraceForPersistence(msgs []adk.Message) []adk.Message {
|
||||
return out
|
||||
}
|
||||
|
||||
// einoExtractFallbackAssistantFromMsgs 在「主通道未产出助手正文」时,从 Eino ADK 轨迹中回填用户可见回复。
|
||||
// 典型场景:监督者仅调用 exit(final_result 落在 Tool 消息中),或工具结果已写入历史但 lastAssistant 未更新。
|
||||
// einoExtractFallbackAssistantFromMsgs 在「主通道未产出助手正文」时,从 Eino ADK
|
||||
// 原生消息轨迹中回填用户可见回复。这里保持克制:只采纳倒序最近的可交付终态,
|
||||
// 避免把工具调用前的过渡语或子任务过程误升为最终回复。
|
||||
//
|
||||
// 优先级:最后一次 exit 工具输出 → 最后一条含 exit 的助手 tool_calls 参数中的 final_result。
|
||||
// 可交付终态:
|
||||
// - exit 工具输出;
|
||||
// - assistant 调用 exit 时 arguments.final_result;
|
||||
// - 没有后续普通工具结果截断的纯 assistant 正文。
|
||||
func einoExtractFallbackAssistantFromMsgs(msgs []adk.Message) string {
|
||||
for i := len(msgs) - 1; i >= 0; i-- {
|
||||
m := msgs[i]
|
||||
if m == nil || m.Role != schema.Tool {
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
if !strings.EqualFold(strings.TrimSpace(m.ToolName), adk.ToolInfoExit.Name) {
|
||||
continue
|
||||
}
|
||||
content := strings.TrimSpace(m.Content)
|
||||
if content == "" || strings.HasPrefix(content, einomcp.ToolErrorPrefix) {
|
||||
continue
|
||||
}
|
||||
return content
|
||||
}
|
||||
for i := len(msgs) - 1; i >= 0; i-- {
|
||||
m := msgs[i]
|
||||
if m == nil || m.Role != schema.Assistant {
|
||||
continue
|
||||
}
|
||||
if s := einoExtractExitFinalFromAssistantToolCalls(m); s != "" {
|
||||
return s
|
||||
switch m.Role {
|
||||
case schema.Tool:
|
||||
if strings.EqualFold(strings.TrimSpace(m.ToolName), adk.ToolInfoExit.Name) {
|
||||
content := strings.TrimSpace(m.Content)
|
||||
if content != "" && !strings.HasPrefix(content, einomcp.ToolErrorPrefix) {
|
||||
return content
|
||||
}
|
||||
}
|
||||
return ""
|
||||
case schema.Assistant:
|
||||
if s := einoExtractExitFinalFromAssistantToolCalls(m); s != "" {
|
||||
return s
|
||||
}
|
||||
if len(m.ToolCalls) == 0 {
|
||||
if content := strings.TrimSpace(m.Content); content != "" {
|
||||
return content
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
|
||||
@@ -55,6 +55,24 @@ func TestEinoRunResultBuilderFinalUsesSnapshots(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEinoRunResultBuilderFallbackIgnoresBaseHistory(t *testing.T) {
|
||||
runMessages := newEinoRunMessageAccumulator([]adk.Message{
|
||||
schema.UserMessage("previous request"),
|
||||
schema.AssistantMessage("previous answer", nil),
|
||||
schema.UserMessage("new request"),
|
||||
})
|
||||
|
||||
got := newEinoRunResultBuilder(einoRunResultBuilderConfig{
|
||||
OrchMode: "deep",
|
||||
EmptyHint: "empty",
|
||||
RunMessages: runMessages,
|
||||
}).BuildFinal()
|
||||
|
||||
if got.Response != "empty" {
|
||||
t.Fatalf("response = %q, want empty hint", got.Response)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEinoRunResultBuilderPlanExecutePrefersExecutorOutput(t *testing.T) {
|
||||
runMessages := newEinoRunMessageAccumulator(nil)
|
||||
runMessages.Append(schema.AssistantMessage(`{"response":"planner text"}`, nil))
|
||||
@@ -73,3 +91,18 @@ func TestEinoRunResultBuilderPlanExecutePrefersExecutorOutput(t *testing.T) {
|
||||
t.Fatalf("response = %q, want executor text", got.Response)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEinoRunResultBuilderPlanExecuteUnwrapsFallbackAssistant(t *testing.T) {
|
||||
runMessages := newEinoRunMessageAccumulator(nil)
|
||||
runMessages.Append(schema.AssistantMessage(`{"response":"fallback executor text"}`, nil))
|
||||
|
||||
got := newEinoRunResultBuilder(einoRunResultBuilderConfig{
|
||||
OrchMode: "plan_execute",
|
||||
EmptyHint: "empty",
|
||||
RunMessages: runMessages,
|
||||
}).BuildFinal()
|
||||
|
||||
if got.Response != "fallback executor text" {
|
||||
t.Fatalf("response = %q, want fallback executor text", got.Response)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,6 +126,9 @@ func (s *einoRunRuntimeSession) HandleRunError(runErr error) einoRunRuntimeError
|
||||
if s == nil || runErr == nil {
|
||||
return einoRunRuntimeErrorResult{}
|
||||
}
|
||||
if isEinoTurnLoopPreemptErr(s.ctx, runErr) {
|
||||
return einoRunRuntimeErrorResult{}
|
||||
}
|
||||
restarted, fatal := s.maybeRestart(runErr)
|
||||
if fatal != nil {
|
||||
result, err := s.takePartial(fatal)
|
||||
@@ -368,5 +371,9 @@ func (s *einoRunRuntimeSession) emitUsageSummary(reason string) bool {
|
||||
if s == nil || s.usage == nil {
|
||||
return false
|
||||
}
|
||||
return s.usage.EmitOnce(s.conversationID, s.orchMode, reason, s.progress, s.logger)
|
||||
modelName := ""
|
||||
if s.args != nil {
|
||||
modelName = s.args.ModelName
|
||||
}
|
||||
return s.usage.EmitOnce(s.conversationID, s.orchMode, reason, modelName, s.progress, s.logger)
|
||||
}
|
||||
|
||||
@@ -153,6 +153,44 @@ func TestEinoRunRuntimeSessionCancellationReturnsPartialError(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEinoRunRuntimeSessionHandleRunErrorSwallowsTurnLoopPreempt(t *testing.T) {
|
||||
agent := &fakeRuntimeSessionAgent{}
|
||||
var events []string
|
||||
drain := newEinoRunEventDrain(einoRunEventDrainConfig{
|
||||
ConversationID: "conv-1",
|
||||
OrchMode: "deep",
|
||||
OrchestratorName: "lead",
|
||||
Progress: func(eventType, _ string, _ interface{}) {
|
||||
events = append(events, eventType)
|
||||
},
|
||||
BaseMessages: []adk.Message{schema.UserMessage("base")},
|
||||
})
|
||||
session := newEinoRunRuntimeSession(einoRunRuntimeSessionConfig{
|
||||
Context: context.Background(),
|
||||
Args: &einoADKRunLoopArgs{
|
||||
ConversationID: "conv-1",
|
||||
OrchMode: "deep",
|
||||
OrchestratorName: "lead",
|
||||
Progress: func(eventType, _ string, _ interface{}) {
|
||||
events = append(events, eventType)
|
||||
},
|
||||
DA: agent,
|
||||
},
|
||||
Drain: drain,
|
||||
BaseMessages: []adk.Message{schema.UserMessage("base")},
|
||||
EmptyHint: "empty",
|
||||
})
|
||||
defer session.Close()
|
||||
|
||||
got := session.HandleRunError(adk.ErrStreamCanceled)
|
||||
if got.Restarted || got.Result != nil || got.Err != nil {
|
||||
t.Fatalf("result = %+v, want swallowed preempt", got)
|
||||
}
|
||||
if containsString(events, "error") || containsString(events, "eino_usage_summary") {
|
||||
t.Fatalf("events = %#v, want no fatal/partial events", events)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEinoRunRuntimeSessionBuildFinalEmitsUsageSummary(t *testing.T) {
|
||||
agent := &fakeRuntimeSessionAgent{}
|
||||
var usageEvent map[string]interface{}
|
||||
|
||||
@@ -61,6 +61,7 @@ func (a *einoRunUsageAccumulator) EmitOnce(
|
||||
conversationID string,
|
||||
orchestration string,
|
||||
reason string,
|
||||
modelName string,
|
||||
progress func(eventType, message string, data interface{}),
|
||||
logger *zap.Logger,
|
||||
) bool {
|
||||
@@ -81,6 +82,7 @@ func (a *einoRunUsageAccumulator) EmitOnce(
|
||||
"source": "eino",
|
||||
"orchestration": orchestration,
|
||||
"reason": reason,
|
||||
"model": modelName,
|
||||
"modelCalls": s.ModelCalls,
|
||||
"promptTokens": s.PromptTokens,
|
||||
"completionTokens": s.CompletionTokens,
|
||||
@@ -96,6 +98,7 @@ func (a *einoRunUsageAccumulator) EmitOnce(
|
||||
zap.String("conversationId", conversationID),
|
||||
zap.String("orchestration", orchestration),
|
||||
zap.String("reason", reason),
|
||||
zap.String("model", modelName),
|
||||
zap.Int("modelCalls", s.ModelCalls),
|
||||
zap.Int("promptTokens", s.PromptTokens),
|
||||
zap.Int("completionTokens", s.CompletionTokens),
|
||||
|
||||
@@ -49,16 +49,16 @@ func TestEinoRunUsageAccumulatorEmitOnce(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
if !acc.EmitOnce("conv-1", "deep", "final", progress, nil) {
|
||||
if !acc.EmitOnce("conv-1", "deep", "final", "gpt-test", progress, nil) {
|
||||
t.Fatal("first emit should return true")
|
||||
}
|
||||
if acc.EmitOnce("conv-1", "deep", "partial", progress, nil) {
|
||||
if acc.EmitOnce("conv-1", "deep", "partial", "gpt-test", progress, nil) {
|
||||
t.Fatal("second emit should return false")
|
||||
}
|
||||
if len(events) != 1 {
|
||||
t.Fatalf("events = %#v, want one usage summary", events)
|
||||
}
|
||||
if events[0]["conversationId"] != "conv-1" || events[0]["orchestration"] != "deep" || events[0]["reason"] != "final" || events[0]["totalTokens"] != 3 {
|
||||
if events[0]["conversationId"] != "conv-1" || events[0]["orchestration"] != "deep" || events[0]["reason"] != "final" || events[0]["model"] != "gpt-test" || events[0]["totalTokens"] != 3 {
|
||||
t.Fatalf("event = %#v", events[0])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,7 +90,7 @@ func RunEinoSingleChatModelAgent(
|
||||
}
|
||||
|
||||
baseHTTPClient := newEinoBaseHTTPClient()
|
||||
agenticModelFactory := newEinoOpenAIAgenticChatModelFactory(baseHTTPClient, reasoningClient, logger)
|
||||
agenticModelFactory := newEinoAgenticChatModelFactory(baseHTTPClient, reasoningClient, logger)
|
||||
mainModel, err := agenticModelFactory(ctx, appCfg.OpenAI, einoModelModeNormal)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("eino single agentic 模型: %w", err)
|
||||
@@ -120,7 +120,7 @@ func RunEinoSingleChatModelAgent(
|
||||
}
|
||||
if einoSkillMW != nil {
|
||||
if einoFSTools && einoLoc != nil {
|
||||
fsMw, fsErr := subAgentAgenticFilesystemMiddleware(ctx, einoLoc, toolInvokeNotify, einoSingleAgentName, einoExecBegin, einoExecAppendPartial, einoExecRegisterCancel, einoExecUnregisterCancel, einoExecFinish, agentToolTimeoutMinutes(appCfg), agentToolWaitTimeoutSeconds(appCfg), agentShellNoOutputTimeoutSeconds(appCfg), nil)
|
||||
fsMw, fsErr := subAgentAgenticFilesystemMiddleware(ctx, einoLoc, toolInvokeNotify, einoSingleAgentName, conversationID, projectID, ma.EinoMiddleware.ReductionRootDir, toolMaxBytesFromMW(&ma.EinoMiddleware), mcpExecBinder, einoExecBegin, einoExecAppendPartial, einoExecRegisterCancel, einoExecUnregisterCancel, einoExecFinish, agentToolTimeoutMinutes(appCfg), agentToolWaitTimeoutSeconds(appCfg), agentShellNoOutputTimeoutSeconds(appCfg), nil)
|
||||
if fsErr != nil {
|
||||
return nil, fmt.Errorf("eino single filesystem 中间件: %w", fsErr)
|
||||
}
|
||||
@@ -203,15 +203,18 @@ func RunEinoSingleChatModelAgent(
|
||||
}
|
||||
|
||||
return runEinoADKAgentLoop(ctx, &einoADKRunLoopArgs{
|
||||
OrchMode: "eino_single",
|
||||
OrchestratorName: einoSingleAgentName,
|
||||
ConversationID: conversationID,
|
||||
Progress: progress,
|
||||
Logger: logger,
|
||||
SnapshotMCPIDs: snapshotMCPIDs,
|
||||
StreamsMainAssistant: streamsMainAssistant,
|
||||
EinoRoleTag: einoRoleTag,
|
||||
CheckpointDir: ma.EinoMiddleware.CheckpointDir,
|
||||
OrchMode: "eino_single",
|
||||
OrchestratorName: einoSingleAgentName,
|
||||
ConversationID: conversationID,
|
||||
Progress: progress,
|
||||
Logger: logger,
|
||||
SnapshotMCPIDs: snapshotMCPIDs,
|
||||
StreamsMainAssistant: streamsMainAssistant,
|
||||
EinoRoleTag: einoRoleTag,
|
||||
// Chat history recovery is intentionally centralized in last_react_*.
|
||||
// ADK checkpoints are a second persisted model-state channel and make
|
||||
// stale-context bugs hard to reason about across user turns.
|
||||
CheckpointDir: "",
|
||||
RunRetryMaxAttempts: RunRetryMaxAttemptsFromConfig(&ma.EinoMiddleware),
|
||||
RunRetryMaxBackoffSec: int(einoRunRetryMaxBackoffFromConfig(&ma.EinoMiddleware).Seconds()),
|
||||
McpIDsMu: &mcpIDsMu,
|
||||
|
||||
@@ -2,6 +2,7 @@ package multiagent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -10,11 +11,13 @@ import (
|
||||
"cyberstrike-ai/internal/config"
|
||||
"cyberstrike-ai/internal/einomcp"
|
||||
"cyberstrike-ai/internal/security"
|
||||
"cyberstrike-ai/internal/tooloutput"
|
||||
|
||||
localbk "github.com/cloudwego/eino-ext/adk/backend/local"
|
||||
"github.com/cloudwego/eino/adk"
|
||||
"github.com/cloudwego/eino/adk/middlewares/filesystem"
|
||||
"github.com/cloudwego/eino/adk/middlewares/skill"
|
||||
"github.com/cloudwego/eino/components/tool"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
@@ -100,6 +103,11 @@ func subAgentAgenticFilesystemMiddleware(
|
||||
loc *localbk.Local,
|
||||
invokeNotify *einomcp.ToolInvokeNotifyHolder,
|
||||
einoAgentName string,
|
||||
conversationID string,
|
||||
projectID string,
|
||||
reductionRootDir string,
|
||||
toolMaxBytes int,
|
||||
binder *MCPExecutionBinder,
|
||||
beginMonitor func(toolCallID, command string) string,
|
||||
appendPartialMonitor func(executionID, toolCallID, chunk string),
|
||||
registerCancelMonitor func(executionID string, cancel context.CancelFunc),
|
||||
@@ -113,7 +121,7 @@ func subAgentAgenticFilesystemMiddleware(
|
||||
if loc == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return filesystem.NewTyped[*schema.AgenticMessage](ctx, &filesystem.MiddlewareConfig{
|
||||
mw, err := filesystem.NewTyped[*schema.AgenticMessage](ctx, &filesystem.MiddlewareConfig{
|
||||
Backend: loc,
|
||||
StreamingShell: &einoStreamingShellWrap{
|
||||
inner: security.NewEinoStreamingShell(),
|
||||
@@ -130,6 +138,71 @@ func subAgentAgenticFilesystemMiddleware(
|
||||
shellNoOutputTimeoutSec: shellNoOutputTimeoutSec,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &einoAgenticFilesystemToolMiddleware{
|
||||
TypedChatModelAgentMiddleware: mw,
|
||||
conversationID: conversationID,
|
||||
projectID: projectID,
|
||||
reductionRootDir: reductionRootDir,
|
||||
toolMaxBytes: toolMaxBytes,
|
||||
binder: binder,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type einoAgenticFilesystemToolMiddleware struct {
|
||||
adk.TypedChatModelAgentMiddleware[*schema.AgenticMessage]
|
||||
conversationID string
|
||||
projectID string
|
||||
reductionRootDir string
|
||||
toolMaxBytes int
|
||||
binder *MCPExecutionBinder
|
||||
}
|
||||
|
||||
func (m *einoAgenticFilesystemToolMiddleware) WrapInvokableToolCall(ctx context.Context, endpoint adk.InvokableToolCallEndpoint, tCtx *adk.ToolContext) (adk.InvokableToolCallEndpoint, error) {
|
||||
wrapped, err := m.TypedChatModelAgentMiddleware.WrapInvokableToolCall(ctx, endpoint, tCtx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if tCtx == nil || !isBuiltinEinoADKFilesystemToolName(tCtx.Name) {
|
||||
return wrapped, nil
|
||||
}
|
||||
return func(ctx context.Context, argumentsInJSON string, opts ...tool.Option) (string, error) {
|
||||
args := parseToolArgumentsObject(argumentsInJSON)
|
||||
if len(args) > 0 && m.binder != nil {
|
||||
m.binder.BindArguments(tCtx.CallID, args)
|
||||
}
|
||||
result, runErr := wrapped(ctx, argumentsInJSON, opts...)
|
||||
if runErr != nil {
|
||||
return result, runErr
|
||||
}
|
||||
return m.boundToolResult(tCtx.CallID, result), nil
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *einoAgenticFilesystemToolMiddleware) boundToolResult(toolCallID, result string) string {
|
||||
if m == nil || m.toolMaxBytes <= 0 || len(result) <= m.toolMaxBytes {
|
||||
return result
|
||||
}
|
||||
return tooloutput.BoundWithSpill(result, m.toolMaxBytes, tooloutput.SpillOpts{
|
||||
RootDir: m.reductionRootDir,
|
||||
ProjectID: m.projectID,
|
||||
ConversationID: m.conversationID,
|
||||
ExecutionID: toolCallID,
|
||||
})
|
||||
}
|
||||
|
||||
func parseToolArgumentsObject(raw string) map[string]interface{} {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" || raw == "{}" || raw == "null" {
|
||||
return nil
|
||||
}
|
||||
var args map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(raw), &args); err != nil || len(args) == 0 {
|
||||
return nil
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
// agentToolTimeoutMinutes 返回 agent.tool_timeout_minutes(与 executeToolViaMCP 一致);cfg 为 nil 时 0。
|
||||
|
||||
@@ -46,6 +46,12 @@ func (h *einoStreamErrorHandler) Handle(streamErr error, agentName string) einoS
|
||||
if h == nil || streamErr == nil {
|
||||
return einoStreamErrorHandleResult{}
|
||||
}
|
||||
if isEinoTurnLoopPreemptErr(h.ctx, streamErr) {
|
||||
// Host context is still alive: TurnLoop preempt canceled the in-flight
|
||||
// model/tool stream. Keep the outer iterator open so the queued
|
||||
// interrupt_continue turn can start.
|
||||
return einoStreamErrorHandleResult{Handled: true}
|
||||
}
|
||||
if isInterruptContinue(h.ctx) {
|
||||
result, err := h.partial(streamErr)
|
||||
return einoStreamErrorHandleResult{Handled: true, Result: result, Err: err}
|
||||
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/cloudwego/eino/adk"
|
||||
)
|
||||
|
||||
func TestEinoStreamErrorHandlerEmitsProgressAndRestarts(t *testing.T) {
|
||||
@@ -70,6 +72,34 @@ func TestEinoStreamErrorHandlerRetryFatalUsesPartial(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEinoStreamErrorHandlerTurnLoopPreemptSwallowsStreamCanceled(t *testing.T) {
|
||||
var progressCalled bool
|
||||
var retryCalled bool
|
||||
var partialCalled bool
|
||||
handler := newEinoStreamErrorHandler(
|
||||
context.Background(),
|
||||
"conv-1",
|
||||
func(string, string, interface{}) { progressCalled = true },
|
||||
nil,
|
||||
func(error) (bool, error) {
|
||||
retryCalled = true
|
||||
return false, errors.New("should not retry")
|
||||
},
|
||||
func(error) (*RunResult, error) {
|
||||
partialCalled = true
|
||||
return nil, errors.New("should not take partial")
|
||||
},
|
||||
)
|
||||
|
||||
got := handler.Handle(adk.ErrStreamCanceled, "lead")
|
||||
if !got.Handled || got.Restarted || got.Result != nil || got.Err != nil {
|
||||
t.Fatalf("result = %+v, want swallowed preempt", got)
|
||||
}
|
||||
if progressCalled || retryCalled || partialCalled {
|
||||
t.Fatalf("progressCalled=%v retryCalled=%v partialCalled=%v, want all false", progressCalled, retryCalled, partialCalled)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEinoStreamErrorHandlerInterruptContinueUsesPartialWithoutProgress(t *testing.T) {
|
||||
base := context.Background()
|
||||
ctx, cancel := context.WithCancelCause(base)
|
||||
@@ -103,6 +133,23 @@ func TestEinoStreamErrorHandlerInterruptContinueUsesPartialWithoutProgress(t *te
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsEinoTurnLoopPreemptErr(t *testing.T) {
|
||||
if !isEinoTurnLoopPreemptErr(context.Background(), adk.ErrStreamCanceled) {
|
||||
t.Fatal("alive host + stream canceled should be treated as TurnLoop preempt")
|
||||
}
|
||||
if !isEinoTurnLoopPreemptErr(context.Background(), context.Canceled) {
|
||||
t.Fatal("alive host + context.Canceled should be treated as TurnLoop preempt")
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
if isEinoTurnLoopPreemptErr(ctx, adk.ErrStreamCanceled) {
|
||||
t.Fatal("canceled host should not be treated as TurnLoop preempt")
|
||||
}
|
||||
if isEinoTurnLoopPreemptErr(context.Background(), errors.New("boom")) {
|
||||
t.Fatal("regular errors must stay fatal")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEinoStreamErrorHandlerNilError(t *testing.T) {
|
||||
got := newEinoStreamErrorHandler(context.Background(), "conv", nil, nil, nil, nil).Handle(nil, "lead")
|
||||
if got.Handled || got.Restarted || got.Result != nil || got.Err != nil {
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
package multiagent
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
// concatToolResultChunks 按 Eino 原生语义合并工具结果流:
|
||||
// - 同一 CallID(EventSender 一 call 一 event):schema.ConcatMessages
|
||||
// - 并行工具被摊进同一条流(ToolsNode MergeStreamReaders 扁平化后):
|
||||
// 按 CallID 分列后再 ConcatMessages,等价于 schema.ConcatMessageArray
|
||||
func concatToolResultChunks(chunks []*schema.Message) ([]*schema.Message, error) {
|
||||
if len(chunks) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
if toolResultChunksShareCallID(chunks) {
|
||||
merged, err := schema.ConcatMessages(chunks)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return []*schema.Message{merged}, nil
|
||||
}
|
||||
return concatToolResultChunksByCallID(chunks)
|
||||
}
|
||||
|
||||
func toolResultChunksShareCallID(chunks []*schema.Message) bool {
|
||||
id := ""
|
||||
for _, chunk := range chunks {
|
||||
if chunk == nil {
|
||||
continue
|
||||
}
|
||||
got := strings.TrimSpace(chunk.ToolCallID)
|
||||
if got == "" {
|
||||
continue
|
||||
}
|
||||
if id == "" {
|
||||
id = got
|
||||
continue
|
||||
}
|
||||
if got != id {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func concatToolResultChunksByCallID(chunks []*schema.Message) ([]*schema.Message, error) {
|
||||
type column struct {
|
||||
key string
|
||||
chunks []*schema.Message
|
||||
}
|
||||
var ordered []column
|
||||
index := make(map[string]int)
|
||||
lastKey := ""
|
||||
anon := 0
|
||||
for _, chunk := range chunks {
|
||||
if chunk == nil {
|
||||
continue
|
||||
}
|
||||
key := strings.TrimSpace(chunk.ToolCallID)
|
||||
if key == "" {
|
||||
if lastKey != "" {
|
||||
key = lastKey
|
||||
} else {
|
||||
key = fmt.Sprintf("\x00anon-%d", anon)
|
||||
anon++
|
||||
}
|
||||
}
|
||||
if idx, ok := index[key]; ok {
|
||||
ordered[idx].chunks = append(ordered[idx].chunks, chunk)
|
||||
} else {
|
||||
index[key] = len(ordered)
|
||||
ordered = append(ordered, column{key: key, chunks: []*schema.Message{chunk}})
|
||||
}
|
||||
lastKey = key
|
||||
}
|
||||
out := make([]*schema.Message, 0, len(ordered))
|
||||
for _, col := range ordered {
|
||||
merged, err := schema.ConcatMessages(col.chunks)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.HasPrefix(col.key, "\x00anon-") {
|
||||
merged.ToolCallID = ""
|
||||
}
|
||||
out = append(out, merged)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package multiagent
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
func TestConcatToolResultChunksUsesEinoConcatForSingleCall(t *testing.T) {
|
||||
got, err := concatToolResultChunks([]*schema.Message{
|
||||
schema.ToolMessage("hel", "call-1", schema.WithToolName("execute")),
|
||||
schema.ToolMessage("lo", "call-1", schema.WithToolName("execute")),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("concat: %v", err)
|
||||
}
|
||||
if len(got) != 1 || got[0].ToolCallID != "call-1" || got[0].Content != "hello" || got[0].ToolName != "execute" {
|
||||
t.Fatalf("got = %#v, want one ConcatMessages result", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcatToolResultChunksSplitsParallelCalls(t *testing.T) {
|
||||
got, err := concatToolResultChunks([]*schema.Message{
|
||||
schema.ToolMessage("nmap 1/2 ", "call-1", schema.WithToolName("nmap")),
|
||||
schema.ToolMessage("nmap 2/2 ", "call-2", schema.WithToolName("nmap")),
|
||||
schema.ToolMessage("22/tcp", "call-1", schema.WithToolName("nmap")),
|
||||
schema.ToolMessage("80/tcp", "call-2", schema.WithToolName("nmap")),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("concat: %v", err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("got = %#v, want two calls", got)
|
||||
}
|
||||
if got[0].ToolCallID != "call-1" || got[0].Content != "nmap 1/2 22/tcp" {
|
||||
t.Fatalf("call-1 = %#v", got[0])
|
||||
}
|
||||
if got[1].ToolCallID != "call-2" || got[1].Content != "nmap 2/2 80/tcp" {
|
||||
t.Fatalf("call-2 = %#v", got[1])
|
||||
}
|
||||
}
|
||||
@@ -42,24 +42,42 @@ func (h *einoToolResultEventHandler) HandleStreaming(mv *adk.MessageVariant, age
|
||||
if h == nil || mv == nil || !mv.IsStreaming || mv.MessageStream == nil || mv.Role != schema.Tool {
|
||||
return false
|
||||
}
|
||||
toolName := strings.TrimSpace(mv.ToolName)
|
||||
content, streamToolCallID, streamToolName, recvErr := recvSchemaMessageStream(h.ctx, mv.MessageStream)
|
||||
if toolName == "" {
|
||||
toolName = streamToolName
|
||||
defaultName := strings.TrimSpace(mv.ToolName)
|
||||
msgs, recvErr := recvSchemaToolResultMessages(h.ctx, mv.MessageStream)
|
||||
if isEinoVoluntaryCancelErr(recvErr) && len(msgs) == 0 {
|
||||
msgs = []*schema.Message{schema.ToolMessage("已中断并继续,当前工具调用已停止。", "", schema.WithToolName(defaultName))}
|
||||
}
|
||||
isErr := einoToolResultIsError(toolName, content)
|
||||
content = einoToolResultBody(content)
|
||||
if streamToolCallID != "" && h.runMessages != nil {
|
||||
h.runMessages.AppendToolMessage(content, streamToolCallID, schema.WithToolName(toolName))
|
||||
if len(msgs) == 0 {
|
||||
msgs = []*schema.Message{schema.ToolMessage("", "", schema.WithToolName(defaultName))}
|
||||
}
|
||||
if h.emitter != nil {
|
||||
h.emitter.Emit(h.ctx, toolName, content, streamToolCallID, isErr, agentName)
|
||||
}
|
||||
if recvErr != nil && h.logger != nil {
|
||||
h.logger.Warn("eino tool result stream recv error",
|
||||
zap.Error(recvErr),
|
||||
zap.String("agent", agentName),
|
||||
zap.String("tool", toolName))
|
||||
for _, msg := range msgs {
|
||||
if msg == nil {
|
||||
continue
|
||||
}
|
||||
toolName := strings.TrimSpace(msg.ToolName)
|
||||
if toolName == "" {
|
||||
toolName = defaultName
|
||||
}
|
||||
content := msg.Content
|
||||
if isEinoVoluntaryCancelErr(recvErr) && strings.TrimSpace(content) == "" {
|
||||
content = "已中断并继续,当前工具调用已停止。"
|
||||
}
|
||||
isErr := einoToolResultIsError(toolName, content) || isEinoVoluntaryCancelErr(recvErr)
|
||||
content = einoToolResultBody(content)
|
||||
toolCallID := strings.TrimSpace(msg.ToolCallID)
|
||||
if toolCallID != "" && h.runMessages != nil {
|
||||
h.runMessages.AppendToolMessage(content, toolCallID, schema.WithToolName(toolName))
|
||||
}
|
||||
if h.emitter != nil {
|
||||
h.emitter.Emit(h.ctx, toolName, content, toolCallID, isErr, agentName)
|
||||
}
|
||||
if recvErr != nil && !isEinoVoluntaryCancelErr(recvErr) && h.logger != nil {
|
||||
h.logger.Warn("eino tool result stream recv error",
|
||||
zap.Error(recvErr),
|
||||
zap.String("agent", agentName),
|
||||
zap.String("tool", toolName),
|
||||
zap.String("toolCallId", toolCallID))
|
||||
}
|
||||
}
|
||||
if recvErr == nil && h.confirmRecovery != nil {
|
||||
h.confirmRecovery()
|
||||
|
||||
@@ -59,6 +59,54 @@ func TestEinoToolResultEventHandlerHandlesStreamingToolResult(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEinoToolResultEventHandlerSplitsParallelStreamingResults(t *testing.T) {
|
||||
var events []map[string]interface{}
|
||||
runMessages := newEinoRunMessageAccumulator(nil)
|
||||
emitter := newEinoToolResultProgressEmitter(einoToolResultProgressEmitterConfig{
|
||||
ConversationID: "conv-1",
|
||||
Progress: func(eventType, _ string, data interface{}) {
|
||||
if eventType != "tool_result" {
|
||||
return
|
||||
}
|
||||
m, _ := data.(map[string]interface{})
|
||||
events = append(events, m)
|
||||
},
|
||||
})
|
||||
handler := newEinoToolResultEventHandler(einoToolResultEventHandlerConfig{
|
||||
RunMessages: runMessages,
|
||||
Emitter: emitter,
|
||||
})
|
||||
stream := schema.StreamReaderFromArray([]*schema.Message{
|
||||
{Role: schema.Tool, Content: "nmap 1/2 start ", ToolCallID: "call-1", ToolName: "nmap"},
|
||||
{Role: schema.Tool, Content: "nmap 2/2 start ", ToolCallID: "call-2", ToolName: "nmap"},
|
||||
{Role: schema.Tool, Content: "22/tcp open", ToolCallID: "call-1", ToolName: "nmap"},
|
||||
{Role: schema.Tool, Content: "80/tcp open", ToolCallID: "call-2", ToolName: "nmap"},
|
||||
})
|
||||
mv := &adk.MessageVariant{
|
||||
IsStreaming: true,
|
||||
Role: schema.Tool,
|
||||
ToolName: "nmap",
|
||||
MessageStream: stream,
|
||||
}
|
||||
|
||||
if !handler.HandleStreaming(mv, "worker") {
|
||||
t.Fatal("streaming tool result was not handled")
|
||||
}
|
||||
if len(events) != 2 {
|
||||
t.Fatalf("events = %#v, want two tool_result", events)
|
||||
}
|
||||
if events[0]["toolCallId"] != "call-1" || events[0]["result"] != "nmap 1/2 start 22/tcp open" {
|
||||
t.Fatalf("first event = %#v", events[0])
|
||||
}
|
||||
if events[1]["toolCallId"] != "call-2" || events[1]["result"] != "nmap 2/2 start 80/tcp open" {
|
||||
t.Fatalf("second event = %#v", events[1])
|
||||
}
|
||||
msgs := runMessages.Messages()
|
||||
if len(msgs) != 2 || msgs[0].ToolCallID != "call-1" || msgs[1].ToolCallID != "call-2" {
|
||||
t.Fatalf("run messages = %#v", msgs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEinoToolResultEventHandlerHandlesMaterializedToolResult(t *testing.T) {
|
||||
var event map[string]interface{}
|
||||
emitter := newEinoToolResultProgressEmitter(einoToolResultProgressEmitterConfig{
|
||||
@@ -93,3 +141,39 @@ func TestEinoToolResultEventHandlerIgnoresNonToolOutput(t *testing.T) {
|
||||
t.Fatal("assistant message should not be handled as tool result")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEinoToolResultEventHandlerMarksCanceledStreamAsInterrupted(t *testing.T) {
|
||||
var event map[string]interface{}
|
||||
emitter := newEinoToolResultProgressEmitter(einoToolResultProgressEmitterConfig{
|
||||
ConversationID: "conv-1",
|
||||
Progress: func(eventType, _ string, data interface{}) {
|
||||
if eventType == "tool_result" {
|
||||
event, _ = data.(map[string]interface{})
|
||||
}
|
||||
},
|
||||
})
|
||||
handler := newEinoToolResultEventHandler(einoToolResultEventHandlerConfig{Emitter: emitter})
|
||||
reader, writer := schema.Pipe[*schema.Message](1)
|
||||
writer.Send(nil, adk.ErrStreamCanceled)
|
||||
writer.Close()
|
||||
mv := &adk.MessageVariant{
|
||||
IsStreaming: true,
|
||||
Role: schema.Tool,
|
||||
ToolName: "http-framework-test",
|
||||
MessageStream: reader,
|
||||
}
|
||||
|
||||
if !handler.HandleStreaming(mv, "penetration") {
|
||||
t.Fatal("canceled tool stream was not handled")
|
||||
}
|
||||
if event["toolName"] != "http-framework-test" {
|
||||
t.Fatalf("event = %#v", event)
|
||||
}
|
||||
if event["isError"] != true || event["success"] != false {
|
||||
t.Fatalf("event flags = %#v, want interrupted tool result", event)
|
||||
}
|
||||
result, _ := event["result"].(string)
|
||||
if result == "stream canceled" || result == "" {
|
||||
t.Fatalf("result = %q, want interrupt-continue notice", result)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,7 +115,22 @@ func (e *einoToolResultProgressEmitter) Emit(ctx context.Context, toolName, cont
|
||||
if e.executeStdoutDup != nil {
|
||||
e.executeStdoutDup.Record(toolName, content, displayIsErr)
|
||||
}
|
||||
recordEinoADKFilesystemToolMonitor(ctx, e.filesystemMonitorAgent, e.filesystemMonitorRecord, e.mcpExecutionBinder, toolName, toolCallID, e.messages(), content, displayIsErr)
|
||||
if args := e.toolCallArguments(toolCallID, toolName); len(args) > 0 {
|
||||
data["argumentsObj"] = args
|
||||
data["arguments"] = mustMarshalToolArguments(args)
|
||||
}
|
||||
if execID := recordEinoADKFilesystemToolMonitor(ctx, e.filesystemMonitorAgent, e.filesystemMonitorRecord, e.mcpExecutionBinder, toolName, toolCallID, e.messages(), content, displayIsErr); execID != "" {
|
||||
if stored := e.filesystemMonitorAgent.MCPExecutionResultText(execID); strings.TrimSpace(stored) != "" {
|
||||
content = stored
|
||||
if len(content) > 200 {
|
||||
preview = content[:200] + "..."
|
||||
} else {
|
||||
preview = content
|
||||
}
|
||||
data["result"] = content
|
||||
data["resultPreview"] = preview
|
||||
}
|
||||
}
|
||||
if e.filesystemMonitorAgent != nil && e.mcpExecutionBinder != nil {
|
||||
if execID := e.mcpExecutionBinder.ExecutionID(toolCallID); execID != "" {
|
||||
e.filesystemMonitorAgent.UpdateMCPExecutionDisplayResult(execID, content)
|
||||
@@ -152,3 +167,15 @@ func (e *einoToolResultProgressEmitter) messages() []adk.Message {
|
||||
}
|
||||
return e.runMessages.Messages()
|
||||
}
|
||||
|
||||
func (e *einoToolResultProgressEmitter) toolCallArguments(toolCallID, toolName string) map[string]interface{} {
|
||||
if e == nil {
|
||||
return nil
|
||||
}
|
||||
if e.mcpExecutionBinder != nil {
|
||||
if args := e.mcpExecutionBinder.Arguments(toolCallID); len(args) > 0 {
|
||||
return args
|
||||
}
|
||||
}
|
||||
return toolCallArgsFromAccumulated(e.messages(), toolCallID, toolName)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
package multiagent
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/cloudwego/eino/adk"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
func TestEinoToolResultProgressEmitterInfersPendingAndDedupes(t *testing.T) {
|
||||
var events []map[string]interface{}
|
||||
@@ -135,3 +140,44 @@ func TestEinoToolResultProgressEmitterTruncatesPreview(t *testing.T) {
|
||||
t.Fatalf("preview = %q len=%d", got, len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestEinoToolResultProgressEmitterBackfillsArgumentsFromRunMessages(t *testing.T) {
|
||||
var data map[string]interface{}
|
||||
progress := func(eventType, _ string, raw interface{}) {
|
||||
if eventType == "tool_result" {
|
||||
data, _ = raw.(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/requirements.txt"}`,
|
||||
},
|
||||
}},
|
||||
},
|
||||
})
|
||||
emitter := newEinoToolResultProgressEmitter(einoToolResultProgressEmitterConfig{
|
||||
ConversationID: "conv-1",
|
||||
Progress: progress,
|
||||
RunMessages: runMessages,
|
||||
})
|
||||
|
||||
if !emitter.Emit(nil, "read_file", "ok", "call-read", false, "lead") {
|
||||
t.Fatal("expected tool result emit")
|
||||
}
|
||||
args, ok := data["argumentsObj"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("argumentsObj = %#v", data["argumentsObj"])
|
||||
}
|
||||
if args["path"] != "/tmp/requirements.txt" {
|
||||
t.Fatalf("path = %#v, want /tmp/requirements.txt", args["path"])
|
||||
}
|
||||
if data["arguments"] != `{"path":"/tmp/requirements.txt"}` {
|
||||
t.Fatalf("arguments = %#v", data["arguments"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,6 +107,76 @@ func TestRunEinoADKAgentLoopUsesTurnLoopInterruptPush(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunEinoADKAgentLoopInterruptContinueSurvivesStreamCanceled(t *testing.T) {
|
||||
baseCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
|
||||
pushCh := make(chan func(string) bool, 1)
|
||||
ctx := WithAgentTurnLoopInterruptRegistrar(baseCtx, func(push func(string) bool) func() {
|
||||
pushCh <- push
|
||||
return func() {}
|
||||
})
|
||||
|
||||
mockModel := newTurnLoopHangingStreamModel()
|
||||
agent, err := adk.NewChatModelAgent(ctx, &adk.ChatModelAgentConfig{
|
||||
Name: "turn-loop-agent",
|
||||
Model: mockModel,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewChatModelAgent: %v", err)
|
||||
}
|
||||
|
||||
done := make(chan struct{})
|
||||
var result *RunResult
|
||||
var runErr error
|
||||
go func() {
|
||||
defer close(done)
|
||||
result, runErr = runEinoADKAgentLoop(ctx, &einoADKRunLoopArgs{
|
||||
OrchMode: "deep",
|
||||
OrchestratorName: "turn-loop-agent",
|
||||
ConversationID: "conv-stream-cancel",
|
||||
DA: agent,
|
||||
EmptyResponseMessage: "empty",
|
||||
TurnLoopInterruptTimeout: 20 * time.Millisecond,
|
||||
}, []*schema.Message{schema.UserMessage("initial task")})
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-mockModel.started:
|
||||
case <-ctx.Done():
|
||||
t.Fatal("first stream did not start")
|
||||
}
|
||||
var push func(string) bool
|
||||
select {
|
||||
case push = <-pushCh:
|
||||
case <-ctx.Done():
|
||||
t.Fatal("turn loop interrupt hook was not registered")
|
||||
}
|
||||
if !push("focus emobile") {
|
||||
t.Fatal("turn loop interrupt push was rejected")
|
||||
}
|
||||
|
||||
select {
|
||||
case <-mockModel.started:
|
||||
case <-ctx.Done():
|
||||
t.Fatal("second stream did not start after interrupt")
|
||||
}
|
||||
select {
|
||||
case <-done:
|
||||
case <-ctx.Done():
|
||||
t.Fatal("run loop did not finish")
|
||||
}
|
||||
if runErr != nil {
|
||||
t.Fatalf("runErr = %v, want interrupt-continue to survive stream canceled", runErr)
|
||||
}
|
||||
if result == nil || result.Response != "done" {
|
||||
t.Fatalf("result = %#v, want continued turn output", result)
|
||||
}
|
||||
if isEinoStreamCanceled(runErr) {
|
||||
t.Fatal("stream canceled leaked as the run error")
|
||||
}
|
||||
}
|
||||
|
||||
func containsString(items []string, target string) bool {
|
||||
for _, item := range items {
|
||||
if item == target {
|
||||
|
||||
@@ -2,7 +2,6 @@ package multiagent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
|
||||
@@ -45,9 +44,16 @@ func (b *einoTurnLoopEventBridge) OnAgentEvents(
|
||||
if ev == nil {
|
||||
continue
|
||||
}
|
||||
if ev.Err != nil && isEinoTurnLoopPreemptCancel(tc, ev.Err) {
|
||||
b.emitPreempted()
|
||||
continue
|
||||
if ev.Err != nil && isEinoVoluntaryCancelErr(ev.Err) {
|
||||
// TurnLoop owns cancel routing. Returning CancelError /
|
||||
// ErrStreamCanceled from OnAgentEvents aborts the whole loop and
|
||||
// races with the Preempted signal becoming observable. A preempt
|
||||
// must return nil so the queued user supplement can start the next
|
||||
// turn; a terminal stop is surfaced through TurnLoopExitState.
|
||||
if isEinoTurnLoopPreemptCancel(tc, ev.Err) {
|
||||
b.emitPreempted()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if b.gen != nil {
|
||||
b.gen.Send(ev)
|
||||
@@ -79,11 +85,7 @@ func (b *einoTurnLoopEventBridge) emitPreempted() {
|
||||
}
|
||||
|
||||
func isEinoTurnLoopPreemptCancel(tc *adk.TurnContext[EinoTurnLoopItem, *schema.Message], err error) bool {
|
||||
if tc == nil || err == nil {
|
||||
return false
|
||||
}
|
||||
var cancelErr *adk.CancelError
|
||||
if !errors.As(err, &cancelErr) {
|
||||
if tc == nil || !isEinoVoluntaryCancelErr(err) {
|
||||
return false
|
||||
}
|
||||
select {
|
||||
|
||||
@@ -41,6 +41,52 @@ func TestEinoTurnLoopEventBridgeSwallowsPreemptCancel(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEinoTurnLoopEventBridgeNeverForwardsStreamCanceled(t *testing.T) {
|
||||
iter, gen := adk.NewAsyncIteratorPair[*adk.AgentEvent]()
|
||||
outIter, outGen := adk.NewAsyncIteratorPair[*adk.AgentEvent]()
|
||||
bridge := newEinoTurnLoopEventBridge("conv", "eino_single", nil, outGen)
|
||||
|
||||
gen.Send(&adk.AgentEvent{Err: adk.ErrStreamCanceled})
|
||||
gen.Close()
|
||||
|
||||
err := bridge.OnAgentEvents(context.Background(), &adk.TurnContext[EinoTurnLoopItem, *schema.Message]{
|
||||
Preempted: make(chan struct{}),
|
||||
}, iter)
|
||||
if err != nil {
|
||||
t.Fatalf("ErrStreamCanceled must be owned by TurnLoop, got %v", err)
|
||||
}
|
||||
if bridge.ForwardedError() {
|
||||
t.Fatal("ErrStreamCanceled must not be marked as forwarded")
|
||||
}
|
||||
outGen.Close()
|
||||
if ev, ok := outIter.Next(); ok || ev != nil {
|
||||
t.Fatalf("ErrStreamCanceled must not be forwarded, got ok=%v ev=%#v", ev, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEinoTurnLoopEventBridgeNeverForwardsCancelError(t *testing.T) {
|
||||
iter, gen := adk.NewAsyncIteratorPair[*adk.AgentEvent]()
|
||||
outIter, outGen := adk.NewAsyncIteratorPair[*adk.AgentEvent]()
|
||||
bridge := newEinoTurnLoopEventBridge("conv", "eino_single", nil, outGen)
|
||||
|
||||
gen.Send(&adk.AgentEvent{Err: &adk.CancelError{Info: &adk.AgentCancelInfo{}}})
|
||||
gen.Close()
|
||||
|
||||
err := bridge.OnAgentEvents(context.Background(), &adk.TurnContext[EinoTurnLoopItem, *schema.Message]{
|
||||
Preempted: make(chan struct{}),
|
||||
}, iter)
|
||||
if err != nil {
|
||||
t.Fatalf("CancelError must be owned by TurnLoop, got %v", err)
|
||||
}
|
||||
if bridge.ForwardedError() {
|
||||
t.Fatal("CancelError must not be marked as forwarded")
|
||||
}
|
||||
outGen.Close()
|
||||
if ev, ok := outIter.Next(); ok || ev != nil {
|
||||
t.Fatalf("CancelError must not be forwarded, got ok=%v ev=%#v", ok, ev)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEinoTurnLoopEventBridgeForwardsRegularError(t *testing.T) {
|
||||
iter, gen := adk.NewAsyncIteratorPair[*adk.AgentEvent]()
|
||||
outIter, outGen := adk.NewAsyncIteratorPair[*adk.AgentEvent]()
|
||||
|
||||
@@ -65,6 +65,73 @@ func (m *turnLoopBlockingModel) snapshotInputs() [][]*schema.Message {
|
||||
return out
|
||||
}
|
||||
|
||||
// turnLoopHangingStreamModel emits one stream chunk then blocks until the
|
||||
// agent cancel scope ends. That matches production ChatModel streams that
|
||||
// receive ErrStreamCanceled when TurnLoop preempt escalates to CancelImmediate.
|
||||
type turnLoopHangingStreamModel struct {
|
||||
started chan struct{}
|
||||
mu sync.Mutex
|
||||
inputs [][]*schema.Message
|
||||
}
|
||||
|
||||
func newTurnLoopHangingStreamModel() *turnLoopHangingStreamModel {
|
||||
return &turnLoopHangingStreamModel{started: make(chan struct{}, 8)}
|
||||
}
|
||||
|
||||
func (m *turnLoopHangingStreamModel) Generate(ctx context.Context, input []*schema.Message, opts ...model.Option) (*schema.Message, error) {
|
||||
sr, err := m.Stream(ctx, input, opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer sr.Close()
|
||||
var last *schema.Message
|
||||
for {
|
||||
msg, rerr := sr.Recv()
|
||||
if rerr != nil {
|
||||
if last != nil {
|
||||
return last, nil
|
||||
}
|
||||
return nil, rerr
|
||||
}
|
||||
if msg != nil {
|
||||
last = msg
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *turnLoopHangingStreamModel) Stream(ctx context.Context, input []*schema.Message, _ ...model.Option) (*schema.StreamReader[*schema.Message], error) {
|
||||
m.mu.Lock()
|
||||
m.inputs = append(m.inputs, cloneSchemaMessages(input))
|
||||
callNo := len(m.inputs)
|
||||
m.mu.Unlock()
|
||||
|
||||
select {
|
||||
case m.started <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
|
||||
if callNo == 1 {
|
||||
reader, writer := schema.Pipe[*schema.Message](1)
|
||||
go func() {
|
||||
writer.Send(schema.AssistantMessage("partial", nil), nil)
|
||||
<-ctx.Done()
|
||||
writer.Close()
|
||||
}()
|
||||
return reader, nil
|
||||
}
|
||||
return schema.StreamReaderFromArray([]*schema.Message{schema.AssistantMessage("done", nil)}), nil
|
||||
}
|
||||
|
||||
func (m *turnLoopHangingStreamModel) snapshotInputs() [][]*schema.Message {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
out := make([][]*schema.Message, len(m.inputs))
|
||||
for i := range m.inputs {
|
||||
out[i] = cloneSchemaMessages(m.inputs[i])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestEinoTurnLoopRuntimePushInterruptStartsNextTurn(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
@@ -7,12 +7,16 @@ import (
|
||||
|
||||
// MCPExecutionBinder maps ADK toolCallID → MCP monitor execution ID for a single agent run.
|
||||
type MCPExecutionBinder struct {
|
||||
mu sync.RWMutex
|
||||
byToolCall map[string]string
|
||||
mu sync.RWMutex
|
||||
byToolCall map[string]string
|
||||
argsByToolCall map[string]map[string]interface{}
|
||||
}
|
||||
|
||||
func NewMCPExecutionBinder() *MCPExecutionBinder {
|
||||
return &MCPExecutionBinder{byToolCall: make(map[string]string)}
|
||||
return &MCPExecutionBinder{
|
||||
byToolCall: make(map[string]string),
|
||||
argsByToolCall: make(map[string]map[string]interface{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (b *MCPExecutionBinder) Bind(toolCallID, executionID string) {
|
||||
@@ -29,6 +33,19 @@ func (b *MCPExecutionBinder) Bind(toolCallID, executionID string) {
|
||||
b.mu.Unlock()
|
||||
}
|
||||
|
||||
func (b *MCPExecutionBinder) BindArguments(toolCallID string, args map[string]interface{}) {
|
||||
if b == nil || len(args) == 0 {
|
||||
return
|
||||
}
|
||||
tid := strings.TrimSpace(toolCallID)
|
||||
if tid == "" {
|
||||
return
|
||||
}
|
||||
b.mu.Lock()
|
||||
b.argsByToolCall[tid] = cloneToolArgs(args)
|
||||
b.mu.Unlock()
|
||||
}
|
||||
|
||||
func (b *MCPExecutionBinder) ExecutionID(toolCallID string) string {
|
||||
if b == nil {
|
||||
return ""
|
||||
@@ -38,3 +55,24 @@ func (b *MCPExecutionBinder) ExecutionID(toolCallID string) string {
|
||||
defer b.mu.RUnlock()
|
||||
return b.byToolCall[tid]
|
||||
}
|
||||
|
||||
func (b *MCPExecutionBinder) Arguments(toolCallID string) map[string]interface{} {
|
||||
if b == nil {
|
||||
return nil
|
||||
}
|
||||
tid := strings.TrimSpace(toolCallID)
|
||||
b.mu.RLock()
|
||||
defer b.mu.RUnlock()
|
||||
return cloneToolArgs(b.argsByToolCall[tid])
|
||||
}
|
||||
|
||||
func cloneToolArgs(args map[string]interface{}) map[string]interface{} {
|
||||
if len(args) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]interface{}, len(args))
|
||||
for k, v := range args {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -9,9 +9,13 @@ import (
|
||||
func TestMCPExecutionBinder(t *testing.T) {
|
||||
b := NewMCPExecutionBinder()
|
||||
b.Bind("call-1", "exec-1")
|
||||
b.BindArguments("call-1", map[string]interface{}{"file_path": "/tmp/a.txt"})
|
||||
if got := b.ExecutionID("call-1"); got != "exec-1" {
|
||||
t.Fatalf("expected exec-1, got %q", got)
|
||||
}
|
||||
if got := b.Arguments("call-1"); got["file_path"] != "/tmp/a.txt" {
|
||||
t.Fatalf("arguments = %#v", got)
|
||||
}
|
||||
if got := b.ExecutionID("missing"); got != "" {
|
||||
t.Fatalf("expected empty, got %q", got)
|
||||
}
|
||||
|
||||
@@ -52,6 +52,7 @@ type RunResult struct {
|
||||
type toolCallPendingInfo struct {
|
||||
ToolCallID string
|
||||
ToolName string
|
||||
Arguments map[string]interface{}
|
||||
EinoAgent string
|
||||
EinoRole string
|
||||
}
|
||||
@@ -154,8 +155,8 @@ func RunDeepAgent(
|
||||
mainDefs := ag.ToolsForRole(roleTools)
|
||||
|
||||
baseHTTPClient := newEinoBaseHTTPClient()
|
||||
modelFactory := newEinoOpenAIChatModelFactory(baseHTTPClient, reasoningClient, logger)
|
||||
agenticModelFactory := newEinoOpenAIAgenticChatModelFactory(baseHTTPClient, reasoningClient, logger)
|
||||
modelFactory := newEinoToolCallingChatModelFactory(baseHTTPClient, reasoningClient, logger)
|
||||
agenticModelFactory := newEinoAgenticChatModelFactory(baseHTTPClient, reasoningClient, logger)
|
||||
agenticModelRetryCfg := newEinoAgenticModelRetryConfig(&ma.EinoMiddleware, logger, "multiagent")
|
||||
agenticModelFailoverCfg, err := newEinoAgenticModelFailoverConfig(ctx, appCfg, &ma.EinoMiddleware, einoModelModeNormal, agenticModelFactory, logger, "multiagent", progress, orchMode, conversationID)
|
||||
if err != nil {
|
||||
@@ -232,7 +233,7 @@ func RunDeepAgent(
|
||||
}
|
||||
if agenticSkillMW != nil {
|
||||
if agenticFSTools && agenticLoc != nil {
|
||||
subFs, fsErr := subAgentAgenticFilesystemMiddleware(ctx, agenticLoc, toolInvokeNotify, id, einoExecBegin, einoExecAppendPartial, einoExecRegisterCancel, einoExecUnregisterCancel, einoExecFinish, agentToolTimeoutMinutes(appCfg), agentToolWaitTimeoutSeconds(appCfg), agentShellNoOutputTimeoutSeconds(appCfg), nil)
|
||||
subFs, fsErr := subAgentAgenticFilesystemMiddleware(ctx, agenticLoc, toolInvokeNotify, id, conversationID, projectID, ma.EinoMiddleware.ReductionRootDir, toolMaxBytesFromMW(&ma.EinoMiddleware), mcpExecBinder, einoExecBegin, einoExecAppendPartial, einoExecRegisterCancel, einoExecUnregisterCancel, einoExecFinish, agentToolTimeoutMinutes(appCfg), agentToolWaitTimeoutSeconds(appCfg), agentShellNoOutputTimeoutSeconds(appCfg), nil)
|
||||
if fsErr != nil {
|
||||
return nil, fmt.Errorf("子代理 %q filesystem 中间件: %w", id, fsErr)
|
||||
}
|
||||
@@ -492,7 +493,7 @@ func RunDeepAgent(
|
||||
}
|
||||
var peFsMw adk.TypedChatModelAgentMiddleware[*schema.AgenticMessage]
|
||||
if agenticSkillMW != nil && agenticFSTools && agenticLoc != nil {
|
||||
peFsMw, err = subAgentAgenticFilesystemMiddleware(ctx, agenticLoc, toolInvokeNotify, "executor", einoExecBegin, einoExecAppendPartial, einoExecRegisterCancel, einoExecUnregisterCancel, einoExecFinish, agentToolTimeoutMinutes(appCfg), agentToolWaitTimeoutSeconds(appCfg), agentShellNoOutputTimeoutSeconds(appCfg), nil)
|
||||
peFsMw, err = subAgentAgenticFilesystemMiddleware(ctx, agenticLoc, toolInvokeNotify, "executor", conversationID, projectID, ma.EinoMiddleware.ReductionRootDir, toolMaxBytesFromMW(&ma.EinoMiddleware), mcpExecBinder, einoExecBegin, einoExecAppendPartial, einoExecRegisterCancel, einoExecUnregisterCancel, einoExecFinish, agentToolTimeoutMinutes(appCfg), agentToolWaitTimeoutSeconds(appCfg), agentShellNoOutputTimeoutSeconds(appCfg), nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("plan_execute agentic filesystem 中间件: %w", err)
|
||||
}
|
||||
@@ -613,15 +614,18 @@ func RunDeepAgent(
|
||||
}
|
||||
|
||||
return runEinoADKAgentLoop(ctx, &einoADKRunLoopArgs{
|
||||
OrchMode: orchMode,
|
||||
OrchestratorName: orchestratorName,
|
||||
ConversationID: conversationID,
|
||||
Progress: progress,
|
||||
Logger: logger,
|
||||
SnapshotMCPIDs: snapshotMCPIDs,
|
||||
StreamsMainAssistant: streamsMainAssistant,
|
||||
EinoRoleTag: einoRoleTag,
|
||||
CheckpointDir: ma.EinoMiddleware.CheckpointDir,
|
||||
OrchMode: orchMode,
|
||||
OrchestratorName: orchestratorName,
|
||||
ConversationID: conversationID,
|
||||
Progress: progress,
|
||||
Logger: logger,
|
||||
SnapshotMCPIDs: snapshotMCPIDs,
|
||||
StreamsMainAssistant: streamsMainAssistant,
|
||||
EinoRoleTag: einoRoleTag,
|
||||
// Chat history recovery is intentionally centralized in last_react_*.
|
||||
// ADK checkpoints are a second persisted model-state channel and make
|
||||
// stale-context bugs hard to reason about across user turns.
|
||||
CheckpointDir: "",
|
||||
RunRetryMaxAttempts: RunRetryMaxAttemptsFromConfig(&ma.EinoMiddleware),
|
||||
RunRetryMaxBackoffSec: int(einoRunRetryMaxBackoffFromConfig(&ma.EinoMiddleware).Seconds()),
|
||||
McpIDsMu: &mcpIDsMu,
|
||||
@@ -907,10 +911,37 @@ func tryEmitToolCallsOnce(
|
||||
if _, ok := seen[sig]; ok {
|
||||
return
|
||||
}
|
||||
if idSig := toolCallsStableIDSignature(msg); idSig != "" {
|
||||
idKey := agentName + "\x1eids\x1e" + idSig
|
||||
if _, ok := seen[idKey]; ok {
|
||||
return
|
||||
}
|
||||
seen[idKey] = struct{}{}
|
||||
}
|
||||
seen[sig] = struct{}{}
|
||||
emitToolCallsFromMessage(msg, agentName, orchestratorName, conversationID, orchMode, progress, subAgentToolStep, mainAgentToolStep, markPending)
|
||||
}
|
||||
|
||||
func toolCallsStableIDSignature(msg *schema.Message) string {
|
||||
if msg == nil || len(msg.ToolCalls) == 0 {
|
||||
return ""
|
||||
}
|
||||
visible := filterVisibleToolCallsForProgress(msg.ToolCalls)
|
||||
ids := make([]string, 0, len(visible))
|
||||
for _, tc := range visible {
|
||||
id := strings.TrimSpace(tc.ID)
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return ""
|
||||
}
|
||||
sort.Strings(ids)
|
||||
return strings.Join(ids, ";")
|
||||
}
|
||||
|
||||
func emitToolCallsFromMessage(
|
||||
msg *schema.Message,
|
||||
agentName, orchestratorName, conversationID, orchMode string,
|
||||
@@ -994,6 +1025,7 @@ func emitToolCallsFromMessage(
|
||||
markPending(toolCallPendingInfo{
|
||||
ToolCallID: toolCallID,
|
||||
ToolName: display,
|
||||
Arguments: argsObj,
|
||||
EinoAgent: agentName,
|
||||
EinoRole: role,
|
||||
})
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,245 @@
|
||||
package openai
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"cyberstrike-ai/internal/llm"
|
||||
|
||||
"github.com/cloudwego/eino/components/model"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
type claudeNativePayload struct {
|
||||
Model string `json:"model"`
|
||||
Messages []claudeNativeMessage `json:"messages"`
|
||||
Temperature *float32 `json:"temperature,omitempty"`
|
||||
TopP *float32 `json:"top_p,omitempty"`
|
||||
MaxCompletionTokens int `json:"max_completion_tokens,omitempty"`
|
||||
MaxTokens int `json:"max_tokens,omitempty"`
|
||||
Thinking any `json:"thinking,omitempty"`
|
||||
OutputConfig any `json:"output_config,omitempty"`
|
||||
}
|
||||
|
||||
type claudeNativeMessage struct {
|
||||
Role string `json:"role"`
|
||||
Content json.RawMessage `json:"content"`
|
||||
}
|
||||
|
||||
func (c *Client) isClaude() bool {
|
||||
return c != nil && c.config != nil && llm.IsClaudeProvider(c.config.Provider)
|
||||
}
|
||||
|
||||
func (c *Client) claudeNativeChatCompletion(ctx context.Context, payload, out any) error {
|
||||
req, err := c.parseClaudeNativePayload(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
nativeModel, err := llm.NewClaudeAgenticModel(
|
||||
ctx,
|
||||
*c.config,
|
||||
c.httpClient,
|
||||
req.maxTokens(c.config.MaxCompletionTokensEffective()),
|
||||
req.extraFields(),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create native Claude model: %w", err)
|
||||
}
|
||||
resp, err := nativeModel.Generate(ctx, req.agenticMessages(), req.options()...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("native Claude generate: %w", err)
|
||||
}
|
||||
return marshalClaudeNativeResponse(resp, req.model(c.config.Model), out)
|
||||
}
|
||||
|
||||
func (c *Client) claudeNativeChatCompletionStream(
|
||||
ctx context.Context,
|
||||
payload any,
|
||||
onDelta func(delta string) error,
|
||||
) (string, error) {
|
||||
req, err := c.parseClaudeNativePayload(payload)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
nativeModel, err := llm.NewClaudeAgenticModel(
|
||||
ctx,
|
||||
*c.config,
|
||||
c.httpClient,
|
||||
req.maxTokens(c.config.MaxCompletionTokensEffective()),
|
||||
req.extraFields(),
|
||||
)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create native Claude model: %w", err)
|
||||
}
|
||||
stream, err := nativeModel.Stream(ctx, req.agenticMessages(), req.options()...)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("native Claude stream: %w", err)
|
||||
}
|
||||
defer stream.Close()
|
||||
|
||||
var full strings.Builder
|
||||
for {
|
||||
chunk, recvErr := stream.Recv()
|
||||
if errors.Is(recvErr, io.EOF) {
|
||||
return full.String(), nil
|
||||
}
|
||||
if recvErr != nil {
|
||||
return full.String(), fmt.Errorf("native Claude stream receive: %w", recvErr)
|
||||
}
|
||||
content, _ := llm.AgenticText(chunk)
|
||||
if content == "" {
|
||||
continue
|
||||
}
|
||||
full.WriteString(content)
|
||||
if onDelta != nil {
|
||||
if err := onDelta(content); err != nil {
|
||||
return full.String(), err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) parseClaudeNativePayload(payload any) (*claudeNativePayload, error) {
|
||||
raw, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal Claude payload: %w", err)
|
||||
}
|
||||
var req claudeNativePayload
|
||||
if err := json.Unmarshal(raw, &req); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal Claude payload: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(req.model(c.config.Model)) == "" {
|
||||
return nil, fmt.Errorf("native Claude model is empty")
|
||||
}
|
||||
if len(req.Messages) == 0 {
|
||||
return nil, fmt.Errorf("native Claude messages are empty")
|
||||
}
|
||||
return &req, nil
|
||||
}
|
||||
|
||||
func (p *claudeNativePayload) model(fallback string) string {
|
||||
if modelName := strings.TrimSpace(p.Model); modelName != "" {
|
||||
return modelName
|
||||
}
|
||||
return strings.TrimSpace(fallback)
|
||||
}
|
||||
|
||||
func (p *claudeNativePayload) maxTokens(fallback int) int {
|
||||
if p.MaxCompletionTokens > 0 {
|
||||
return p.MaxCompletionTokens
|
||||
}
|
||||
if p.MaxTokens > 0 {
|
||||
return p.MaxTokens
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func (p *claudeNativePayload) extraFields() map[string]any {
|
||||
fields := make(map[string]any, 2)
|
||||
if p.Thinking != nil {
|
||||
fields["thinking"] = p.Thinking
|
||||
}
|
||||
if p.OutputConfig != nil {
|
||||
fields["output_config"] = p.OutputConfig
|
||||
}
|
||||
if len(fields) == 0 {
|
||||
return nil
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
func (p *claudeNativePayload) options() []model.Option {
|
||||
opts := make([]model.Option, 0, 3)
|
||||
if p.Temperature != nil {
|
||||
opts = append(opts, model.WithTemperature(*p.Temperature))
|
||||
}
|
||||
if p.TopP != nil {
|
||||
opts = append(opts, model.WithTopP(*p.TopP))
|
||||
}
|
||||
if p.MaxCompletionTokens > 0 || p.MaxTokens > 0 {
|
||||
opts = append(opts, model.WithMaxTokens(p.maxTokens(0)))
|
||||
}
|
||||
return opts
|
||||
}
|
||||
|
||||
func (p *claudeNativePayload) agenticMessages() []*schema.AgenticMessage {
|
||||
out := make([]*schema.AgenticMessage, 0, len(p.Messages))
|
||||
for _, msg := range p.Messages {
|
||||
text := claudeNativeTextContent(msg.Content)
|
||||
role := schema.AgenticRoleTypeUser
|
||||
var block *schema.ContentBlock
|
||||
switch strings.ToLower(strings.TrimSpace(msg.Role)) {
|
||||
case "system":
|
||||
role = schema.AgenticRoleTypeSystem
|
||||
block = schema.NewContentBlock(&schema.UserInputText{Text: text})
|
||||
case "assistant":
|
||||
role = schema.AgenticRoleTypeAssistant
|
||||
block = schema.NewContentBlock(&schema.AssistantGenText{Text: text})
|
||||
default:
|
||||
block = schema.NewContentBlock(&schema.UserInputText{Text: text})
|
||||
}
|
||||
out = append(out, &schema.AgenticMessage{
|
||||
Role: role,
|
||||
ContentBlocks: []*schema.ContentBlock{block},
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func claudeNativeTextContent(raw json.RawMessage) string {
|
||||
var text string
|
||||
if err := json.Unmarshal(raw, &text); err == nil {
|
||||
return text
|
||||
}
|
||||
var parts []struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &parts); err == nil {
|
||||
var out strings.Builder
|
||||
for _, part := range parts {
|
||||
if part.Type == "" || part.Type == "text" {
|
||||
out.WriteString(part.Text)
|
||||
}
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
return strings.TrimSpace(string(raw))
|
||||
}
|
||||
|
||||
func marshalClaudeNativeResponse(resp *schema.AgenticMessage, modelName string, out any) error {
|
||||
if out == nil {
|
||||
return nil
|
||||
}
|
||||
content, reasoning := llm.AgenticText(resp)
|
||||
id := ""
|
||||
if resp != nil && resp.ResponseMeta != nil && resp.ResponseMeta.ClaudeExtension != nil {
|
||||
id = resp.ResponseMeta.ClaudeExtension.ID
|
||||
}
|
||||
wire := map[string]any{
|
||||
"id": id,
|
||||
"object": "chat.completion",
|
||||
"model": modelName,
|
||||
"choices": []any{map[string]any{
|
||||
"index": 0,
|
||||
"message": map[string]any{
|
||||
"role": "assistant",
|
||||
"content": content,
|
||||
"reasoning_content": reasoning,
|
||||
},
|
||||
"finish_reason": "stop",
|
||||
}},
|
||||
}
|
||||
raw, err := json.Marshal(wire)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal native Claude response: %w", err)
|
||||
}
|
||||
if err := json.Unmarshal(raw, out); err != nil {
|
||||
return fmt.Errorf("unmarshal native Claude response: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package openai
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"cyberstrike-ai/internal/config"
|
||||
)
|
||||
|
||||
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return f(req)
|
||||
}
|
||||
|
||||
func TestChatCompletionUsesNativeClaudeMessagesAPI(t *testing.T) {
|
||||
t.Parallel()
|
||||
httpClient := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
if req.URL.Path != "/v1/messages" {
|
||||
t.Fatalf("request path = %q, want /v1/messages", req.URL.Path)
|
||||
}
|
||||
if req.Header.Get("x-api-key") != "test-key" {
|
||||
t.Fatalf("x-api-key header = %q", req.Header.Get("x-api-key"))
|
||||
}
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(strings.NewReader(`{
|
||||
"id":"msg_1",
|
||||
"type":"message",
|
||||
"role":"assistant",
|
||||
"model":"claude-test",
|
||||
"content":[{"type":"text","text":"native ok"}],
|
||||
"stop_reason":"end_turn",
|
||||
"usage":{"input_tokens":1,"output_tokens":2}
|
||||
}`)),
|
||||
Request: req,
|
||||
}, nil
|
||||
})}
|
||||
client := NewClient(&config.OpenAIConfig{
|
||||
Provider: "claude",
|
||||
BaseURL: "https://example.test",
|
||||
APIKey: "test-key",
|
||||
Model: "claude-test",
|
||||
}, httpClient, nil)
|
||||
|
||||
var out struct {
|
||||
Choices []struct {
|
||||
Message struct {
|
||||
Content string `json:"content"`
|
||||
} `json:"message"`
|
||||
} `json:"choices"`
|
||||
}
|
||||
err := client.ChatCompletion(context.Background(), map[string]any{
|
||||
"model": "claude-test",
|
||||
"messages": []map[string]string{
|
||||
{"role": "user", "content": "hello"},
|
||||
},
|
||||
"max_completion_tokens": 16,
|
||||
}, &out)
|
||||
if err != nil {
|
||||
t.Fatalf("ChatCompletion: %v", err)
|
||||
}
|
||||
if len(out.Choices) != 1 || out.Choices[0].Message.Content != "native ok" {
|
||||
t.Fatalf("response = %#v", out)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package openai
|
||||
|
||||
import "strings"
|
||||
|
||||
// claudeReasoningRoundTripSep is retained only to render historical traces
|
||||
// written by the removed OpenAI-to-Claude HTTP bridge.
|
||||
const claudeReasoningRoundTripSep = "\n---CSAI_CLAUDE_THINKING_BLOCKS---\n"
|
||||
|
||||
// DisplayReasoningContent strips the obsolete bridge metadata suffix from
|
||||
// historical records. Native AgenticMessage reasoning does not add this suffix.
|
||||
func DisplayReasoningContent(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
i := strings.LastIndex(s, claudeReasoningRoundTripSep)
|
||||
if i < 0 {
|
||||
return s
|
||||
}
|
||||
return strings.TrimSpace(s[:i])
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package openai
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestDisplayReasoningContentStripsLegacyClaudeSuffix(t *testing.T) {
|
||||
raw := "hello" + claudeReasoningRoundTripSep + `[{"type":"thinking"}]`
|
||||
if got := DisplayReasoningContent(raw); got != "hello" {
|
||||
t.Fatalf("DisplayReasoningContent() = %q, want hello", got)
|
||||
}
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
package openai
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// claudeReasoningRoundTripSep separates human-readable reasoning from a JSON payload of
|
||||
// Anthropic thinking blocks (with signatures) for multi-turn extended thinking + tools.
|
||||
// Not shown in UI (see DisplayReasoningContent).
|
||||
const claudeReasoningRoundTripSep = "\n---CSAI_CLAUDE_THINKING_BLOCKS---\n"
|
||||
|
||||
// DisplayReasoningContent returns reasoning text suitable for the UI (strips internal
|
||||
// Claude round-trip JSON suffix). Safe for DeepSeek/plain reasoning strings (no-op).
|
||||
func DisplayReasoningContent(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
i := strings.LastIndex(s, claudeReasoningRoundTripSep)
|
||||
if i < 0 {
|
||||
return s
|
||||
}
|
||||
return strings.TrimSpace(s[:i])
|
||||
}
|
||||
|
||||
func appendClaudeReasoningRoundTrip(display string, blocks []claudeContentBlock) string {
|
||||
var payload []map[string]string
|
||||
for _, b := range blocks {
|
||||
if b.Type != "thinking" {
|
||||
continue
|
||||
}
|
||||
payload = append(payload, map[string]string{
|
||||
"type": b.Type,
|
||||
"thinking": b.Thinking,
|
||||
"signature": b.Signature,
|
||||
})
|
||||
}
|
||||
if len(payload) == 0 {
|
||||
return strings.TrimSpace(display)
|
||||
}
|
||||
js, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return strings.TrimSpace(display)
|
||||
}
|
||||
d := strings.TrimSpace(display)
|
||||
if d == "" {
|
||||
return claudeReasoningRoundTripSep + string(js)
|
||||
}
|
||||
return d + claudeReasoningRoundTripSep + string(js)
|
||||
}
|
||||
|
||||
// parseClaudeReasoningAssistantBlocks extracts Anthropic thinking blocks from an OpenAI-style
|
||||
// reasoning_content string. When no suffix is present, blocks is nil (caller must not invent signatures).
|
||||
func parseClaudeReasoningAssistantBlocks(reasoningContent string) (display string, blocks []claudeContentBlock) {
|
||||
reasoningContent = strings.TrimSpace(reasoningContent)
|
||||
if reasoningContent == "" {
|
||||
return "", nil
|
||||
}
|
||||
idx := strings.LastIndex(reasoningContent, claudeReasoningRoundTripSep)
|
||||
if idx < 0 {
|
||||
return reasoningContent, nil
|
||||
}
|
||||
display = strings.TrimSpace(reasoningContent[:idx])
|
||||
jsonPart := strings.TrimSpace(reasoningContent[idx+len(claudeReasoningRoundTripSep):])
|
||||
var arr []struct {
|
||||
Type string `json:"type"`
|
||||
Thinking string `json:"thinking"`
|
||||
Signature string `json:"signature"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(jsonPart), &arr); err != nil {
|
||||
return reasoningContent, nil
|
||||
}
|
||||
for _, x := range arr {
|
||||
if x.Type != "thinking" {
|
||||
continue
|
||||
}
|
||||
blocks = append(blocks, claudeContentBlock{Type: "thinking", Thinking: x.Thinking, Signature: x.Signature})
|
||||
}
|
||||
return display, blocks
|
||||
}
|
||||
@@ -1,211 +0,0 @@
|
||||
package openai
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDisplayReasoningContent(t *testing.T) {
|
||||
raw := "hello" + claudeReasoningRoundTripSep + `[{"type":"thinking","thinking":"x","signature":"sig"}]`
|
||||
if d := DisplayReasoningContent(raw); d != "hello" {
|
||||
t.Fatalf("got %q", d)
|
||||
}
|
||||
if DisplayReasoningContent("plain") != "plain" {
|
||||
t.Fatal()
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendParseClaudeReasoningRoundTrip(t *testing.T) {
|
||||
blocks := []claudeContentBlock{
|
||||
{Type: "thinking", Thinking: "a", Signature: "s1"},
|
||||
{Type: "thinking", Thinking: "b", Signature: "s2"},
|
||||
}
|
||||
s := appendClaudeReasoningRoundTrip("sum", blocks)
|
||||
if !strings.Contains(s, claudeReasoningRoundTripSep) {
|
||||
t.Fatal("missing sep")
|
||||
}
|
||||
display, back := parseClaudeReasoningAssistantBlocks(s)
|
||||
if display != "sum" || len(back) != 2 {
|
||||
t.Fatalf("display=%q len=%d", display, len(back))
|
||||
}
|
||||
if back[0].Signature != "s1" || back[1].Thinking != "b" {
|
||||
t.Fatalf("%+v", back)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertOpenAIToClaude_AssistantReasoningReplay(t *testing.T) {
|
||||
rc := appendClaudeReasoningRoundTrip("vis", []claudeContentBlock{
|
||||
{Type: "thinking", Thinking: "t1", Signature: "sig1"},
|
||||
})
|
||||
payload := map[string]interface{}{
|
||||
"model": "claude-3-5-sonnet-latest",
|
||||
"messages": []interface{}{
|
||||
map[string]interface{}{
|
||||
"role": "assistant",
|
||||
"content": "out",
|
||||
"reasoning_content": rc,
|
||||
},
|
||||
},
|
||||
}
|
||||
req, err := convertOpenAIToClaude(payload)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(req.Messages) != 1 {
|
||||
t.Fatalf("messages=%d", len(req.Messages))
|
||||
}
|
||||
blocks := req.Messages[0].Content.Blocks
|
||||
if len(blocks) < 2 {
|
||||
t.Fatalf("blocks=%d", len(blocks))
|
||||
}
|
||||
if blocks[0].Type != "thinking" || blocks[0].Signature != "sig1" {
|
||||
t.Fatalf("first block %+v", blocks[0])
|
||||
}
|
||||
foundText := false
|
||||
for _, b := range blocks {
|
||||
if b.Type == "text" && b.Text == "out" {
|
||||
foundText = true
|
||||
}
|
||||
}
|
||||
if !foundText {
|
||||
t.Fatalf("blocks=%+v", blocks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertOpenAIToClaude_OutputConfigEffort(t *testing.T) {
|
||||
payload := map[string]interface{}{
|
||||
"model": "claude-opus-4-8",
|
||||
"messages": []interface{}{
|
||||
map[string]interface{}{"role": "user", "content": "hi"},
|
||||
},
|
||||
"thinking": map[string]interface{}{
|
||||
"type": "adaptive",
|
||||
"display": "summarized",
|
||||
},
|
||||
"output_config": map[string]interface{}{
|
||||
"effort": "high",
|
||||
},
|
||||
}
|
||||
req, err := convertOpenAIToClaude(payload)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(req.Thinking) == 0 {
|
||||
t.Fatal("expected thinking")
|
||||
}
|
||||
if len(req.OutputConfig) == 0 {
|
||||
t.Fatal("expected output_config")
|
||||
}
|
||||
var oc map[string]interface{}
|
||||
if err := json.Unmarshal(req.OutputConfig, &oc); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if oc["effort"] != "high" {
|
||||
t.Fatalf("effort=%v", oc["effort"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertOpenAIToClaudeAcceptsCompleteMultiToolBatch(t *testing.T) {
|
||||
payload := map[string]interface{}{
|
||||
"model": "claude-test",
|
||||
"messages": []interface{}{
|
||||
map[string]interface{}{
|
||||
"role": "assistant",
|
||||
"tool_calls": []interface{}{
|
||||
map[string]interface{}{"id": "c1", "function": map[string]interface{}{"name": "one", "arguments": "{}"}},
|
||||
map[string]interface{}{"id": "c2", "function": map[string]interface{}{"name": "two", "arguments": "{}"}},
|
||||
},
|
||||
},
|
||||
map[string]interface{}{"role": "tool", "tool_call_id": "c1", "content": "r1"},
|
||||
map[string]interface{}{"role": "tool", "tool_call_id": "c2", "content": "r2"},
|
||||
map[string]interface{}{"role": "assistant", "content": "done"},
|
||||
},
|
||||
}
|
||||
req, err := convertOpenAIToClaude(payload)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(req.Messages) != 3 || len(req.Messages[1].Content.Blocks) != 2 {
|
||||
t.Fatalf("unexpected converted messages: %+v", req.Messages)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertOpenAIToClaudeRejectsMissingToolResult(t *testing.T) {
|
||||
payload := map[string]interface{}{
|
||||
"model": "claude-test",
|
||||
"messages": []interface{}{
|
||||
map[string]interface{}{
|
||||
"role": "assistant",
|
||||
"tool_calls": []interface{}{
|
||||
map[string]interface{}{"id": "c1", "function": map[string]interface{}{"name": "one", "arguments": "{}"}},
|
||||
map[string]interface{}{"id": "c2", "function": map[string]interface{}{"name": "two", "arguments": "{}"}},
|
||||
},
|
||||
},
|
||||
map[string]interface{}{"role": "tool", "tool_call_id": "c1", "content": "r1"},
|
||||
},
|
||||
}
|
||||
_, err := convertOpenAIToClaude(payload)
|
||||
if err == nil || !strings.Contains(err.Error(), "missing tool_result") {
|
||||
t.Fatalf("expected missing tool_result error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertOpenAIToClaudeRejectsOrphanToolResult(t *testing.T) {
|
||||
payload := map[string]interface{}{
|
||||
"model": "claude-test",
|
||||
"messages": []interface{}{
|
||||
map[string]interface{}{"role": "user", "content": "start"},
|
||||
map[string]interface{}{"role": "tool", "tool_call_id": "orphan", "content": "result"},
|
||||
},
|
||||
}
|
||||
_, err := convertOpenAIToClaude(payload)
|
||||
if err == nil || !strings.Contains(err.Error(), "orphan tool_result") {
|
||||
t.Fatalf("expected orphan tool_result error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudeToOpenAIResponseJSON_Thinking(t *testing.T) {
|
||||
claudeBody := []byte(`{
|
||||
"id":"msg_1","type":"message","role":"assistant","model":"x","stop_reason":"end_turn",
|
||||
"content":[
|
||||
{"type":"thinking","thinking":"step","signature":"sigx"},
|
||||
{"type":"text","text":"hi"}
|
||||
]
|
||||
}`)
|
||||
oai, err := claudeToOpenAIResponseJSON(claudeBody)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var wrap map[string]interface{}
|
||||
if err := json.Unmarshal(oai, &wrap); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
choices := wrap["choices"].([]interface{})
|
||||
ch0 := choices[0].(map[string]interface{})
|
||||
msg := ch0["message"].(map[string]interface{})
|
||||
rc, _ := msg["reasoning_content"].(string)
|
||||
if !strings.Contains(rc, "step") || !strings.Contains(rc, claudeReasoningRoundTripSep) {
|
||||
t.Fatalf("reasoning_content=%q", rc)
|
||||
}
|
||||
if msg["content"] != "hi" {
|
||||
t.Fatal()
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertOpenAIToClaudeMapsMaxCompletionTokens(t *testing.T) {
|
||||
req, err := convertOpenAIToClaude(map[string]interface{}{
|
||||
"model": "claude-test",
|
||||
"max_completion_tokens": float64(16384),
|
||||
"max_tokens": float64(1024),
|
||||
"messages": []interface{}{
|
||||
map[string]interface{}{"role": "user", "content": "hello"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if req.MaxTokens != 16384 {
|
||||
t.Fatalf("max tokens=%d, want 16384", req.MaxTokens)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package openai
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"cyberstrike-ai/internal/config"
|
||||
)
|
||||
|
||||
// NewEinoHTTPClient adds OpenAI-compatible request fixes and SSE sanitation.
|
||||
// Claude channels use Eino's native agenticclaude model and never enter here.
|
||||
func NewEinoHTTPClient(cfg *config.OpenAIConfig, base *http.Client) *http.Client {
|
||||
if base == nil {
|
||||
base = http.DefaultClient
|
||||
}
|
||||
cloned := *base
|
||||
transport := base.Transport
|
||||
if transport == nil {
|
||||
transport = http.DefaultTransport
|
||||
}
|
||||
transport = &reasoningToolChoiceCompatRoundTripper{base: transport, cfg: cfg}
|
||||
transport = &einoSSESanitizingRoundTripper{base: transport}
|
||||
cloned.Transport = transport
|
||||
return &cloned
|
||||
}
|
||||
@@ -93,7 +93,7 @@ func (c *Client) ChatCompletion(ctx context.Context, payload interface{}, out in
|
||||
return fmt.Errorf("openai api key is empty")
|
||||
}
|
||||
if c.isClaude() {
|
||||
return c.claudeChatCompletion(ctx, payload, out)
|
||||
return c.claudeNativeChatCompletion(ctx, payload, out)
|
||||
}
|
||||
|
||||
baseURL := strings.TrimSuffix(c.config.BaseURL, "/")
|
||||
@@ -188,7 +188,7 @@ func (c *Client) ChatCompletionStream(ctx context.Context, payload interface{},
|
||||
return "", fmt.Errorf("openai api key is empty")
|
||||
}
|
||||
if c.isClaude() {
|
||||
return c.claudeChatCompletionStream(ctx, payload, onDelta)
|
||||
return c.claudeNativeChatCompletionStream(ctx, payload, onDelta)
|
||||
}
|
||||
|
||||
baseURL := strings.TrimSuffix(c.config.BaseURL, "/")
|
||||
@@ -338,7 +338,7 @@ func (c *Client) ChatCompletionStreamWithToolCalls(
|
||||
return "", nil, "", fmt.Errorf("openai api key is empty")
|
||||
}
|
||||
if c.isClaude() {
|
||||
return c.claudeChatCompletionStreamWithToolCalls(ctx, payload, onContentDelta)
|
||||
return "", nil, "", fmt.Errorf("native Claude tool-call streaming requires Eino AgenticModel")
|
||||
}
|
||||
|
||||
baseURL := strings.TrimSuffix(c.config.BaseURL, "/")
|
||||
|
||||
@@ -205,7 +205,7 @@ func TestReasoningToolChoiceCompatRoundTripperDeepSeek(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestReasoningToolChoiceCompatRoundTripperDeepSeekEndpointWinsOverProfile(t *testing.T) {
|
||||
func TestReasoningToolChoiceCompatRoundTripperOpenAIProfileWinsOverDeepSeekEndpoint(t *testing.T) {
|
||||
var gotBody string
|
||||
rt := &reasoningToolChoiceCompatRoundTripper{
|
||||
cfg: &config.OpenAIConfig{
|
||||
@@ -235,11 +235,11 @@ func TestReasoningToolChoiceCompatRoundTripperDeepSeekEndpointWinsOverProfile(t
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(gotBody, "tool_choice") {
|
||||
t.Fatalf("expected DeepSeek tool_choice stripped despite openai_compat profile, got %s", gotBody)
|
||||
if !strings.Contains(gotBody, "tool_choice") {
|
||||
t.Fatalf("expected tool_choice preserved for explicit openai_compat profile, got %s", gotBody)
|
||||
}
|
||||
if !strings.Contains(gotBody, "tools") {
|
||||
t.Fatalf("expected tools preserved for DeepSeek, got %s", gotBody)
|
||||
t.Fatalf("expected tools preserved for explicit openai_compat profile, got %s", gotBody)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -55,9 +55,6 @@ func isDeepSeekToolChoiceCompatProfile(cfg *config.OpenAIConfig) bool {
|
||||
if cfg == nil {
|
||||
return false
|
||||
}
|
||||
if cfg.IsDeepSeekEndpointOrModel() {
|
||||
return true
|
||||
}
|
||||
profile := strings.ToLower(strings.TrimSpace(cfg.Reasoning.ProfileEffective()))
|
||||
if profile == "deepseek" || profile == "deepseek_compat" {
|
||||
return true
|
||||
@@ -65,5 +62,5 @@ func isDeepSeekToolChoiceCompatProfile(cfg *config.OpenAIConfig) bool {
|
||||
if profile != "" && profile != "auto" {
|
||||
return false
|
||||
}
|
||||
return false
|
||||
return cfg.IsDeepSeekEndpointOrModel()
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ func ApplyPlanExecutePlannerModelConfig(cfg *einoopenai.ChatModelConfig, oa *con
|
||||
}
|
||||
mergeExtraRequestFields(cfg, oa.Reasoning.ExtraRequestFields)
|
||||
clearReasoningFromChatModelConfig(cfg)
|
||||
if resolveWireProfile(oa, &oa.Reasoning) == wireDeepseek || oa.IsDeepSeekEndpointOrModel() {
|
||||
if resolveWireProfile(oa, &oa.Reasoning) == wireDeepseek {
|
||||
// DeepSeek enables thinking by default, so omission would not actually
|
||||
// disable it for the planner's forced tool-choice requests.
|
||||
applyThinkingDisabled(cfg)
|
||||
@@ -88,16 +88,16 @@ func ApplyToEinoChatModelConfig(cfg *einoopenai.ChatModelConfig, oa *config.Open
|
||||
clearReasoningFromChatModelConfig(cfg)
|
||||
// Strict OpenAI endpoints reject unknown `thinking` fields, whereas the
|
||||
// DeepSeek API enables thinking by default and requires an explicit
|
||||
// thinking.type=disabled switch. Detect the actual DeepSeek target even
|
||||
// when the configured reasoning profile was left as openai_compat.
|
||||
if resolveWireProfile(oa, sr) == wireDeepseek || oa.IsDeepSeekEndpointOrModel() {
|
||||
// thinking.type=disabled switch. The configured profile is authoritative;
|
||||
// auto-detection only happens inside resolveWireProfile for profile=auto.
|
||||
if resolveWireProfile(oa, sr) == wireDeepseek {
|
||||
applyThinkingDisabled(cfg)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Claude (Anthropic): merge admin extras first; optional extended thinking maps to top-level `thinking`
|
||||
// (see internal/openai convertOpenAIToClaude). DeepSeek/OpenAI-style fields are not sent.
|
||||
// Claude (Anthropic): merge admin extras first; optional extended thinking maps to top-level `thinking`.
|
||||
// DeepSeek/OpenAI-style fields are not sent.
|
||||
if strings.EqualFold(strings.TrimSpace(oa.Provider), "claude") ||
|
||||
strings.EqualFold(strings.TrimSpace(oa.Provider), "anthropic") {
|
||||
applyClaudeExtendedThinking(cfg, mode, effectiveEffort(sr, client, allowClient), oa.Model)
|
||||
@@ -132,7 +132,7 @@ func AgenticOpenAIExtraFields(oa *config.OpenAIConfig, client *ClientIntent) map
|
||||
fields := cloneExtraRequestFields(sr.ExtraRequestFields)
|
||||
if mode == "off" {
|
||||
clearReasoningExtraFields(fields)
|
||||
if resolveWireProfile(oa, sr) == wireDeepseek || oa.IsDeepSeekEndpointOrModel() {
|
||||
if resolveWireProfile(oa, sr) == wireDeepseek {
|
||||
if fields == nil {
|
||||
fields = make(map[string]any)
|
||||
}
|
||||
@@ -194,7 +194,7 @@ func AgenticOpenAIPlannerExtraFields(oa *config.OpenAIConfig) map[string]any {
|
||||
}
|
||||
fields := cloneExtraRequestFields(oa.Reasoning.ExtraRequestFields)
|
||||
clearReasoningExtraFields(fields)
|
||||
if resolveWireProfile(oa, &oa.Reasoning) == wireDeepseek || oa.IsDeepSeekEndpointOrModel() {
|
||||
if resolveWireProfile(oa, &oa.Reasoning) == wireDeepseek {
|
||||
if fields == nil {
|
||||
fields = make(map[string]any)
|
||||
}
|
||||
|
||||
+110
-19
@@ -140,7 +140,7 @@ func TestAgenticOpenAIPlannerExtraFields_deepseekDisablesThinking(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgenticOpenAIPlannerExtraFields_deepseekEndpointWinsOverOpenAIProfile(t *testing.T) {
|
||||
func TestAgenticOpenAIPlannerExtraFields_openAIProfileWinsOverDeepseekEndpoint(t *testing.T) {
|
||||
oa := &config.OpenAIConfig{
|
||||
BaseURL: "https://api.deepseek.com/v1",
|
||||
Model: "deepseek-v4-flash",
|
||||
@@ -155,12 +155,10 @@ func TestAgenticOpenAIPlannerExtraFields_deepseekEndpointWinsOverOpenAIProfile(t
|
||||
},
|
||||
}
|
||||
got := AgenticOpenAIPlannerExtraFields(oa)
|
||||
if _, ok := got["reasoning_effort"]; ok {
|
||||
t.Fatalf("planner should strip reasoning_effort: %#v", got)
|
||||
}
|
||||
thinking, ok := got["thinking"].(map[string]any)
|
||||
if !ok || thinking["type"] != "disabled" {
|
||||
t.Fatalf("expected deepseek thinking disabled despite openai_compat profile, got %#v", got)
|
||||
for _, key := range reasoningPayloadKeysForTest {
|
||||
if _, ok := got[key]; ok {
|
||||
t.Fatalf("planner fields unexpectedly contain %q: %#v", key, got)
|
||||
}
|
||||
}
|
||||
if got["vendor_option"] != true {
|
||||
t.Fatalf("vendor option not preserved: %#v", got)
|
||||
@@ -189,7 +187,7 @@ func TestApplyPlanExecutePlannerModelConfig_stripsReasoningWhenGlobalOn(t *testi
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyPlanExecutePlannerModelConfig_deepseekEndpointWinsOverOpenAIProfile(t *testing.T) {
|
||||
func TestApplyPlanExecutePlannerModelConfig_openAIProfileWinsOverDeepseekEndpoint(t *testing.T) {
|
||||
cfg := &einoopenai.ChatModelConfig{ExtraFields: map[string]any{
|
||||
"thinking": map[string]any{"type": "enabled"},
|
||||
"reasoning_effort": "high",
|
||||
@@ -205,16 +203,7 @@ func TestApplyPlanExecutePlannerModelConfig_deepseekEndpointWinsOverOpenAIProfil
|
||||
},
|
||||
}
|
||||
ApplyPlanExecutePlannerModelConfig(cfg, oa)
|
||||
if cfg.ReasoningEffort != "" {
|
||||
t.Fatalf("expected ReasoningEffort omitted, got %q", cfg.ReasoningEffort)
|
||||
}
|
||||
if _, ok := cfg.ExtraFields["reasoning_effort"]; ok {
|
||||
t.Fatalf("expected reasoning_effort omitted, got %#v", cfg.ExtraFields)
|
||||
}
|
||||
thinking, ok := cfg.ExtraFields["thinking"].(map[string]any)
|
||||
if !ok || thinking["type"] != "disabled" {
|
||||
t.Fatalf("expected deepseek thinking disabled despite openai_compat profile, got %#v", cfg.ExtraFields)
|
||||
}
|
||||
assertNoReasoningFields(t, cfg)
|
||||
if cfg.ExtraFields["vendor_option"] != true {
|
||||
t.Fatalf("expected unrelated extra field preserved, got %#v", cfg.ExtraFields)
|
||||
}
|
||||
@@ -246,6 +235,89 @@ func TestApplyReasoningOff_omitsAllReasoningFields(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyReasoningOff_openAICompatDeepseekModelOmitsAllReasoningFields(t *testing.T) {
|
||||
allowClient := false
|
||||
cfg := &einoopenai.ChatModelConfig{ExtraFields: map[string]any{
|
||||
"thinking": map[string]any{"type": "enabled"},
|
||||
"reasoning_effort": "high",
|
||||
}}
|
||||
oa := &config.OpenAIConfig{
|
||||
Provider: "openai_compatible",
|
||||
BaseURL: "http://your-gateway:port/v1",
|
||||
Model: "deepseek-v4-flash-0731",
|
||||
Reasoning: config.OpenAIReasoningConfig{
|
||||
Mode: "off",
|
||||
Effort: "high",
|
||||
Profile: "openai_compat",
|
||||
AllowClientReasoning: &allowClient,
|
||||
ExtraRequestFields: map[string]interface{}{
|
||||
"thinking": map[string]any{"type": "disabled"},
|
||||
"output_config": map[string]any{"effort": "high"},
|
||||
"vendor_option": true,
|
||||
},
|
||||
},
|
||||
}
|
||||
ApplyToEinoChatModelConfig(cfg, oa, nil)
|
||||
assertNoReasoningFields(t, cfg)
|
||||
if cfg.ExtraFields["vendor_option"] != true {
|
||||
t.Fatalf("expected unrelated extra field preserved, got %#v", cfg.ExtraFields)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgenticOpenAIExtraFields_openAICompatDeepseekModelOmitsAllReasoningFields(t *testing.T) {
|
||||
oa := &config.OpenAIConfig{
|
||||
Provider: "openai_compatible",
|
||||
BaseURL: "http://your-gateway:port/v1",
|
||||
Model: "deepseek-v4-flash-0731",
|
||||
Reasoning: config.OpenAIReasoningConfig{
|
||||
Mode: "off",
|
||||
Effort: "high",
|
||||
Profile: "openai_compat",
|
||||
ExtraRequestFields: map[string]interface{}{
|
||||
"thinking": map[string]any{"type": "disabled"},
|
||||
"reasoning_effort": "high",
|
||||
"vendor_option": true,
|
||||
},
|
||||
},
|
||||
}
|
||||
got := AgenticOpenAIExtraFields(oa, nil)
|
||||
for _, key := range reasoningPayloadKeysForTest {
|
||||
if _, ok := got[key]; ok {
|
||||
t.Fatalf("agentic fields unexpectedly contain %q: %#v", key, got)
|
||||
}
|
||||
}
|
||||
if got["vendor_option"] != true {
|
||||
t.Fatalf("vendor option not preserved: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgenticOpenAIPlannerExtraFields_openAICompatDeepseekModelOmitsAllReasoningFields(t *testing.T) {
|
||||
oa := &config.OpenAIConfig{
|
||||
Provider: "openai_compatible",
|
||||
BaseURL: "http://your-gateway:port/v1",
|
||||
Model: "deepseek-v4-flash-0731",
|
||||
Reasoning: config.OpenAIReasoningConfig{
|
||||
Mode: "on",
|
||||
Effort: "high",
|
||||
Profile: "openai_compat",
|
||||
ExtraRequestFields: map[string]interface{}{
|
||||
"thinking": map[string]any{"type": "enabled"},
|
||||
"reasoning_effort": "high",
|
||||
"vendor_option": true,
|
||||
},
|
||||
},
|
||||
}
|
||||
got := AgenticOpenAIPlannerExtraFields(oa)
|
||||
for _, key := range reasoningPayloadKeysForTest {
|
||||
if _, ok := got[key]; ok {
|
||||
t.Fatalf("planner fields unexpectedly contain %q: %#v", key, got)
|
||||
}
|
||||
}
|
||||
if got["vendor_option"] != true {
|
||||
t.Fatalf("vendor option not preserved: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyReasoningOff_clientOverrideOmit(t *testing.T) {
|
||||
cfg := &einoopenai.ChatModelConfig{}
|
||||
oa := &config.OpenAIConfig{Reasoning: config.OpenAIReasoningConfig{
|
||||
@@ -256,7 +328,7 @@ func TestApplyReasoningOff_clientOverrideOmit(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestApplyReasoningOff_deepseekExplicitlyDisablesDefaultThinking(t *testing.T) {
|
||||
for _, profile := range []string{"deepseek_compat", "auto", "openai_compat"} {
|
||||
for _, profile := range []string{"deepseek_compat", "auto"} {
|
||||
t.Run(profile, func(t *testing.T) {
|
||||
cfg := &einoopenai.ChatModelConfig{ExtraFields: map[string]any{
|
||||
"reasoning_effort": "high",
|
||||
@@ -287,6 +359,25 @@ func TestApplyReasoningOff_deepseekExplicitlyDisablesDefaultThinking(t *testing.
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyReasoningOff_openAIProfileWinsOverDeepseekEndpoint(t *testing.T) {
|
||||
cfg := &einoopenai.ChatModelConfig{ExtraFields: map[string]any{
|
||||
"reasoning_effort": "high",
|
||||
"vendor_option": true,
|
||||
}}
|
||||
oa := &config.OpenAIConfig{
|
||||
BaseURL: "https://api.deepseek.com",
|
||||
Model: "deepseek-v4-pro",
|
||||
Reasoning: config.OpenAIReasoningConfig{
|
||||
Mode: "off", Effort: "high", Profile: "openai_compat",
|
||||
},
|
||||
}
|
||||
ApplyToEinoChatModelConfig(cfg, oa, nil)
|
||||
assertNoReasoningFields(t, cfg)
|
||||
if cfg.ExtraFields["vendor_option"] != true {
|
||||
t.Fatalf("expected unrelated extra field preserved, got %#v", cfg.ExtraFields)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyReasoningOff_wirePayloadOmitsThinking(t *testing.T) {
|
||||
var requestBody map[string]any
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user