Compare commits

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

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

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

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

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

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

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

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

---------

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

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

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

* fix(chat): preserve navigation during conversation startup

* fix(tasks): guarantee hard cancellation

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

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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 00:29:12 +08:00
公明 ac6e04a94c Update config.example.yaml 2026-08-19 00:14:46 +08:00
公明 cb58b90083 Update .gitignore 2026-08-18 20:53:37 +08:00
公明 5ccabdc3e4 Add files via upload 2026-08-18 20:52:02 +08:00
公明 286511d0a8 Add files via upload 2026-08-18 20:48:50 +08:00
公明 1f66b93866 Add files via upload 2026-08-18 20:41:19 +08:00
公明 c21ba6c419 Add files via upload 2026-08-18 20:38:40 +08:00
公明 5720de0d27 Add files via upload 2026-08-18 20:36:34 +08:00
公明 5aa2c382b0 Add files via upload 2026-08-18 20:34:53 +08:00
公明 98c380ed5f Add files via upload 2026-08-18 20:31:48 +08:00
公明 11537713ce Add files via upload 2026-08-18 20:30:18 +08:00
公明 acc2ebd7e2 Add files via upload 2026-08-18 20:29:17 +08:00
公明 5eaddd9e8c Add files via upload 2026-08-18 20:25:43 +08:00
公明 3421d8b047 Add files via upload 2026-08-18 20:23:28 +08:00
公明 5b000d1e3e Add files via upload 2026-08-18 20:22:40 +08:00
公明 cecbe5a086 Add files via upload 2026-08-18 20:20:39 +08:00
公明 c7603c2b66 Add files via upload 2026-08-18 20:17:38 +08:00
公明 d3bfb1c98e Add files via upload 2026-08-18 20:15:30 +08:00
公明 55ee5fbac9 Delete internal directory 2026-08-18 20:14:29 +08:00
RuoJi6 910d07ea0a docs(hitl): remove stale asm_list_resources references (#259) 2026-08-17 17:19:16 +08:00
公明 217f5570c2 Update config.example.yaml 2026-08-17 15:55:03 +08:00
RuoJi6 f30a9de01d feat(hitl): expand safe read-only tool allowlist (#258) 2026-08-17 15:50:32 +08:00
公明 893f1342ae Add files via upload 2026-08-15 11:11:40 +08:00
公明 6db0975223 Add files via upload 2026-08-15 11:09:28 +08:00
公明 b935e1f90f Add files via upload 2026-08-15 11:06:53 +08:00
公明 3b7fd8d582 Add files via upload 2026-08-15 11:06:08 +08:00
公明 053c75b2cc Add files via upload 2026-08-15 11:04:35 +08:00
公明 7823fb6b0c Add files via upload 2026-08-15 11:03:13 +08:00
公明 9c0621819f Add files via upload 2026-08-15 10:13:12 +08:00
公明 aa11b32ce5 Add files via upload 2026-08-15 09:45:37 +08:00
92 changed files with 4751 additions and 2349 deletions
+24 -1
View File
@@ -1,30 +1,48 @@
# Runtime data
data/
chat_uploads/
*.db
*.db-shm
*.db-wal
*.db-journal
*.sqlite
*.sqlite3
# Local configuration and secrets
config.yaml
config.local.yaml
config.yaml.backup
*.backup
.env
.env.*
!.env.example
*.pem
*.key
*.crt
# Upgrade / settings backups
.upgrade-backup/
# Eino runtime task board (under skills_dir)
skills/.eino/
# Build outputs
cyberstrike-ai
cyberstrike-ai.exe
*.test
bin/
dist/
build/
.build/
target/
.gradle/
coverage.out
coverage.html
# Logs and temporary files
*.log
*.bak
*~
tmp/
temp/
@@ -38,7 +56,12 @@ __pycache__/
# Go
vendor/
# macOS / editors
# Node / browser extension
node_modules/
# macOS / Windows / editors
.DS_Store
Thumbs.db
desktop.ini
.idea/
.vscode/
+5 -5
View File
@@ -10,7 +10,7 @@
# ============================================
# 前端显示的版本号(可选,不填则显示默认版本)
version: "v1.7.14"
version: "v1.7.15"
# 服务器配置
server:
host: 0.0.0.0 # 监听地址,0.0.0.0 表示监听所有网络接口
@@ -57,7 +57,7 @@ monitor:
# - DeepSeek: https://api.deepseek.com/v1
# - 其他兼容 OpenAI 协议的 API
# 常用模型: gpt-4, gpt-3.5-turbo, deepseek-chat, claude-3-opus 等
# provider: 可选值 openai_compatible(默认) | claude(自动桥接到 Anthropic Claude Messages API)
# provider: 可选值 openai_compatible(默认) | claude(Eino 原生 Anthropic Messages API)
ai:
default_channel: qwen-max
channels:
@@ -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: | # 审查编辑模式;留空使用内置默认
+1 -1
View File
@@ -68,7 +68,7 @@ ai:
| `ai.default_channel` | Default channel ID for new conversations and requests without an explicit channel. |
| `ai.channels.<id>` | Channel config. IDs are normalized to lowercase letters, digits, and hyphens. |
| `name` | Display name in the Web UI; falls back to the ID. |
| `provider` | `openai_compatible` or `claude`. OpenAI-compatible channels map to runtime `openai`; Claude channels bridge to Anthropic Messages API. |
| `provider` | `openai_compatible` or `claude`. OpenAI-compatible channels map to runtime `openai`; Claude channels use Eino's native Anthropic Messages API component. |
| `base_url/api_key/model` | Required. Base URL usually includes a version path such as `/v1`. |
| `max_total_tokens` | Shared context budget for compression, attack-chain generation, multi-agent summaries, and similar paths. |
| `max_completion_tokens` | Per-response output cap; default is used when empty. |
+1 -1
View File
@@ -10,7 +10,7 @@ CyberStrikeAI can run as a local testing tool, an internal team service, or a pr
- Python for some MCP servers and tool scripts.
- SQLite files under `data/`; no external DB is required by default.
- Actual security tools installed in PATH. YAML files under `tools/` only describe commands.
- At least one `ai.channels` entry. Use `provider: openai_compatible` for OpenAI-compatible endpoints, or `provider: claude` for the Claude bridge.
- At least one `ai.channels` entry. Use `provider: openai_compatible` for OpenAI-compatible endpoints, or `provider: claude` for Eino's native Claude component.
Important persistent paths:
+10 -2
View File
@@ -25,7 +25,7 @@ hitl:
api_key: ""
model: "" # set a small model here; blank reuses the default AI channel model
retention_days: 90
tool_whitelist: [read_file, list_dir, glob, grep, tool_search]
tool_whitelist: [read_file, ls, glob, grep, tool_search, get_project_fact, list_project_facts, search_project_facts, list_vulnerabilities, get_vulnerability, get_asset, query_assets, list_knowledge_risk_types, get_tool_execution, wait_tool_execution, batch_task_list, batch_task_get, manage_webshell_list, c2_event, c2_file]
```
`audit_model` supports partial configuration. Empty fields inherit from the resolved default AI channel, so the common setup is to fill only `model` and run approvals on a cheaper small model.
@@ -91,10 +91,16 @@ In review-edit mode, you may narrow paths, targets, or command arguments before
Allowlisted tools skip approval, so keep the list stable and low-risk. Recommended examples:
- `read_file`
- `list_dir`
- `ls`
- `glob`
- `grep`
- `tool_search`
- Project and vulnerability reads: `get_project_fact`, `list_project_facts`, `search_project_facts`, `list_vulnerabilities`, `get_vulnerability`
- Asset and knowledge-metadata reads: `get_asset`, `query_assets`, `list_knowledge_risk_types`
- Execution and task-state reads: `get_tool_execution`, `wait_tool_execution`, `batch_task_list`, `batch_task_get`
- Local management-metadata reads: `manage_webshell_list`, `c2_event`, `c2_file`
These built-in MCP reads remain constrained by RBAC and resource scope; the allowlist only bypasses HITL approval and does not grant additional access. `list_dir` is effective only when that is the actual tool name; the Eino filesystem directory-listing tool is named `ls`.
Avoid globally allowlisting:
@@ -102,6 +108,8 @@ Avoid globally allowlisting:
- File write/delete tools
- C2 task tools
- WebShell command execution tools
- Nominally read-only tools that send requests to a target or external service, such as `webshell_file_read`, `webshell_file_list`, and `search_knowledge_base`
- Multiplexed tools whose actions include both reads and writes, such as `c2_session`, `c2_listener`, `c2_profile`, and `c2_task_manage`
## Mode Selection
+1 -1
View File
@@ -65,7 +65,7 @@ ai:
| `ai.default_channel` | 默认通道 ID。新对话、机器人、批量任务和未显式选择通道的请求使用它。 |
| `ai.channels.<id>` | 通道配置。ID 会归一化为小写、数字和短横线,例如 `Qwen_Max` 会变成 `qwen-max`。 |
| `name` | Web UI 展示名。留空时使用通道 ID。 |
| `provider` | `openai_compatible``claude``openai_compatible` 会在运行时映射为 `openai``claude` 会桥接到 Anthropic Messages API。 |
| `provider` | `openai_compatible``claude``openai_compatible` 会在运行时映射为 `openai``claude` 使用 Eino 原生 Anthropic Messages API。 |
| `base_url/api_key/model` | 必填。Base URL 通常需要包含版本路径,如 OpenAI/兼容网关的 `/v1`。 |
| `max_total_tokens` | 上下文压缩、攻击链构建、多代理摘要等共用的总预算。 |
| `max_completion_tokens` | 单次模型输出上限;未填时使用默认值。 |
+1 -1
View File
@@ -10,7 +10,7 @@
- Python:部分 MCP 服务或工具脚本需要 Python 运行环境。
- SQLite:默认使用文件型数据库,无需单独服务。
- 安全工具:`tools/` 中的 YAML 只是工具定义,实际命令如 `nmap``sqlmap``nuclei` 仍需安装到系统 PATH。
- 模型服务:至少配置一个 `ai.channels` 通道;`provider: openai_compatible` 适用于 OpenAI 兼容 API`provider: claude` 会走 Claude 桥接
- 模型服务:至少配置一个 `ai.channels` 通道;`provider: openai_compatible` 适用于 OpenAI 兼容 API`provider: claude` 使用 Eino 原生 Claude 组件
建议目录:
+10 -2
View File
@@ -25,7 +25,7 @@ hitl:
api_key: ""
model: "" # 可填小模型;留空复用默认 AI 通道的模型
retention_days: 90
tool_whitelist: [read_file, list_dir, glob, grep, tool_search]
tool_whitelist: [read_file, ls, glob, grep, tool_search, get_project_fact, list_project_facts, search_project_facts, list_vulnerabilities, get_vulnerability, get_asset, query_assets, list_knowledge_risk_types, get_tool_execution, wait_tool_execution, batch_task_list, batch_task_get, manage_webshell_list, c2_event, c2_file]
```
`audit_model` 的字段可以只填一部分。空字段会自动继承默认 AI 通道解析后的模型配置,因此常见做法是只填 `model`,让审计 Agent 使用更便宜的小模型。
@@ -91,10 +91,16 @@ hitl:
白名单工具会跳过审批,因此要保守维护。推荐放:
- `read_file`
- `list_dir`
- `ls`
- `glob`
- `grep`
- `tool_search`
- 项目与漏洞查询:`get_project_fact``list_project_facts``search_project_facts``list_vulnerabilities``get_vulnerability`
- 资产与知识元数据查询:`get_asset``query_assets``list_knowledge_risk_types`
- 执行与任务状态查询:`get_tool_execution``wait_tool_execution``batch_task_list``batch_task_get`
- 本地管理元数据查询:`manage_webshell_list``c2_event``c2_file`
上述内置 MCP 查询仍受 RBAC 和资源范围约束;白名单只跳过 HITL 审批,不扩大访问权限。`list_dir` 仅在实际工具名为该值时有效;Eino 文件系统的目录列表工具名是 `ls`
不建议直接全局白名单:
@@ -102,6 +108,8 @@ hitl:
- 文件写入/删除工具
- C2 任务工具
- WebShell 命令执行工具
- 会向目标或外部服务发起请求的“只读”工具,例如 `webshell_file_read``webshell_file_list``search_knowledge_base`
- 同一工具名同时包含读写 action 的复合工具,例如 `c2_session``c2_listener``c2_profile``c2_task_manage`
## 模式选择
+37 -3
View File
@@ -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
+144 -12
View File
@@ -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=
+13
View File
@@ -820,6 +820,19 @@ func (a *Agent) UpdateMCPExecutionDisplayResult(executionID, resultText string)
}
}
// MCPExecutionResultText returns the monitor-facing result text after storage
// guards such as large-output spilling have been applied.
func (a *Agent) MCPExecutionResultText(executionID string) string {
if a == nil || a.mcpServer == nil || strings.TrimSpace(executionID) == "" {
return ""
}
exec, ok := a.mcpServer.GetExecution(executionID)
if !ok || exec == nil || exec.Result == nil {
return ""
}
return mcp.ToolResultPlainText(exec.Result)
}
// CancelMCPToolExecutionWithNote 取消一次进行中的 MCP 工具(先内部后外部),与监控页「终止工具」一致;note 非空时合并进返回给模型的文本。
func (a *Agent) CancelMCPToolExecutionWithNote(executionID, note string) bool {
executionID = strings.TrimSpace(executionID)
+1 -1
View File
@@ -817,7 +817,7 @@ type MCPConfig struct {
}
type OpenAIConfig struct {
Provider string `yaml:"provider,omitempty" json:"provider,omitempty"` // API 提供商: "openai"(默认) 或 "claude"claude 时自动桥接为 Anthropic Messages API
Provider string `yaml:"provider,omitempty" json:"provider,omitempty"` // API 提供商: "openai"(默认) 或 "claude"claude 使用 Eino 原生 Anthropic Messages API
APIKey string `yaml:"api_key" json:"api_key"`
BaseURL string `yaml:"base_url" json:"base_url"`
Model string `yaml:"model" json:"model"`
+93 -51
View File
@@ -1548,12 +1548,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,24 +1569,10 @@ 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),
@@ -1603,36 +1589,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 +1612,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{
@@ -1704,6 +1670,82 @@ 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 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
@@ -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++ {
@@ -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
}
+25 -1
View File
@@ -18,11 +18,13 @@ import (
"cyberstrike-ai/internal/config"
"cyberstrike-ai/internal/database"
"cyberstrike-ai/internal/knowledge"
"cyberstrike-ai/internal/llm"
"cyberstrike-ai/internal/mcp"
"cyberstrike-ai/internal/mcp/builtin"
"cyberstrike-ai/internal/openai"
"cyberstrike-ai/internal/security"
"github.com/cloudwego/eino/schema"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"gopkg.in/yaml.v3"
@@ -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"`
+1
View File
@@ -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,
@@ -139,3 +139,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)
}
}
+210 -54
View File
@@ -1,11 +1,13 @@
package handler
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
@@ -87,6 +89,24 @@ type fofaSearchResponse struct {
Results []map[string]interface{} `json:"results"`
}
type spaceSearchEnvelope struct {
Code interface{} `json:"code"`
Message string `json:"message"`
Error string `json:"error"`
Query string `json:"query"`
Total int `json:"total"`
TotalCount int `json:"total_count"`
Page int `json:"page"`
PageSize int `json:"pagesize"`
Data json.RawMessage `json:"data"`
Matches json.RawMessage `json:"matches"`
Meta struct {
Pagination struct {
Total int `json:"total"`
} `json:"pagination"`
} `json:"meta"`
}
func normalizeSpaceSearchProvider(provider string) string {
switch strings.ToLower(strings.TrimSpace(provider)) {
case "", "fofa":
@@ -156,19 +176,20 @@ func (h *FofaHandler) resolveAPIKey(provider string) string {
}
func (h *FofaHandler) resolveBaseURL(provider string) string {
provider = normalizeSpaceSearchProvider(provider)
if h.cfg != nil {
switch normalizeSpaceSearchProvider(provider) {
switch provider {
case "zoomeye":
if v := strings.TrimSpace(h.cfg.ZoomEye.BaseURL); v != "" {
return v
return canonicalizeSpaceSearchBaseURL(provider, v)
}
case "quake":
if v := strings.TrimSpace(h.cfg.Quake.BaseURL); v != "" {
return v
return canonicalizeSpaceSearchBaseURL(provider, v)
}
case "shodan":
if v := strings.TrimSpace(h.cfg.Shodan.BaseURL); v != "" {
return v
return canonicalizeSpaceSearchBaseURL(provider, v)
}
default:
if v := strings.TrimSpace(h.cfg.FOFA.BaseURL); v != "" {
@@ -176,11 +197,11 @@ func (h *FofaHandler) resolveBaseURL(provider string) string {
}
}
}
switch normalizeSpaceSearchProvider(provider) {
switch provider {
case "zoomeye":
return "https://api.zoomeye.org/v2/search"
return "https://api.zoomeye.ai/v2/search"
case "quake":
return "https://quake.360.cn/api/v3/search/quake_service"
return "https://quake.360.net/api/v3/search/quake_service"
case "shodan":
return "https://api.shodan.io"
default:
@@ -188,6 +209,20 @@ func (h *FofaHandler) resolveBaseURL(provider string) string {
}
}
func canonicalizeSpaceSearchBaseURL(provider, raw string) string {
v := strings.TrimSpace(raw)
if v == "" {
return v
}
switch normalizeSpaceSearchProvider(provider) {
case "zoomeye":
v = strings.Replace(v, "://api.zoomeye.org", "://api.zoomeye.ai", 1)
case "quake":
v = strings.Replace(v, "://quake.360.cn", "://quake.360.net", 1)
}
return v
}
// ParseNaturalLanguage 将自然语言解析为 FOFA 查询语法(仅生成,不执行查询)
func (h *FofaHandler) ParseNaturalLanguage(c *gin.Context) {
var req fofaParseRequest
@@ -716,23 +751,17 @@ func (h *FofaHandler) searchZoomEye(c *gin.Context, req fofaSearchRequest, apiKe
if fields := strings.TrimSpace(req.Fields); fields != "" {
body["fields"] = fields
}
var apiResp struct {
Code int `json:"code"`
Message string `json:"message"`
Query string `json:"query"`
Total int `json:"total"`
Page int `json:"page"`
PageSize int `json:"pagesize"`
Data []map[string]interface{} `json:"data"`
}
var apiResp spaceSearchEnvelope
if !h.doJSONRequest(c, http.MethodPost, u.String(), apiKey, "API-KEY", body, &apiResp, "ZoomEye") {
return
}
if apiResp.Code != 60000 {
msg := strings.TrimSpace(apiResp.Message)
if msg == "" {
msg = "ZoomEye 返回错误"
}
rows, err := decodeSpaceSearchRows(apiResp.Data)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "解析 ZoomEye 响应失败: " + err.Error()})
return
}
if zoomEyeRequestFailed(apiResp.Code, apiResp.Message) {
msg := firstNonEmptySpaceSearchValue(apiResp.Message, messageFromRawObject(apiResp.Data), "ZoomEye 返回错误")
c.JSON(http.StatusBadGateway, gin.H{"error": msg})
return
}
@@ -744,8 +773,8 @@ func (h *FofaHandler) searchZoomEye(c *gin.Context, req fofaSearchRequest, apiKe
Page: firstPositive(apiResp.Page, req.Page),
Total: apiResp.Total,
Fields: fields,
ResultsCount: len(apiResp.Data),
Results: projectRows(apiResp.Data, fields),
ResultsCount: len(rows),
Results: projectRows(rows, fields),
})
}
@@ -766,25 +795,17 @@ func (h *FofaHandler) searchQuake(c *gin.Context, req fofaSearchRequest, apiKey
if len(fields) > 0 {
body["include"] = fields
}
var apiResp struct {
Code interface{} `json:"code"`
Message string `json:"message"`
TotalCount int `json:"total_count"`
Data []map[string]interface{} `json:"data"`
Meta struct {
Pagination struct {
Total int `json:"total"`
} `json:"pagination"`
} `json:"meta"`
}
var apiResp spaceSearchEnvelope
if !h.doJSONRequest(c, http.MethodPost, u.String(), apiKey, "X-QuakeToken", body, &apiResp, "Quake") {
return
}
rows, err := decodeSpaceSearchRows(apiResp.Data)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "解析 Quake 响应失败: " + err.Error()})
return
}
if !isZeroSpaceSearchCode(apiResp.Code) {
msg := strings.TrimSpace(apiResp.Message)
if msg == "" {
msg = "Quake 返回错误"
}
msg := firstNonEmptySpaceSearchValue(apiResp.Message, messageFromRawObject(apiResp.Data), "Quake 返回错误")
c.JSON(http.StatusBadGateway, gin.H{"error": msg})
return
}
@@ -796,8 +817,8 @@ func (h *FofaHandler) searchQuake(c *gin.Context, req fofaSearchRequest, apiKey
Page: req.Page,
Total: total,
Fields: fields,
ResultsCount: len(apiResp.Data),
Results: projectRows(apiResp.Data, fields),
ResultsCount: len(rows),
Results: projectRows(rows, fields),
})
}
@@ -811,6 +832,9 @@ func isZeroSpaceSearchCode(code interface{}) bool {
return v == 0
case float64:
return v == 0
case json.Number:
n, err := v.Int64()
return err == nil && n == 0
case string:
return strings.TrimSpace(v) == "0"
default:
@@ -818,6 +842,128 @@ func isZeroSpaceSearchCode(code interface{}) bool {
}
}
func isZoomEyeSuccessCode(code interface{}) bool {
switch v := code.(type) {
case int:
return v == 60000
case int64:
return v == 60000
case float64:
return v == 60000
case json.Number:
n, err := v.Int64()
return err == nil && n == 60000
case string:
return strings.TrimSpace(v) == "60000"
default:
return false
}
}
func zoomEyeRequestFailed(code interface{}, message string) bool {
if isZoomEyeSuccessCode(code) {
return false
}
msg := strings.ToLower(strings.TrimSpace(message))
if code == nil || isZeroSpaceSearchCode(code) {
return msg != "" && msg != "success" && msg != "ok" && msg != "successful."
}
return true
}
func decodeSpaceSearchRows(raw json.RawMessage) ([]map[string]interface{}, error) {
raw = bytes.TrimSpace(raw)
if len(raw) == 0 || bytes.Equal(raw, []byte("null")) {
return nil, nil
}
switch raw[0] {
case '[':
var rows []map[string]interface{}
if err := json.Unmarshal(raw, &rows); err != nil {
return nil, err
}
if rows == nil {
return []map[string]interface{}{}, nil
}
return rows, nil
case '{':
var obj map[string]interface{}
if err := json.Unmarshal(raw, &obj); err != nil {
return nil, err
}
if len(obj) == 0 {
return []map[string]interface{}{}, nil
}
for _, key := range []string{"data", "items", "matches", "results", "list", "records"} {
nested, ok := obj[key]
if !ok {
continue
}
switch rows := nested.(type) {
case []map[string]interface{}:
return rows, nil
case []interface{}:
return interfaceSliceToRowMaps(rows), nil
}
}
return []map[string]interface{}{}, nil
default:
return nil, fmt.Errorf("unexpected JSON value")
}
}
func interfaceSliceToRowMaps(items []interface{}) []map[string]interface{} {
out := make([]map[string]interface{}, 0, len(items))
for _, item := range items {
if row, ok := item.(map[string]interface{}); ok {
out = append(out, row)
}
}
return out
}
func messageFromRawObject(raw json.RawMessage) string {
raw = bytes.TrimSpace(raw)
if len(raw) == 0 || raw[0] != '{' {
return ""
}
var obj map[string]interface{}
if err := json.Unmarshal(raw, &obj); err != nil {
return ""
}
for _, key := range []string{"message", "error", "errmsg", "msg"} {
if s, ok := obj[key].(string); ok {
if msg := strings.TrimSpace(s); msg != "" {
return msg
}
}
}
return ""
}
func extractRemoteAPIError(body []byte, statusCode int, label string) string {
trimmed := bytes.TrimSpace(body)
if len(trimmed) > 0 && trimmed[0] == '{' {
var obj map[string]interface{}
if err := json.Unmarshal(trimmed, &obj); err == nil {
for _, key := range []string{"error", "message", "errmsg", "msg"} {
if s, ok := obj[key].(string); ok {
if msg := strings.TrimSpace(s); msg != "" {
return msg
}
}
}
}
}
if len(trimmed) > 0 && trimmed[0] == '<' {
return fmt.Sprintf("%s 返回了网页而不是 JSONHTTP %d),请检查 Base URL 或网络是否被拦截", label, statusCode)
}
if statusCode < 200 || statusCode >= 300 {
return fmt.Sprintf("%s 返回非 2xx: %d", label, statusCode)
}
return ""
}
func (h *FofaHandler) searchShodan(c *gin.Context, req fofaSearchRequest, apiKey string) {
baseURL := strings.TrimRight(h.resolveBaseURL("shodan"), "/") + "/shodan/host/search"
u, err := url.Parse(baseURL)
@@ -826,11 +972,7 @@ func (h *FofaHandler) searchShodan(c *gin.Context, req fofaSearchRequest, apiKey
return
}
var apiResp struct {
Total int `json:"total"`
Matches []map[string]interface{} `json:"matches"`
Error string `json:"error"`
}
var apiResp spaceSearchEnvelope
targetSize := req.Size
if targetSize <= 0 {
targetSize = 100
@@ -852,19 +994,23 @@ func (h *FofaHandler) searchShodan(c *gin.Context, req fofaSearchRequest, apiKey
params.Set("fields", fields)
}
pageURL.RawQuery = params.Encode()
apiResp.Matches = nil
apiResp.Error = ""
apiResp = spaceSearchEnvelope{}
if !h.doJSONRequest(c, http.MethodGet, pageURL.String(), "", "", nil, &apiResp, "Shodan") {
return
}
if strings.TrimSpace(apiResp.Error) != "" {
c.JSON(http.StatusBadGateway, gin.H{"error": apiResp.Error})
if errMsg := firstNonEmptySpaceSearchValue(apiResp.Error, apiResp.Message); errMsg != "" {
c.JSON(http.StatusBadGateway, gin.H{"error": errMsg})
return
}
if len(apiResp.Matches) == 0 {
pageMatches, err := decodeSpaceSearchRows(apiResp.Matches)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "解析 Shodan 响应失败: " + err.Error()})
return
}
if len(pageMatches) == 0 {
break
}
matches = append(matches, apiResp.Matches...)
matches = append(matches, pageMatches...)
if len(matches) >= targetSize {
matches = matches[:targetSize]
break
@@ -947,11 +1093,21 @@ func (h *FofaHandler) doJSONRequest(c *gin.Context, method, endpoint, apiKey, he
return false
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
c.JSON(http.StatusBadGateway, gin.H{"error": fmt.Sprintf("%s 返回非 2xx: %d", label, resp.StatusCode)})
respBody, err := io.ReadAll(io.LimitReader(resp.Body, 32<<20))
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "读取 " + label + " 响应失败: " + err.Error()})
return false
}
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
msg := extractRemoteAPIError(respBody, resp.StatusCode, label)
c.JSON(http.StatusBadGateway, gin.H{"error": msg})
return false
}
if err := json.Unmarshal(respBody, out); err != nil {
if msg := extractRemoteAPIError(respBody, resp.StatusCode, label); strings.Contains(msg, "网页") {
c.JSON(http.StatusBadGateway, gin.H{"error": msg})
return false
}
c.JSON(http.StatusBadGateway, gin.H{"error": "解析 " + label + " 响应失败: " + err.Error()})
return false
}
+139 -1
View File
@@ -164,7 +164,7 @@ func TestQuakeSearchHandlesStringErrorCode(t *testing.T) {
t.Fatalf("Quake token = %q, want test-quake-key", got)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"code":"q5000","message":"查询语法错误"}`))
_, _ = w.Write([]byte(`{"code":"q5000","message":"查询语法错误","data":{}}`))
}))
defer quakeServer.Close()
@@ -195,6 +195,144 @@ func TestQuakeSearchHandlesStringErrorCode(t *testing.T) {
}
}
func TestQuakeSearchAcceptsArrayData(t *testing.T) {
gin.SetMode(gin.TestMode)
t.Setenv("QUAKE_API_KEY", "")
quakeServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"code":0,"message":"Successful.","data":[{"ip":"1.1.1.1","port":53}],"meta":{"pagination":{"total":1}}}`))
}))
defer quakeServer.Close()
h := NewFofaHandler(&config.Config{
Quake: config.SpaceSearchConfig{
BaseURL: quakeServer.URL,
APIKey: "test-quake-key",
},
}, zap.NewNop())
recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
body := `{"provider":"quake","query":"ip:\"1.1.1.1\"","fields":"ip,port","size":10,"page":1}`
ctx.Request = httptest.NewRequest(http.MethodPost, "/api/fofa/search", strings.NewReader(body))
ctx.Request.Header.Set("Content-Type", "application/json")
h.Search(ctx)
if recorder.Code != http.StatusOK {
t.Fatalf("Search() status = %d, body = %s", recorder.Code, recorder.Body.String())
}
var response fofaSearchResponse
if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
t.Fatalf("decode response: %v", err)
}
if response.ResultsCount != 1 || response.Total != 1 {
t.Fatalf("results_count=%d total=%d, want 1/1", response.ResultsCount, response.Total)
}
}
func TestZoomEyeSearchHandlesObjectDataError(t *testing.T) {
gin.SetMode(gin.TestMode)
t.Setenv("ZOOMEYE_API_KEY", "")
zoomeyeServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("API-KEY"); got != "test-zoomeye-key" {
t.Fatalf("ZoomEye API-KEY = %q, want test-zoomeye-key", got)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"code":40001,"message":"invalid api key","data":{}}`))
}))
defer zoomeyeServer.Close()
h := NewFofaHandler(&config.Config{
ZoomEye: config.SpaceSearchConfig{
BaseURL: zoomeyeServer.URL,
APIKey: "test-zoomeye-key",
},
}, zap.NewNop())
recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
body := `{"provider":"zoomeye","query":"ip=\"1.1.1.1\"","fields":"ip,port","size":10,"page":1}`
ctx.Request = httptest.NewRequest(http.MethodPost, "/api/fofa/search", strings.NewReader(body))
ctx.Request.Header.Set("Content-Type", "application/json")
h.Search(ctx)
if recorder.Code != http.StatusBadGateway {
t.Fatalf("Search() status = %d, want %d, body = %s", recorder.Code, http.StatusBadGateway, recorder.Body.String())
}
bodyText := recorder.Body.String()
if !strings.Contains(bodyText, "invalid api key") {
t.Fatalf("response should include ZoomEye error message, got %s", bodyText)
}
if strings.Contains(bodyText, "cannot unmarshal") {
t.Fatalf("response exposed JSON type decoding failure: %s", bodyText)
}
}
func TestShodanSearchSurfacesJSONErrorOnUnauthorized(t *testing.T) {
gin.SetMode(gin.TestMode)
t.Setenv("SHODAN_API_KEY", "")
shodanServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"error":"Invalid API key"}`))
}))
defer shodanServer.Close()
h := NewFofaHandler(&config.Config{
Shodan: config.SpaceSearchConfig{
BaseURL: shodanServer.URL,
APIKey: "test-shodan-key",
},
}, zap.NewNop())
recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
body := `{"provider":"shodan","query":"product:nginx","fields":"ip_str,port","size":10,"page":1}`
ctx.Request = httptest.NewRequest(http.MethodPost, "/api/fofa/search", strings.NewReader(body))
ctx.Request.Header.Set("Content-Type", "application/json")
h.Search(ctx)
if recorder.Code != http.StatusBadGateway {
t.Fatalf("Search() status = %d, want %d, body = %s", recorder.Code, http.StatusBadGateway, recorder.Body.String())
}
bodyText := recorder.Body.String()
if !strings.Contains(bodyText, "Invalid API key") {
t.Fatalf("response should include Shodan error message, got %s", bodyText)
}
if strings.Contains(bodyText, "非 2xx") {
t.Fatalf("response should not hide Shodan error behind generic status, got %s", bodyText)
}
}
func TestCanonicalizeSpaceSearchBaseURLMigratesLegacyHosts(t *testing.T) {
t.Parallel()
cases := []struct {
provider string
in string
want string
}{
{provider: "zoomeye", in: "https://api.zoomeye.org/v2/search", want: "https://api.zoomeye.ai/v2/search"},
{provider: "quake", in: "https://quake.360.cn/api/v3/search/quake_service", want: "https://quake.360.net/api/v3/search/quake_service"},
{provider: "shodan", in: "https://api.shodan.io", want: "https://api.shodan.io"},
}
for _, tc := range cases {
tc := tc
t.Run(tc.provider, func(t *testing.T) {
t.Parallel()
got := canonicalizeSpaceSearchBaseURL(tc.provider, tc.in)
if got != tc.want {
t.Fatalf("canonicalizeSpaceSearchBaseURL() = %q, want %q", got, tc.want)
}
})
}
}
func TestExtractInfoCollectJSONObject(t *testing.T) {
t.Parallel()
cases := []struct {
+11 -1
View File
@@ -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
+34 -15
View File
@@ -8,9 +8,11 @@ import (
"time"
"cyberstrike-ai/internal/config"
"cyberstrike-ai/internal/llm"
"cyberstrike-ai/internal/openai"
einoopenai "github.com/cloudwego/eino-ext/components/model/openai"
"github.com/cloudwego/eino/components/model"
"github.com/cloudwego/eino/flow/retriever/multiquery"
"go.uber.org/zap"
)
@@ -29,21 +31,38 @@ func WireRetrieverPipeline(ctx context.Context, r *Retriever, openAI *config.Ope
}
r.wireOpenAI = openAI
httpClient := openai.NewEinoHTTPClient(openAI, &http.Client{Timeout: 120 * time.Second})
maxCompletionTokens := openAI.MaxCompletionTokensEffective()
chatCfg := &einoopenai.ChatModelConfig{
APIKey: strings.TrimSpace(openAI.APIKey),
BaseURL: strings.TrimSuffix(strings.TrimSpace(openAI.BaseURL), "/"),
Model: strings.TrimSpace(openAI.Model),
HTTPClient: httpClient,
MaxCompletionTokens: &maxCompletionTokens,
}
if chatCfg.Model == "" {
chatCfg.Model = "gpt-4o"
}
rewriteLLM, err := einoopenai.NewChatModel(ctx, chatCfg)
if err != nil {
return fmt.Errorf("multi_query rewrite model: %w", err)
baseHTTPClient := &http.Client{Timeout: 120 * time.Second}
var rewriteLLM model.ChatModel
if llm.IsClaudeProvider(openAI.Provider) {
nativeModel, err := llm.NewClaudeAgenticModel(
ctx,
*openAI,
baseHTTPClient,
openAI.MaxCompletionTokensEffective(),
nil,
)
if err != nil {
return fmt.Errorf("multi_query native Claude rewrite model: %w", err)
}
rewriteLLM = llm.NewAgenticChatModelAdapter(nativeModel)
} else {
httpClient := openai.NewEinoHTTPClient(openAI, baseHTTPClient)
maxCompletionTokens := openAI.MaxCompletionTokensEffective()
chatCfg := &einoopenai.ChatModelConfig{
APIKey: strings.TrimSpace(openAI.APIKey),
BaseURL: strings.TrimSuffix(strings.TrimSpace(openAI.BaseURL), "/"),
Model: strings.TrimSpace(openAI.Model),
HTTPClient: httpClient,
MaxCompletionTokens: &maxCompletionTokens,
}
if chatCfg.Model == "" {
chatCfg.Model = "gpt-4o"
}
var err error
rewriteLLM, err = einoopenai.NewChatModel(ctx, chatCfg)
if err != nil {
return fmt.Errorf("multi_query rewrite model: %w", err)
}
}
reranker, err := NewHTTPReranker(&r.config.Rerank, openAI, r.logger)
+125
View File
@@ -0,0 +1,125 @@
package llm
import (
"context"
"fmt"
"github.com/cloudwego/eino/components/model"
"github.com/cloudwego/eino/schema"
)
// AgenticChatModelAdapter exposes a text-oriented AgenticModel as a classic
// BaseChatModel for Eino components that have not adopted AgenticMessage yet.
// It adapts only Eino's in-memory message shape; no HTTP protocol is translated.
type AgenticChatModelAdapter struct {
model model.AgenticModel
tools []*schema.ToolInfo
}
func NewAgenticChatModelAdapter(agenticModel model.AgenticModel) model.ChatModel {
return &AgenticChatModelAdapter{model: agenticModel}
}
func (a *AgenticChatModelAdapter) BindTools(tools []*schema.ToolInfo) error {
if a == nil || a.model == nil {
return fmt.Errorf("agentic chat adapter: model is nil")
}
a.tools = append([]*schema.ToolInfo(nil), tools...)
return nil
}
func (a *AgenticChatModelAdapter) Generate(
ctx context.Context,
input []*schema.Message,
opts ...model.Option,
) (*schema.Message, error) {
if a == nil || a.model == nil {
return nil, fmt.Errorf("agentic chat adapter: model is nil")
}
out, err := a.model.Generate(ctx, classicMessagesToAgentic(input), commonAgenticOptions(a.tools, opts...)...)
if err != nil {
return nil, err
}
return agenticMessageToClassic(out), nil
}
func (a *AgenticChatModelAdapter) Stream(
ctx context.Context,
input []*schema.Message,
opts ...model.Option,
) (*schema.StreamReader[*schema.Message], error) {
if a == nil || a.model == nil {
return nil, fmt.Errorf("agentic chat adapter: model is nil")
}
stream, err := a.model.Stream(ctx, classicMessagesToAgentic(input), commonAgenticOptions(a.tools, opts...)...)
if err != nil {
return nil, err
}
return schema.StreamReaderWithConvert(stream, func(msg *schema.AgenticMessage) (*schema.Message, error) {
return agenticMessageToClassic(msg), nil
}), nil
}
func classicMessagesToAgentic(input []*schema.Message) []*schema.AgenticMessage {
out := make([]*schema.AgenticMessage, 0, len(input))
for _, msg := range input {
if msg == nil {
continue
}
role := schema.AgenticRoleTypeUser
switch msg.Role {
case schema.System:
role = schema.AgenticRoleTypeSystem
case schema.Assistant:
role = schema.AgenticRoleTypeAssistant
}
agentic := &schema.AgenticMessage{Role: role}
if msg.Role == schema.Assistant {
if msg.Content != "" {
agentic.ContentBlocks = append(agentic.ContentBlocks, schema.NewContentBlock(&schema.AssistantGenText{Text: msg.Content}))
}
} else if msg.Content != "" {
agentic.ContentBlocks = append(agentic.ContentBlocks, schema.NewContentBlock(&schema.UserInputText{Text: msg.Content}))
}
out = append(out, agentic)
}
return out
}
func agenticMessageToClassic(msg *schema.AgenticMessage) *schema.Message {
if msg == nil {
return nil
}
content, reasoning := AgenticText(msg)
return &schema.Message{
Role: schema.Assistant,
Content: content,
ReasoningContent: reasoning,
}
}
func commonAgenticOptions(boundTools []*schema.ToolInfo, opts ...model.Option) []model.Option {
common := model.GetCommonOptions(&model.Options{
Tools: append([]*schema.ToolInfo(nil), boundTools...),
}, opts...)
out := make([]model.Option, 0, 6)
if common.Temperature != nil {
out = append(out, model.WithTemperature(*common.Temperature))
}
if common.Model != nil {
out = append(out, model.WithModel(*common.Model))
}
if common.TopP != nil {
out = append(out, model.WithTopP(*common.TopP))
}
if common.MaxTokens != nil {
out = append(out, model.WithMaxTokens(*common.MaxTokens))
}
if len(common.Stop) > 0 {
out = append(out, model.WithStop(common.Stop))
}
if common.Tools != nil {
out = append(out, model.WithTools(common.Tools))
}
return out
}
+60
View File
@@ -0,0 +1,60 @@
package llm
import (
"context"
"net/http"
"strings"
"cyberstrike-ai/internal/config"
agenticclaude "github.com/cloudwego/eino-ext/components/model/agenticclaude"
"github.com/cloudwego/eino/components/model"
"github.com/cloudwego/eino/schema"
)
func IsClaudeProvider(provider string) bool {
provider = strings.ToLower(strings.TrimSpace(provider))
return provider == "claude" || provider == "anthropic"
}
func NewClaudeAgenticModel(
ctx context.Context,
cfg config.OpenAIConfig,
httpClient *http.Client,
maxTokens int,
extraFields map[string]any,
) (model.AgenticModel, error) {
if maxTokens <= 0 {
maxTokens = cfg.MaxCompletionTokensEffective()
}
if cfg.IsDeepSeekEndpointOrModel() {
httpClient = newDeepSeekAnthropicCompatibleClient(httpClient)
}
return agenticclaude.New(ctx, &agenticclaude.Config{
APIKey: strings.TrimSpace(cfg.APIKey),
BaseURL: strings.TrimSuffix(strings.TrimSpace(cfg.BaseURL), "/"),
Model: strings.TrimSpace(cfg.Model),
MaxTokens: maxTokens,
HTTPClient: httpClient,
ExtraFields: extraFields,
})
}
func AgenticText(msg *schema.AgenticMessage) (content, reasoning string) {
if msg == nil {
return "", ""
}
var contentParts, reasoningParts []string
for _, block := range msg.ContentBlocks {
if block == nil {
continue
}
switch {
case block.AssistantGenText != nil:
contentParts = append(contentParts, block.AssistantGenText.Text)
case block.Reasoning != nil:
reasoningParts = append(reasoningParts, block.Reasoning.Text)
}
}
return strings.Join(contentParts, ""), strings.Join(reasoningParts, "")
}
+76
View File
@@ -0,0 +1,76 @@
package llm
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
)
// newDeepSeekAnthropicCompatibleClient compensates for DeepSeek's Anthropic
// endpoint lagging behind the current Anthropic SDK. The SDK emits
// {"type":"custom"} for function tools, while DeepSeek expects the older
// name/input_schema/description shape without that discriminator.
//
// This is a field-level compatibility fix; requests still originate from
// Eino's native agenticclaude model and remain Anthropic Messages API requests.
func newDeepSeekAnthropicCompatibleClient(base *http.Client) *http.Client {
if base == nil {
base = http.DefaultClient
}
cloned := *base
transport := base.Transport
if transport == nil {
transport = http.DefaultTransport
}
cloned.Transport = &deepSeekAnthropicCompatRoundTripper{base: transport}
return &cloned
}
type deepSeekAnthropicCompatRoundTripper struct {
base http.RoundTripper
}
func (rt *deepSeekAnthropicCompatRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
if req == nil || req.Body == nil || req.Method != http.MethodPost {
return rt.base.RoundTrip(req)
}
body, err := io.ReadAll(req.Body)
if err != nil {
return nil, fmt.Errorf("read DeepSeek Anthropic request: %w", err)
}
_ = req.Body.Close()
var payload map[string]any
if err := json.Unmarshal(body, &payload); err != nil {
req.Body = io.NopCloser(bytes.NewReader(body))
return rt.base.RoundTrip(req)
}
tools, ok := payload["tools"].([]any)
if !ok {
req.Body = io.NopCloser(bytes.NewReader(body))
return rt.base.RoundTrip(req)
}
changed := false
for _, rawTool := range tools {
tool, ok := rawTool.(map[string]any)
if !ok || tool["type"] != "custom" {
continue
}
delete(tool, "type")
changed = true
}
if changed {
body, err = json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("marshal DeepSeek Anthropic request: %w", err)
}
}
req.Body = io.NopCloser(bytes.NewReader(body))
req.ContentLength = int64(len(body))
req.GetBody = func() (io.ReadCloser, error) {
return io.NopCloser(bytes.NewReader(body)), nil
}
return rt.base.RoundTrip(req)
}
@@ -0,0 +1,55 @@
package llm
import (
"bytes"
"io"
"net/http"
"strings"
"testing"
)
type captureRoundTripper struct {
body string
}
func (rt *captureRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
body, err := io.ReadAll(req.Body)
if err != nil {
return nil, err
}
rt.body = string(body)
return &http.Response{
StatusCode: http.StatusOK,
Header: make(http.Header),
Body: io.NopCloser(bytes.NewReader(nil)),
Request: req,
}, nil
}
func TestDeepSeekAnthropicCompatStripsOnlyCustomToolType(t *testing.T) {
t.Parallel()
capture := &captureRoundTripper{}
client := newDeepSeekAnthropicCompatibleClient(&http.Client{Transport: capture})
req, err := http.NewRequest(
http.MethodPost,
"https://api.deepseek.com/anthropic/v1/messages",
strings.NewReader(`{"tools":[{"type":"custom","name":"mcp_tool","input_schema":{"type":"object"}},{"type":"web_search_20260209","name":"web_search"}]}`),
)
if err != nil {
t.Fatalf("NewRequest: %v", err)
}
resp, err := client.Do(req)
if err != nil {
t.Fatalf("Do: %v", err)
}
_ = resp.Body.Close()
if strings.Contains(capture.body, `"type":"custom"`) {
t.Fatalf("custom discriminator was not removed: %s", capture.body)
}
if !strings.Contains(capture.body, `"type":"web_search_20260209"`) {
t.Fatalf("server tool discriminator was removed: %s", capture.body)
}
if !strings.Contains(capture.body, `"name":"mcp_tool"`) {
t.Fatalf("custom tool definition was removed: %s", capture.body)
}
}
@@ -0,0 +1,126 @@
package multiagent
import (
"context"
"github.com/cloudwego/eino/adk"
"github.com/cloudwego/eino/schema"
"go.uber.org/zap"
)
// agenticOrphanToolPrunerMiddleware is the AgenticMessage equivalent of
// orphanToolPrunerMiddleware. It removes user-role messages whose content
// blocks are exclusively FunctionToolResult entries with CallIDs that do not
// match any FunctionToolCall in the history.
//
// This is a defense-in-depth layer after agenticToolPairReconcilerMiddleware;
// the reconciler handles the common case (assistant followed by its results)
// while this pruner catches stray results that appear before their assistant
// or in non-adjacent positions (e.g. after summarization rewriting).
type agenticOrphanToolPrunerMiddleware struct {
*adk.TypedBaseChatModelAgentMiddleware[*schema.AgenticMessage]
logger *zap.Logger
phase string
}
func newAgenticOrphanToolPrunerMiddleware(logger *zap.Logger, phase string) adk.TypedChatModelAgentMiddleware[*schema.AgenticMessage] {
return &agenticOrphanToolPrunerMiddleware{
TypedBaseChatModelAgentMiddleware: &adk.TypedBaseChatModelAgentMiddleware[*schema.AgenticMessage]{},
logger: logger,
phase: phase,
}
}
func (m *agenticOrphanToolPrunerMiddleware) BeforeModelRewriteState(
ctx context.Context,
state *adk.TypedChatModelAgentState[*schema.AgenticMessage],
mc *adk.TypedModelContext[*schema.AgenticMessage],
) (context.Context, *adk.TypedChatModelAgentState[*schema.AgenticMessage], error) {
_ = mc
if m == nil || state == nil || len(state.Messages) == 0 {
return ctx, state, nil
}
// Pass 1: collect all provided CallIDs from assistant FunctionToolCall blocks.
provided := make(map[string]struct{}, 8)
for _, msg := range state.Messages {
if msg == nil || msg.Role != schema.AgenticRoleTypeAssistant {
continue
}
for _, block := range msg.ContentBlocks {
if block != nil && block.FunctionToolCall != nil && block.FunctionToolCall.CallID != "" {
provided[block.FunctionToolCall.CallID] = struct{}{}
}
}
}
// Fast path: check if any orphan exists.
hasOrphan := false
for _, msg := range state.Messages {
if msg == nil || !isPureAgenticToolResult(msg) {
continue
}
for _, id := range agenticToolResultCallIDs(msg) {
if _, ok := provided[id]; !ok {
hasOrphan = true
break
}
}
if hasOrphan {
break
}
}
if !hasOrphan {
return ctx, state, nil
}
// Pass 2: build pruned list.
pruned := make([]*schema.AgenticMessage, 0, len(state.Messages))
var droppedIDs []string
var droppedNames []string
for _, msg := range state.Messages {
if msg == nil {
continue
}
if !isPureAgenticToolResult(msg) {
pruned = append(pruned, msg)
continue
}
// Check if ALL result call IDs are orphans. If any is matched, keep the
// message (the reconciler already handled partial mismatches).
allOrphan := true
for _, id := range agenticToolResultCallIDs(msg) {
if _, ok := provided[id]; ok {
allOrphan = false
break
}
}
if allOrphan {
for _, block := range msg.ContentBlocks {
if block != nil && block.FunctionToolResult != nil {
droppedIDs = append(droppedIDs, block.FunctionToolResult.CallID)
droppedNames = append(droppedNames, block.FunctionToolResult.Name)
}
}
continue
}
pruned = append(pruned, msg)
}
if len(droppedIDs) == 0 {
return ctx, state, nil
}
if m.logger != nil {
m.logger.Warn("agentic orphan tool messages pruned before model call",
zap.String("phase", m.phase),
zap.Int("dropped_count", len(droppedIDs)),
zap.Strings("dropped_tool_call_ids", droppedIDs),
zap.Strings("dropped_tool_names", droppedNames),
zap.Int("messages_before", len(state.Messages)),
zap.Int("messages_after", len(pruned)),
)
}
ns := *state
ns.Messages = pruned
return ctx, &ns, nil
}
@@ -0,0 +1,247 @@
package multiagent
import (
"context"
"fmt"
"github.com/cloudwego/eino/adk"
"github.com/cloudwego/eino/schema"
"go.uber.org/zap"
)
// agenticToolPairReconcilerMiddleware is the AgenticMessage equivalent of
// toolPairReconcilerMiddleware. It ensures every assistant FunctionToolCall
// block is followed by a matching FunctionToolResult message, patching or
// dropping as needed so the downstream model never receives an unpaired
// tool-call history.
//
// In the AgenticMessage protocol:
// - Assistant tool calls: Role=AgenticRoleTypeAssistant with FunctionToolCall content blocks.
// - Tool results: Role=AgenticRoleTypeUser with FunctionToolResult content blocks.
//
// This middleware runs after summarization which may truncate history and
// break pairings.
type agenticToolPairReconcilerMiddleware struct {
*adk.TypedBaseChatModelAgentMiddleware[*schema.AgenticMessage]
logger *zap.Logger
phase string
}
func newAgenticToolPairReconcilerMiddleware(logger *zap.Logger, phase string) adk.TypedChatModelAgentMiddleware[*schema.AgenticMessage] {
return &agenticToolPairReconcilerMiddleware{
TypedBaseChatModelAgentMiddleware: &adk.TypedBaseChatModelAgentMiddleware[*schema.AgenticMessage]{},
logger: logger,
phase: phase,
}
}
func (m *agenticToolPairReconcilerMiddleware) BeforeModelRewriteState(
ctx context.Context,
state *adk.TypedChatModelAgentState[*schema.AgenticMessage],
mc *adk.TypedModelContext[*schema.AgenticMessage],
) (context.Context, *adk.TypedChatModelAgentState[*schema.AgenticMessage], error) {
_ = mc
if m == nil || state == nil || len(state.Messages) == 0 {
return ctx, state, nil
}
usedIDs := make(map[string]struct{}, 16)
changed := false
patched := 0
dropped := 0
out := make([]*schema.AgenticMessage, 0, len(state.Messages))
for i := 0; i < len(state.Messages); {
msg := state.Messages[i]
if msg == nil {
changed = true
i++
continue
}
calls := agenticFunctionToolCalls(msg)
// Non-assistant or assistant without tool calls — but check for orphan
// tool-result messages (user role with only FunctionToolResult blocks).
if len(calls) == 0 {
if isPureAgenticToolResult(msg) {
// Orphan tool result not preceded by its assistant; drop it.
changed = true
dropped++
i++
continue
}
out = append(out, msg)
i++
continue
}
// Deduplicate / fix empty call IDs.
idsChanged := false
for ci := range calls {
id := calls[ci].CallID
_, duplicate := usedIDs[id]
if id == "" || duplicate {
base := fmt.Sprintf("patched_agentic_call_%d_%d", i, ci)
id = base
for suffix := 1; ; suffix++ {
if _, exists := usedIDs[id]; !exists {
break
}
id = fmt.Sprintf("%s_%d", base, suffix)
}
calls[ci].CallID = id
idsChanged = true
changed = true
}
usedIDs[id] = struct{}{}
}
assistant := msg
if idsChanged {
assistant = cloneAgenticMessageWithCalls(msg, calls)
}
out = append(out, assistant)
// Build expected set.
expected := make(map[string]*schema.FunctionToolCall, len(calls))
for ci := range calls {
expected[calls[ci].CallID] = calls[ci]
}
// Consume following tool-result messages.
results := make(map[string]*schema.AgenticMessage, len(calls))
j := i + 1
for j < len(state.Messages) {
next := state.Messages[j]
if next == nil {
changed = true
j++
continue
}
if !isPureAgenticToolResult(next) {
break
}
resultCallIDs := agenticToolResultCallIDs(next)
consumed := false
for _, rid := range resultCallIDs {
if _, wanted := expected[rid]; !wanted {
continue
}
if _, dup := results[rid]; dup {
continue
}
results[rid] = next
consumed = true
}
if !consumed {
changed = true
dropped++
}
j++
}
// Emit results in call order, patching missing ones.
for _, tc := range calls {
if result, ok := results[tc.CallID]; ok {
out = append(out, result)
continue
}
out = append(out, makeAgenticPatchedToolResult(tc.CallID, tc.Name))
changed = true
patched++
}
i = j
}
if !changed {
return ctx, state, nil
}
if m.logger != nil {
m.logger.Warn("agentic tool-call/result pairs reconciled before model call",
zap.String("phase", m.phase),
zap.Int("patched_results", patched),
zap.Int("dropped_results", dropped),
zap.Int("messages_before", len(state.Messages)),
zap.Int("messages_after", len(out)),
)
}
ns := *state
ns.Messages = out
return ctx, &ns, nil
}
// agenticFunctionToolCalls extracts FunctionToolCall pointers from an
// assistant message's content blocks. Returns nil for non-assistant messages.
func agenticFunctionToolCalls(msg *schema.AgenticMessage) []*schema.FunctionToolCall {
if msg == nil || msg.Role != schema.AgenticRoleTypeAssistant {
return nil
}
var out []*schema.FunctionToolCall
for _, block := range msg.ContentBlocks {
if block != nil && block.FunctionToolCall != nil {
out = append(out, block.FunctionToolCall)
}
}
return out
}
// isPureAgenticToolResult returns true when the message is a user-role
// message whose content blocks are exclusively FunctionToolResult entries.
func isPureAgenticToolResult(msg *schema.AgenticMessage) bool {
if msg == nil || msg.Role != schema.AgenticRoleTypeUser || len(msg.ContentBlocks) == 0 {
return false
}
for _, block := range msg.ContentBlocks {
if block == nil {
continue
}
if block.FunctionToolResult == nil {
return false
}
}
return true
}
// agenticToolResultCallIDs extracts all CallIDs from FunctionToolResult blocks.
func agenticToolResultCallIDs(msg *schema.AgenticMessage) []string {
if msg == nil {
return nil
}
var ids []string
for _, block := range msg.ContentBlocks {
if block != nil && block.FunctionToolResult != nil && block.FunctionToolResult.CallID != "" {
ids = append(ids, block.FunctionToolResult.CallID)
}
}
return ids
}
func cloneAgenticMessageWithCalls(msg *schema.AgenticMessage, calls []*schema.FunctionToolCall) *schema.AgenticMessage {
cloned := *msg
cloned.ContentBlocks = make([]*schema.ContentBlock, 0, len(msg.ContentBlocks))
callIdx := 0
for _, block := range msg.ContentBlocks {
if block != nil && block.FunctionToolCall != nil && callIdx < len(calls) {
cloned.ContentBlocks = append(cloned.ContentBlocks, schema.NewContentBlock(calls[callIdx]))
callIdx++
} else {
cloned.ContentBlocks = append(cloned.ContentBlocks, block)
}
}
return &cloned
}
func makeAgenticPatchedToolResult(callID, name string) *schema.AgenticMessage {
return &schema.AgenticMessage{
Role: schema.AgenticRoleTypeUser,
ContentBlocks: []*schema.ContentBlock{schema.NewContentBlock(&schema.FunctionToolResult{
CallID: callID,
Name: name,
Content: []*schema.FunctionToolResultContentBlock{{
Type: schema.FunctionToolResultContentBlockTypeText,
Text: &schema.UserInputText{Text: patchedMissingToolResult},
}},
})},
}
}
@@ -0,0 +1,157 @@
package multiagent
import (
"context"
"testing"
"github.com/cloudwego/eino/adk"
"github.com/cloudwego/eino/schema"
)
func TestAgenticToolPairReconcilerPatchesMissing(t *testing.T) {
t.Parallel()
mw := newAgenticToolPairReconcilerMiddleware(nil, "test")
state := &adk.TypedChatModelAgentState[*schema.AgenticMessage]{
Messages: []*schema.AgenticMessage{
agenticAssistantToolCall("c1", "search", `{"q":"x"}`),
agenticAssistantToolCall("c2", "execute", `{"cmd":"ls"}`),
// c1 result present, c2 missing
agenticToolResult("c1", "search", "found it"),
},
}
_, out, err := mw.BeforeModelRewriteState(context.Background(), state, nil)
if err != nil {
t.Fatal(err)
}
// Expected: assistant(c1) -> result(c1) -> assistant(c2) -> patched_result(c2)
if len(out.Messages) != 4 {
t.Fatalf("messages = %d, want 4", len(out.Messages))
}
// c1 assistant
if calls := agenticFunctionToolCalls(out.Messages[0]); len(calls) != 1 || calls[0].CallID != "c1" {
t.Fatal("msg[0] should be assistant(c1)")
}
// c1 result
if ids := agenticToolResultCallIDs(out.Messages[1]); len(ids) != 1 || ids[0] != "c1" {
t.Fatal("msg[1] should be result(c1)")
}
// c2 assistant
if calls := agenticFunctionToolCalls(out.Messages[2]); len(calls) != 1 || calls[0].CallID != "c2" {
t.Fatal("msg[2] should be assistant(c2)")
}
// c2 patched result
if ids := agenticToolResultCallIDs(out.Messages[3]); len(ids) != 1 || ids[0] != "c2" {
t.Fatal("msg[3] should be patched result(c2)")
}
resultText := out.Messages[3].ContentBlocks[0].FunctionToolResult.Content[0].Text.Text
if resultText != patchedMissingToolResult {
t.Fatalf("patched text = %q", resultText)
}
}
func TestAgenticToolPairReconcilerDropsOrphan(t *testing.T) {
t.Parallel()
mw := newAgenticToolPairReconcilerMiddleware(nil, "test")
state := &adk.TypedChatModelAgentState[*schema.AgenticMessage]{
Messages: []*schema.AgenticMessage{
// Orphan tool result with no preceding assistant
agenticToolResult("orphan", "deleted_tool", "stale data"),
{Role: schema.AgenticRoleTypeUser, ContentBlocks: []*schema.ContentBlock{
schema.NewContentBlock(&schema.UserInputText{Text: "hello"}),
}},
},
}
_, out, err := mw.BeforeModelRewriteState(context.Background(), state, nil)
if err != nil {
t.Fatal(err)
}
if len(out.Messages) != 1 {
t.Fatalf("messages = %d, want 1 (orphan dropped)", len(out.Messages))
}
if out.Messages[0].ContentBlocks[0].UserInputText == nil {
t.Fatal("remaining message should be the user text")
}
}
func TestAgenticToolPairReconcilerNoopWhenPaired(t *testing.T) {
t.Parallel()
mw := newAgenticToolPairReconcilerMiddleware(nil, "test")
state := &adk.TypedChatModelAgentState[*schema.AgenticMessage]{
Messages: []*schema.AgenticMessage{
agenticAssistantToolCall("c1", "search", `{}`),
agenticToolResult("c1", "search", "ok"),
},
}
_, out, err := mw.BeforeModelRewriteState(context.Background(), state, nil)
if err != nil {
t.Fatal(err)
}
// Should return original state unchanged
if &out.Messages[0] == &state.Messages[0] {
// pointer equality on slice — state not cloned
}
if len(out.Messages) != 2 {
t.Fatalf("messages = %d, want 2", len(out.Messages))
}
}
func TestAgenticToolPairReconcilerFixesEmptyCallID(t *testing.T) {
t.Parallel()
mw := newAgenticToolPairReconcilerMiddleware(nil, "test")
state := &adk.TypedChatModelAgentState[*schema.AgenticMessage]{
Messages: []*schema.AgenticMessage{
{
Role: schema.AgenticRoleTypeAssistant,
ContentBlocks: []*schema.ContentBlock{schema.NewContentBlock(&schema.FunctionToolCall{
CallID: "", Name: "search", Arguments: `{}`,
})},
},
},
}
_, out, err := mw.BeforeModelRewriteState(context.Background(), state, nil)
if err != nil {
t.Fatal(err)
}
calls := agenticFunctionToolCalls(out.Messages[0])
if len(calls) != 1 || calls[0].CallID == "" {
t.Fatalf("empty call ID should be patched, got %q", calls[0].CallID)
}
}
func TestAgenticOrphanToolPrunerRemovesOrphan(t *testing.T) {
t.Parallel()
mw := newAgenticOrphanToolPrunerMiddleware(nil, "test")
state := &adk.TypedChatModelAgentState[*schema.AgenticMessage]{
Messages: []*schema.AgenticMessage{
agenticAssistantToolCall("c1", "search", `{}`),
agenticToolResult("c1", "search", "ok"),
// Orphan: no assistant has call_id "c_orphan"
agenticToolResult("c_orphan", "deleted", "stale"),
},
}
_, out, err := mw.BeforeModelRewriteState(context.Background(), state, nil)
if err != nil {
t.Fatal(err)
}
if len(out.Messages) != 2 {
t.Fatalf("messages = %d, want 2 (orphan pruned)", len(out.Messages))
}
}
func TestAgenticOrphanToolPrunerNoopWhenClean(t *testing.T) {
t.Parallel()
mw := newAgenticOrphanToolPrunerMiddleware(nil, "test")
state := &adk.TypedChatModelAgentState[*schema.AgenticMessage]{
Messages: []*schema.AgenticMessage{
agenticAssistantToolCall("c1", "search", `{}`),
agenticToolResult("c1", "search", "ok"),
},
}
_, out, err := mw.BeforeModelRewriteState(context.Background(), state, nil)
if err != nil {
t.Fatal(err)
}
if len(out.Messages) != 2 {
t.Fatalf("messages = %d, want 2", len(out.Messages))
}
}
+71 -10
View File
@@ -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,
+8 -2
View File
@@ -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),
@@ -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))
}
}
+44 -6
View File
@@ -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)
}
}
@@ -228,6 +228,7 @@ func (d *einoRunEventDrain) markPendingWithMonitor(tc toolCallPendingInfo) {
d.cfg.MCPExecutionBinder,
tc.ToolCallID,
tc.ToolName,
tc.Arguments,
)
}
@@ -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
@@ -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)
@@ -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{}
+2 -2
View File
@@ -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)
}
+74 -1
View File
@@ -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 原生语义合并工具结果流:
// - 同一 CallIDEventSender 一 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()
+41 -3
View File
@@ -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)
}
+33 -4
View File
@@ -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)
}
@@ -907,10 +908,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 +1022,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
+245
View File
@@ -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
}
+70
View File
@@ -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)
}
}
+24
View File
@@ -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
}
+3 -3
View File
@@ -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, "/")
+2 -2
View File
@@ -96,8 +96,8 @@ func ApplyToEinoChatModelConfig(cfg *einoopenai.ChatModelConfig, oa *config.Open
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)
+44 -12
View File
@@ -10,6 +10,7 @@ import (
"time"
"cyberstrike-ai/internal/config"
"cyberstrike-ai/internal/llm"
"cyberstrike-ai/internal/openai"
einoopenai "github.com/cloudwego/eino-ext/components/model/openai"
@@ -58,8 +59,50 @@ func (c *Client) Analyze(ctx context.Context, img ImagePayload, question string)
ResponseHeaderTimeout: timeout + 10*time.Second,
},
}
httpClient = openai.NewEinoHTTPClient(&oa, httpClient)
b64 := base64.StdEncoding.EncodeToString(img.Bytes)
detail := schema.ImageURLDetailLow
switch c.cfg.DetailEffective() {
case "high":
detail = schema.ImageURLDetailHigh
case "auto":
detail = schema.ImageURLDetailAuto
}
prompt := buildVisionPrompt(question)
if llm.IsClaudeProvider(oa.Provider) {
nativeModel, err := llm.NewClaudeAgenticModel(
ctx,
oa,
httpClient,
oa.MaxCompletionTokensEffective(),
nil,
)
if err != nil {
return "", fmt.Errorf("vision native Claude model: %w", err)
}
resp, err := nativeModel.Generate(ctx, []*schema.AgenticMessage{{
Role: schema.AgenticRoleTypeUser,
ContentBlocks: []*schema.ContentBlock{
schema.NewContentBlock(&schema.UserInputText{Text: prompt}),
schema.NewContentBlock(&schema.UserInputImage{
Base64Data: b64,
MIMEType: mime,
Detail: detail,
}),
},
}})
if err != nil {
return "", fmt.Errorf("vision native Claude generate: %w", err)
}
content, _ := llm.AgenticText(resp)
if strings.TrimSpace(content) == "" {
return "", fmt.Errorf("vision model returned empty content")
}
return strings.TrimSpace(content), nil
}
httpClient = openai.NewEinoHTTPClient(&oa, httpClient)
maxCompletionTokens := oa.MaxCompletionTokensEffective()
modelCfg := &einoopenai.ChatModelConfig{
APIKey: oa.APIKey,
@@ -72,17 +115,6 @@ func (c *Client) Analyze(ctx context.Context, img ImagePayload, question string)
if err != nil {
return "", fmt.Errorf("vision chat model: %w", err)
}
b64 := base64.StdEncoding.EncodeToString(img.Bytes)
detail := schema.ImageURLDetailLow
switch c.cfg.DetailEffective() {
case "high":
detail = schema.ImageURLDetailHigh
case "auto":
detail = schema.ImageURLDetailAuto
}
prompt := buildVisionPrompt(question)
userMsg := &schema.Message{
Role: schema.User,
UserInputMultiContent: []schema.MessageInputPart{
+11 -1
View File
@@ -16,7 +16,7 @@ args:
# ==================================================
# Quake API基础URL
base_url = "https://quake.360.cn/api/v3/search/quake_service"
base_url = "https://quake.360.net/api/v3/search/quake_service"
# 解析参数(从JSON字符串或命令行参数)
def parse_args():
@@ -178,6 +178,16 @@ args:
sys.exit(1)
results = result_data.get("data", [])
if isinstance(results, dict):
nested = None
for key in ("data", "items", "matches", "results", "list", "records"):
value = results.get(key)
if isinstance(value, list):
nested = value
break
results = nested if nested is not None else []
if not isinstance(results, list):
results = []
meta = result_data.get("meta", {})
pagination = meta.get("pagination", {}) if isinstance(meta, dict) else {}
-13
View File
@@ -25,7 +25,6 @@ parameters:
required: true
flag: "-a"
format: "flag"
# -p, --ports;范围请用 range (-r),勿把 1-1000 填进 ports
- name: "ports"
type: "string"
@@ -36,7 +35,6 @@ parameters:
required: false
flag: "-p"
format: "flag"
# -r, --range(与 ports 列表二选一或按官方说明组合)
- name: "range"
type: "string"
@@ -47,7 +45,6 @@ parameters:
required: false
flag: "-r"
format: "flag"
# -u, --ulimit
- name: "ulimit"
type: "int"
@@ -56,7 +53,6 @@ parameters:
flag: "-u"
format: "flag"
default: 5000
# --scriptsnone | default | customCLI 默认 default
- name: "scripts"
type: "string"
@@ -73,7 +69,6 @@ parameters:
- "none"
- "default"
- "custom"
# -g, --greppable:仅端口列表,无 Nmap 输出
- name: "greppable"
type: "bool"
@@ -82,7 +77,6 @@ parameters:
flag: "-g"
format: "flag"
default: false
# -b, --batch-size [default: 4500]
- name: "batch_size"
type: "int"
@@ -90,7 +84,6 @@ parameters:
required: false
flag: "-b"
format: "flag"
# -t, --timeout ms [default: 1500]
- name: "timeout_ms"
type: "int"
@@ -98,7 +91,6 @@ parameters:
required: false
flag: "-t"
format: "flag"
# --scan-order serial | random [default: serial]
- name: "scan_order"
type: "string"
@@ -109,7 +101,6 @@ parameters:
options:
- "serial"
- "random"
# --toptop 1000 端口
- name: "top_ports"
type: "bool"
@@ -118,7 +109,6 @@ parameters:
flag: "--top"
format: "flag"
default: false
# -e, --exclude-ports
- name: "exclude_ports"
type: "string"
@@ -126,7 +116,6 @@ parameters:
required: false
flag: "-e"
format: "flag"
# -x, --exclude-addresses
- name: "exclude_addresses"
type: "string"
@@ -134,7 +123,6 @@ parameters:
required: false
flag: "-x"
format: "flag"
# --tries [default: 1]
- name: "tries"
type: "int"
@@ -142,7 +130,6 @@ parameters:
required: false
flag: "--tries"
format: "flag"
- name: "additional_args"
type: "string"
description: |
File diff suppressed because one or more lines are too long
+12 -2
View File
@@ -17,7 +17,7 @@ args:
# ==================================================
# ZoomEye API基础URL
base_url = "https://api.zoomeye.org/v2/search"
base_url = "https://api.zoomeye.ai/v2/search"
# 解析参数(从JSON字符串或命令行参数)
def parse_args():
@@ -78,7 +78,7 @@ args:
"status": "error",
"message": "缺少ZoomEye配置: api_keyZoomEye API密钥)",
"required_config": ["api_key"],
"note": "请在YAML文件的ZOOMEYE_API_KEY配置项中填写您的API密钥,或在环境变量ZOOMEYE_API_KEY中设置。API密钥可在ZoomEye个人中心获取: https://www.zoomeye.org/user"
"note": "请在YAML文件的ZOOMEYE_API_KEY配置项中填写您的API密钥,或在环境变量ZOOMEYE_API_KEY中设置。API密钥可在ZoomEye个人中心获取: https://www.zoomeye.ai/user"
}
print(json.dumps(error_result, ensure_ascii=False, indent=2))
sys.exit(1)
@@ -174,6 +174,16 @@ args:
# 格式化输出结果
# ZoomEye API 返回的数据在 data 字段中
data_list = result_data.get('data', [])
if isinstance(data_list, dict):
nested = None
for key in ('data', 'items', 'matches', 'results', 'list', 'records'):
value = data_list.get(key)
if isinstance(value, list):
nested = value
break
data_list = nested if nested is not None else []
if not isinstance(data_list, list):
data_list = []
output = {
"status": "success",
"query": result_data.get('query', query),
+148 -7
View File
@@ -4448,11 +4448,16 @@ html[data-theme="dark"] .sidebar-content:hover::-webkit-scrollbar-thumb:hover {
}
.process-details-content {
position: relative;
width: 100%;
min-width: 0;
max-width: 100%;
}
.process-details-return-latest-host {
position: relative;
}
.process-details-content .progress-timeline {
max-height: 0;
overflow: hidden;
@@ -4473,6 +4478,71 @@ html[data-theme="dark"] .sidebar-content:hover::-webkit-scrollbar-thumb:hover {
scrollbar-gutter: stable;
}
.process-details-return-latest {
position: absolute;
right: 18px;
bottom: 16px;
z-index: 7;
width: 34px;
height: 34px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
border: 1px solid rgba(15, 23, 42, 0.14);
border-radius: 50%;
background: rgba(255, 255, 255, 0.96);
color: #5f6875;
box-shadow: 0 8px 22px rgba(15, 23, 42, 0.14), 0 1px 3px rgba(15, 23, 42, 0.08);
backdrop-filter: blur(10px);
cursor: pointer;
transition: color 0.16s ease, background 0.16s ease, border-color 0.16s ease, box-shadow 0.16s ease, transform 0.16s ease;
}
.process-details-return-latest[hidden] {
display: none !important;
}
.process-details-return-latest:hover {
border-color: rgba(37, 99, 235, 0.3);
background: #ffffff;
color: var(--accent-color, #0b6cff);
box-shadow: 0 10px 26px rgba(15, 23, 42, 0.18), 0 2px 5px rgba(15, 23, 42, 0.1);
transform: translateY(-1px);
}
.process-details-return-latest:focus-visible {
outline: 2px solid rgba(37, 99, 235, 0.42);
outline-offset: 3px;
}
.process-details-return-latest svg {
width: 18px;
height: 18px;
}
.process-details-return-latest.has-pending-new::after,
.process-details-return-latest.is-streaming::after {
content: "";
position: absolute;
top: 5px;
right: 5px;
width: 7px;
height: 7px;
border: 2px solid rgba(255, 255, 255, 0.96);
border-radius: 50%;
background: var(--accent-color, #0b6cff);
}
.process-details-return-latest.is-streaming svg {
animation: process-details-return-latest-nudge 1.1s ease-in-out infinite;
}
@keyframes process-details-return-latest-nudge {
0%, 65%, 100% { transform: translateY(0); }
35% { transform: translateY(2px); }
}
.process-details-auto-sentinel {
display: block;
width: 100%;
@@ -36506,6 +36576,24 @@ html[data-theme="dark"] .chat-return-latest:hover {
color: #ffffff;
}
html[data-theme="dark"] .process-details-return-latest {
border-color: rgba(148, 163, 184, 0.28);
background: rgba(15, 23, 42, 0.96);
color: #cbd5e1;
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.38), 0 1px 3px rgba(0, 0, 0, 0.28);
}
html[data-theme="dark"] .process-details-return-latest:hover {
border-color: rgba(96, 165, 250, 0.42);
background: #172033;
color: #93c5fd;
}
html[data-theme="dark"] .process-details-return-latest.has-pending-new::after,
html[data-theme="dark"] .process-details-return-latest.is-streaming::after {
border-color: rgba(15, 23, 42, 0.96);
}
html[data-theme="dark"] .process-details-container {
border-top-color: #263244;
}
@@ -45832,6 +45920,16 @@ html[data-theme="dark"] .conversation-sidebar .recent-conversations-section {
font-family: inherit;
}
.chat-system-channel-option-label {
font-family: inherit;
}
.chat-system-model-section-divider {
height: 1px;
margin: 5px 10px;
background: var(--border-color, #e5e7eb);
}
.chat-system-model-current {
flex: 0 0 auto;
padding: 3px 7px;
@@ -46713,6 +46811,7 @@ html[data-theme="dark"] .chat-composer-surface > .chat-session-settings-popover
line-height: 1.5;
white-space: pre-wrap;
overflow-wrap: anywhere;
scrollbar-gutter: stable;
}
.hitl-approval-countdown {
@@ -46838,7 +46937,11 @@ html[data-theme="dark"] .chat-composer-surface > .chat-session-settings-popover
display: block;
width: 100%;
min-height: 158px;
padding: 18px 20px 16px;
max-height: min(62vh, 560px);
max-height: min(62dvh, 560px);
padding: 18px 20px 74px;
overflow: hidden;
overscroll-behavior: contain;
border: 1px solid rgba(15, 23, 42, 0.14);
border-radius: 24px;
background: rgba(255, 255, 255, 0.985);
@@ -46847,6 +46950,16 @@ html[data-theme="dark"] .chat-composer-surface > .chat-session-settings-popover
outline: none;
}
.chat-hitl-approval-scroll-region {
min-height: 0;
max-height: max(76px, calc(min(62vh, 560px) - 94px));
max-height: max(76px, calc(min(62dvh, 560px) - 94px));
overflow-y: auto;
overscroll-behavior: contain;
scroll-padding-bottom: 12px;
scrollbar-gutter: stable;
}
.chat-hitl-approval-dock .hitl-codex-tool-row {
font-size: 0.92rem;
}
@@ -46860,8 +46973,12 @@ html[data-theme="dark"] .chat-composer-surface > .chat-session-settings-popover
}
.chat-hitl-approval-dock .hitl-approval-heading h3 {
display: -webkit-box;
max-width: 900px;
overflow: hidden;
font-size: 1rem;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
}
.chat-hitl-approval-dock .hitl-inline-body {
@@ -46870,13 +46987,28 @@ html[data-theme="dark"] .chat-composer-surface > .chat-session-settings-popover
padding: 0;
}
.chat-hitl-approval-dock .hitl-edit-args {
max-height: min(28vh, 220px);
max-height: min(28dvh, 220px);
overflow: auto;
resize: vertical;
}
.chat-hitl-approval-dock .hitl-inline-actions {
position: absolute;
right: 20px;
bottom: 16px;
left: 20px;
z-index: 2;
min-height: 48px;
justify-content: flex-end;
gap: 8px;
margin-top: 12px;
padding: 0;
margin-top: 0;
padding: 10px 0 0;
border: 0;
background: transparent;
border-top: 1px solid color-mix(in srgb, var(--border-color) 70%, transparent);
background: rgba(255, 255, 255, 0.985);
box-shadow: none;
}
.chat-hitl-approval-dock .hitl-inline-status {
@@ -46912,8 +47044,9 @@ html[data-theme="dark"] .chat-hitl-approval-dock {
}
html[data-theme="dark"] .chat-hitl-approval-dock .hitl-inline-actions {
border-color: transparent !important;
background: transparent !important;
border-color: color-mix(in srgb, var(--border-color) 70%, transparent) !important;
background: color-mix(in srgb, var(--bg-primary) 97%, transparent) !important;
box-shadow: none;
}
html[data-theme="dark"] .hitl-approval-primary code,
@@ -47038,10 +47171,15 @@ html[data-theme="dark"] .hitl-inline-approval.hitl-inline-approval--merged {
}
.chat-hitl-approval-dock {
padding: 16px;
padding: 16px 16px 116px;
border-radius: 18px;
}
.chat-hitl-approval-scroll-region {
max-height: max(76px, calc(min(62vh, 560px) - 134px));
max-height: max(76px, calc(min(62dvh, 560px) - 134px));
}
.chat-hitl-approval-dock .hitl-approval-heading,
.chat-hitl-approval-dock .hitl-approval-primary,
.chat-hitl-approval-dock .hitl-approval-countdown,
@@ -47051,6 +47189,9 @@ html[data-theme="dark"] .hitl-inline-approval.hitl-inline-approval--merged {
}
.chat-hitl-approval-dock .hitl-inline-actions {
right: 16px;
bottom: 16px;
left: 16px;
flex-wrap: wrap;
}
+6 -2
View File
@@ -562,6 +562,7 @@
"conversationPreviewViewed": "Viewed",
"conversationPreviewConversation": "Conversation",
"returnToLatest": "Jump to latest message",
"returnToLatestProcessDetail": "Jump to latest iteration",
"taskProgressStep": "Step {{current}} of {{total}}",
"taskProgressOpen": "View task progress",
"taskProgressDetails": "Task progress details",
@@ -772,9 +773,9 @@
"reasoningPanelHint": "Only Eino single- and multi-agent requests use these; merged with defaults in Settings.",
"sessionSettingsTitle": "Session settings",
"sessionSettingsAria": "Open session settings",
"sessionSettingsHint": "AI channel, reasoning, and HITL settings only affect future messages.",
"sessionSettingsHint": "Human-in-the-loop settings only affect future messages.",
"sessionShortcutAuditAgent": "Agent review",
"modelSettingsAria": "Choose model and reasoning effort",
"modelSettingsAria": "Choose AI channel, model, and reasoning settings",
"systemModelPickerTitle": "Choose system model",
"systemModelField": "Model",
"systemModelLoading": "Fetching model list…",
@@ -782,6 +783,7 @@
"systemModelCurrent": "Current",
"systemModelSaving": "Saving…",
"systemModelSaved": "Saved automatically",
"reasoningSessionUpdated": "Session reasoning updated",
"systemModelLoadFailed": "Failed to fetch models",
"systemModelSaveFailed": "Failed to save model",
"systemModelApplyFailed": "Failed to apply model",
@@ -876,6 +878,8 @@
"approvalUrgencyWithinOne": "Earliest approval expires within 1 minute",
"requestGeneric": "Allow CyberStrikeAI to call {{tool}}?",
"requestVisitUrl": "Allow CyberStrikeAI to visit {{url}}?",
"requestVisitLongUrl": "Allow CyberStrikeAI to visit this address?",
"requestModifyLongPath": "Allow CyberStrikeAI to modify this file?",
"requestBrowser": "Allow CyberStrikeAI to use the browser?",
"requestCommand": "Allow CyberStrikeAI to run this command?",
"requestFile": "Allow CyberStrikeAI to modify {{path}}?",
+6 -2
View File
@@ -550,6 +550,7 @@
"conversationPreviewViewed": "已查看",
"conversationPreviewConversation": "对话",
"returnToLatest": "回到最新消息",
"returnToLatestProcessDetail": "回到最新迭代",
"taskProgressStep": "第 {{current}} / {{total}} 步",
"taskProgressOpen": "查看任务进度",
"taskProgressDetails": "任务进度详情",
@@ -760,9 +761,9 @@
"reasoningPanelHint": "仅 Eino 单代理与多代理请求会带上这些参数;与系统设置中的默认值合并。",
"sessionSettingsTitle": "会话设置",
"sessionSettingsAria": "打开会话设置",
"sessionSettingsHint": "AI 通道、推理设置与人机协同只影响后续消息。",
"sessionSettingsHint": "人机协同设置只影响后续消息。",
"sessionShortcutAuditAgent": "Agent 审查",
"modelSettingsAria": "选择模型与推理强度",
"modelSettingsAria": "选择 AI 通道、模型与推理设置",
"systemModelPickerTitle": "选择系统模型",
"systemModelField": "模型",
"systemModelLoading": "正在获取模型列表…",
@@ -770,6 +771,7 @@
"systemModelCurrent": "当前",
"systemModelSaving": "正在保存…",
"systemModelSaved": "已自动保存",
"reasoningSessionUpdated": "会话推理设置已更新",
"systemModelLoadFailed": "获取模型失败",
"systemModelSaveFailed": "保存模型失败",
"systemModelApplyFailed": "应用模型失败",
@@ -864,6 +866,8 @@
"approvalUrgencyWithinOne": "最早审批将在 1 分钟内到期",
"requestGeneric": "允许 CyberStrikeAI 调用 {{tool}}",
"requestVisitUrl": "允许 CyberStrikeAI 访问 {{url}}",
"requestVisitLongUrl": "允许 CyberStrikeAI 访问此地址?",
"requestModifyLongPath": "允许 CyberStrikeAI 修改此文件?",
"requestBrowser": "允许 CyberStrikeAI 使用浏览器?",
"requestCommand": "允许 CyberStrikeAI 执行这条命令?",
"requestFile": "允许 CyberStrikeAI 修改 {{path}}",
+90 -4
View File
@@ -6,6 +6,7 @@ const vm = require('node:vm');
const scroll = fs.readFileSync('web/static/js/chat-scroll.js', 'utf8');
const monitor = fs.readFileSync('web/static/js/monitor.js', 'utf8');
const chat = fs.readFileSync('web/static/js/chat.js', 'utf8');
const projects = fs.readFileSync('web/static/js/projects.js', 'utf8');
const router = fs.readFileSync('web/static/js/router.js', 'utf8');
const auth = fs.readFileSync('web/static/js/auth.js', 'utf8');
const webshell = fs.readFileSync('web/static/js/webshell.js', 'utf8');
@@ -226,10 +227,21 @@ test('刷新后迭代思考区独立跟随最新内容且允许用户上滑解
const startSource = functionSource(monitor, 'startProcessDetailsLatestFollow', 'loadProcessDetailsPaginated');
const loadSource = functionSource(monitor, 'loadProcessDetailsPaginated', 'shouldInitiallyOpenProcessDetailsAtLatest');
const attachSource = functionSource(monitor, 'attachRunningTaskEventStream', 'parseToolCallArgsFromData');
const returnLatestSource = functionSource(monitor, 'ensureProcessDetailsReturnLatestControl', 'scrollProcessDetailsToLatest');
assert.match(monitor, /const processDetailsReturnLatestControls = new WeakMap\(\)/);
assert.match(returnLatestSource, /className = 'process-details-return-latest'/);
assert.match(monitor, /function getProcessDetailsLatestFollowStateForTimeline\(timeline\)/);
assert.match(monitor, /const followingLatest = !!\(followState && !followState\.detached\)/);
assert.match(monitor, /const shouldShow = !followingLatest && expanded && scrollable && awayFromLatest/);
assert.match(returnLatestSource, /timeline\.scrollTo\(\{ top: targetTop, behavior: 'smooth' \}\)/);
assert.match(returnLatestSource, /timeline\.addEventListener\('scroll', onScroll/);
assert.match(returnLatestSource, /window\.ensureProcessDetailsReturnLatestControl = ensureProcessDetailsReturnLatestControl/);
assert.match(startSource, /new MutationObserver\(scheduleFollowLatest\)/);
assert.match(startSource, /characterData: true/);
assert.match(startSource, /new ResizeObserver\(scheduleFollowLatest\)/);
assert.match(startSource, /ensureProcessDetailsReturnLatestControl\(timeline\)/);
assert.match(startSource, /markProcessDetailsReturnLatestPending\(timeline\)/);
assert.match(startSource, /scrollProcessDetailsToLatest\(String\(assistantMessageId \|\| ''\), false\)/);
assert.match(startSource, /event\.deltaY < -1/);
assert.match(startSource, /state\.userScrollIntentUntil = Date\.now\(\) \+ 1200/);
@@ -245,6 +257,8 @@ test('刷新后迭代思考区独立跟随最新内容且允许用户上滑解
assert.match(loadSource, /startProcessDetailsLatestFollow\(assistantMessageId/);
assert.match(attachSource, /startProcessDetailsLatestFollow\(asEl\.id, \{ persistent: true \}\)/);
assert.match(attachSource, /stopProcessDetailsLatestFollow\(asEl\.id\)/);
assert.match(chat, /window\.ensureProcessDetailsReturnLatestControl\(timeline\)/);
assert.doesNotMatch(returnLatestSource, /chat-return-latest/);
});
test('刷新后的工具调用恢复与实时一致的成功失败徽标', () => {
@@ -271,6 +285,8 @@ test('首次实时输出与刷新恢复都保留独立迭代滚动并跟随最
assert.match(css, /\.progress-container\.is-streaming \.progress-timeline\.expanded,[\s\S]{0,360}max-height: min\(64vh, 720px\);[\s\S]{0,180}overflow-y: auto;/);
assert.match(css, /\.message\.progress-message \.progress-timeline\.expanded \{[\s\S]{0,260}max-height: min\(64vh, 720px\);[\s\S]{0,160}overflow-y: auto;/);
assert.match(css, /\.process-details-return-latest \{[\s\S]{0,260}position: absolute;[\s\S]{0,260}border-radius: 50%;/);
assert.match(css, /\.process-details-return-latest\.has-pending-new::after,/);
assert.doesNotMatch(css, /流式执行中[\s\S]{0,320}overflow-y: visible;/);
assert.match(addSource, /startLiveProgressLatestFollow\(id\)/);
assert.match(liveSource, /stateKey: liveProgressLatestFollowKey\(id\)/);
@@ -325,7 +341,7 @@ test('消息气泡内部流式增高时仅在跟随模式继续粘底', () => {
test('页面在任务补流脚本之前加载智能滚动控制器', () => {
const scrollIndex = html.indexOf('/static/js/chat-scroll.js?v=20260815-1');
const monitorIndex = html.indexOf('/static/js/monitor.js?v=20260815-1');
const monitorIndex = html.indexOf('/static/js/monitor.js?v=20260819-3');
assert.notEqual(scrollIndex, -1);
assert.notEqual(monitorIndex, -1);
@@ -368,6 +384,76 @@ test('任务计划进度事件在活跃任务列表变化和新任务开始时
assert.match(renderSource, /detail: \{ tasks: normalizedTasks \}/);
});
test('活跃任务按启动时间稳定排列且无变化刷新不重建停止按钮', () => {
const sortSource = functionSource(monitor, 'stableActiveTasksForDisplay', 'activeTasksRenderSignature');
const sortTasks = vm.runInNewContext(`(${sortSource.trim()})`);
const tasks = [
{ conversationId: 'conversation-z', startedAt: '2026-08-19T10:00:00Z' },
{ conversationId: 'conversation-late', startedAt: '2026-08-19T10:01:00Z' },
{ conversationId: 'conversation-a', startedAt: '2026-08-19T10:00:00Z' }
];
assert.deepEqual(
Array.from(sortTasks(tasks), task => task.conversationId),
['conversation-a', 'conversation-z', 'conversation-late']
);
const renderSource = functionSource(monitor, 'renderActiveTasks', 'reconcileHitlApprovalStateWithActiveTasks');
assert.match(renderSource, /nextVisualSignature === activeTasksVisualSignature/);
assert.match(renderSource, /bar\.querySelectorAll\('\.active-task-item'\)\.length === normalizedTasks\.length/);
assert.match(renderSource, /const previousScrollLeft = bar\.scrollLeft/);
assert.match(renderSource, /bar\.scrollLeft = previousScrollLeft/);
});
test('新对话初始化期间切换会话后旧流事件不能把页面拉回', () => {
const guardSource = functionSource(chat, 'shouldIgnoreLiveChatStreamEvent', 'clearLiveChatStreamIfOwned');
const shouldIgnore = vm.runInNewContext(`(${guardSource.trim()})`);
const activeStream = { active: true, detached: false, navigationSeq: 7 };
assert.equal(shouldIgnore(activeStream, activeStream, 7), false);
activeStream.detached = true;
assert.equal(shouldIgnore(activeStream, activeStream, 7), true);
activeStream.detached = false;
activeStream.active = false;
assert.equal(shouldIgnore(activeStream, activeStream, 7), true);
activeStream.active = true;
assert.equal(shouldIgnore(activeStream, activeStream, 8), true);
assert.equal(shouldIgnore({ active: true, detached: false, navigationSeq: 7 }, activeStream, 7), true);
const sendSource = functionSource(chat, 'sendMessage', 'renderChatFileChips');
const guardIndex = sendSource.indexOf('shouldIgnoreLiveChatStreamEvent(liveStreamState)');
const handlerIndex = sendSource.indexOf('handleStreamEvent(eventData');
assert.notEqual(guardIndex, -1);
assert.notEqual(handlerIndex, -1);
assert.ok(guardIndex < handlerIndex);
assert.match(sendSource, /const requestNavigationSeq = chatConversationNavigationSeq;[\s\S]*?await loadActiveTasks\(\)/);
assert.match(sendSource, /if \(requestNavigationSeq !== chatConversationNavigationSeq\) \{[\s\S]{0,80}return;/);
assert.match(sendSource, /navigationSeq: requestNavigationSeq/);
assert.match(sendSource, /if \(!streamConversationId\) \{[\s\S]{0,180}liveStreamState\.conversationId = eventConvId/);
assert.match(sendSource, /if \(eventConvId\) updateProgressConversation\(progressId, eventConvId\);[\s\S]{0,80}return;/);
const loadSource = functionSource(chat, 'loadConversation', 'attachDeleteTurnButton');
const newConversationSource = functionSource(chat, 'startNewConversation', 'loadConversations');
assert.match(loadSource, /markChatConversationNavigation\(conversationId\)/);
assert.match(loadSource, /window\.cancelScheduledChatConversationFromHash\(\)/);
assert.match(newConversationSource, /markChatConversationNavigation\('', true\)/);
assert.match(newConversationSource, /clearChatConversationHash\(\)/);
assert.match(router, /function cancelScheduledChatConversationFromHash\(\)[\s\S]{0,160}chatConversationFromHashSeq\+\+/);
assert.match(chat, /function abandonChatConversationForPageNavigation\(\)[\s\S]{0,260}markChatConversationNavigation\('', true\)/);
assert.match(chat, /abandonChatConversationForPageNavigation\(\)[\s\S]{0,420}detachLiveChatStreamForNavigation\('', true\)/);
assert.match(router, /currentPage === 'chat'[\s\S]{0,140}window\.abandonChatConversationForPageNavigation\(\)/);
assert.match(chat, /const targetConversationId = String\(item\.dataset\.conversationId \|\| ''\)\.trim\(\);[\s\S]{0,80}loadConversation\(targetConversationId\)/);
assert.match(projects, /const targetConversationId = String\(event\.currentTarget && event\.currentTarget\.dataset\.conversationId \|\| ''\)\.trim\(\)/);
assert.match(projects, /window\.loadConversation\(targetConversationId\)/);
assert.match(chat, /let loadConversationPendingId = ''/);
assert.match(chat, /window\.isChatConversationLoadPending = isChatConversationLoadPending/);
const immediateSelectionIndex = loadSource.indexOf('currentConversationId = conversationId;');
const conversationFetchIndex = loadSource.indexOf('await apiFetch(`/api/conversations/${conversationId}?include_process_details=0`');
assert.notEqual(immediateSelectionIndex, -1);
assert.notEqual(conversationFetchIndex, -1);
assert.ok(immediateSelectionIndex < conversationFetchIndex);
assert.match(monitor, /String\(window\.currentConversationId \|\| ''\) !== conversationId[\s\S]{0,300}window\.isChatConversationLoadPending\(conversationId\)/);
});
test('刷新指定对话时立即恢复且加载完成前不闪出无项目状态', () => {
const scheduleSource = functionSource(router, 'scheduleChatConversationFromHash', 'navigateToConversation');
const restoreStateSource = functionSource(router, 'setChatConversationRestorePending', 'finishChatConversationRestore');
@@ -382,8 +468,8 @@ test('刷新指定对话时立即恢复且加载完成前不闪出无项目状
assert.match(loadSource, /finally \{[\s\S]*?finishChatConversationRestore\(conversationId\)/);
assert.match(css, /\.chat-container\.is-conversation-restoring #chat-messages/);
assert.match(css, /\.chat-container\.is-conversation-restoring #chat-input-container/);
assert.match(html, /router\.js\?v=20260813-2/);
assert.match(html, /chat\.js\?v=20260813-4/);
assert.match(html, /router\.js\?v=20260819-3/);
assert.match(html, /chat\.js\?v=20260819-5/);
});
test('刷新运行中回复会复用已持久化 planning 并继续追加未来增量', () => {
@@ -447,5 +533,5 @@ test('暗色模式对话三点悬浮不会触发浅色父行背景', () => {
const css = fs.readFileSync('web/static/css/style.css', 'utf8');
assert.match(css, /html\[data-theme="dark"\] \.project-conversation-row:hover \.project-conversation-item/);
assert.match(css, /html\[data-theme="dark"\] \.project-folder-action:hover,[\s\S]*?background: rgba\(71, 85, 105, 0\.28\);[\s\S]*?box-shadow: none;/);
assert.match(html, /style\.css\?v=20260813-6/);
assert.match(html, /style\.css\?v=20260819-4/);
});
+417 -146
View File
@@ -10,8 +10,46 @@ function syncChatConversationHash(conversationId) {
}
}
window.syncChatConversationHash = syncChatConversationHash;
function clearChatConversationHash() {
if (window.location.hash.split('?')[0] !== '#chat' || window.location.hash === '#chat') return;
window.history.replaceState(null, '', '#chat');
}
window.clearChatConversationHash = clearChatConversationHash;
let loadConversationRequestSeq = 0;
let loadConversationAbortController = null;
let loadConversationPendingId = '';
let chatConversationNavigationSeq = 0;
function isChatConversationLoadPending(conversationId) {
const id = String(conversationId || '').trim();
return !!id && loadConversationPendingId === id;
}
window.isChatConversationLoadPending = isChatConversationLoadPending;
function markChatConversationNavigation(nextConversationId, force = false) {
const nextId = String(nextConversationId || '').trim();
const visibleId = String(currentConversationId || '').trim();
if (force || nextId !== visibleId) {
chatConversationNavigationSeq++;
}
return chatConversationNavigationSeq;
}
/**
* 离开聊天页时立即让尚在初始化的发送请求失去页面所有权
* 后端任务仍会继续执行这里只中止浏览器前台流避免首个 conversation
* 事件在用户已经切到其他页面后再次抢占当前会话
*/
function abandonChatConversationForPageNavigation() {
markChatConversationNavigation('', true);
if (typeof window.cancelScheduledChatConversationFromHash === 'function') {
window.cancelScheduledChatConversationFromHash();
}
cancelPendingConversationLoad();
detachLiveChatStreamForNavigation('', true);
}
window.abandonChatConversationForPageNavigation = abandonChatConversationForPageNavigation;
/**
* 轻量会话 LRU 缓存
@@ -111,6 +149,8 @@ let chatSystemModelCloseTimer = null;
let chatSystemModelOptions = [];
let chatSystemModelCurrent = '';
let chatSystemModelLoadError = '';
const CHAT_SYSTEM_MODEL_CACHE_TTL_MS = 5 * 60 * 1000;
const chatSystemModelCache = new Map();
// 人机协同(HITL)会话级配置
const HITL_STORAGE_PREFIX = 'cyberstrike-chat-hitl';
@@ -128,6 +168,8 @@ const DEFAULT_HITL_TIMEOUT_SECONDS = 300;
const DEFAULT_HITL_SESSION_TOOL_WHITELIST = 'tool_search, skill, task, write_todos, transfer_to_agent, exit, TaskCreate, TaskGet, TaskUpdate, TaskList, upsert_project_fact, get_project_fact';
let hitlApplyFeedbackTimer = null;
let hitlAutoSaveTimer = null;
let hitlConfigSyncConversationId = '';
let hitlConfigSyncPromise = Promise.resolve();
const sessionSettingsSelects = new Map();
let sessionSettingsSelectDocBound = false;
@@ -704,6 +746,18 @@ function refreshHitlConfigByCurrentConversation() {
applyHitlConfigToUI(cfg);
}
async function waitForHitlConfigReady(conversationId) {
const cid = String(conversationId || '').trim();
if (cid && hitlConfigSyncConversationId === cid) {
await hitlConfigSyncPromise;
return;
}
if (!cid && window.csaiHitlDefaultReviewerReady && typeof window.csaiHitlDefaultReviewerReady.then === 'function') {
await window.csaiHitlDefaultReviewerReady.catch(function () {});
if (!currentConversationId) refreshHitlConfigByCurrentConversation();
}
}
function showHitlApplyFeedback(text, isError, partial) {
const el = document.getElementById('hitl-apply-feedback');
if (hitlApplyFeedbackTimer) {
@@ -1061,27 +1115,31 @@ function currentHitlAuditModelLabel() {
return chatHitlAuditModelName || currentSystemModelLabel();
}
function currentSystemReasoningEffort() {
const ch = chatDefaultAIChannel ? chatAIChannels[chatDefaultAIChannel] : null;
const reasoning = ch && ch.reasoning && typeof ch.reasoning === 'object' ? ch.reasoning : {};
const effort = typeof reasoning.effort === 'string' ? reasoning.effort.trim() : '';
return ['', 'low', 'medium', 'high', 'xhigh', 'max'].includes(effort) ? effort : '';
function resolveChatPickerChannelId() {
return selectedChatAIChannelId() || chatDefaultAIChannel;
}
function chatSystemModelConfigState(cfg) {
function chatSystemModelConfigState(cfg, preferredChannelId) {
const source = cfg && typeof cfg === 'object' ? cfg : {};
const sourceAI = source.ai && typeof source.ai === 'object' ? source.ai : {};
const channels = sourceAI.channels && typeof sourceAI.channels === 'object'
? { ...sourceAI.channels }
: {};
let channelId = String(sourceAI.default_channel || '').trim();
const resolveFromChannels = function (value) {
const raw = String(value || '').trim();
if (raw && channels[raw]) return raw;
const normalized = normalizeChatAIChannelId(raw);
return normalized
? Object.keys(channels).find(function (id) {
return normalizeChatAIChannelId(id) === normalized;
}) || ''
: '';
};
let defaultChannelId = resolveFromChannels(sourceAI.default_channel);
let channelId = resolveFromChannels(preferredChannelId) || defaultChannelId;
if (!channels[channelId]) {
const normalized = normalizeChatAIChannelId(channelId);
channelId = Object.keys(channels).find(function (id) {
return normalizeChatAIChannelId(id) === normalized;
}) || '';
channelId = Object.keys(channels)[0] || 'default';
}
if (!channelId) channelId = Object.keys(channels)[0] || 'default';
if (!channels[channelId]) {
const legacy = source.openai && typeof source.openai === 'object' ? source.openai : {};
channels[channelId] = {
@@ -1092,8 +1150,9 @@ function chatSystemModelConfigState(cfg) {
model: legacy.model || ''
};
}
if (!defaultChannelId) defaultChannelId = channelId;
return {
ai: { ...sourceAI, default_channel: channelId, channels: channels },
ai: { ...sourceAI, default_channel: defaultChannelId, channels: channels },
channelId: channelId,
channel: channels[channelId]
};
@@ -1110,7 +1169,9 @@ function chatSystemModelElements() {
list: document.getElementById('chat-system-model-list'),
status: document.getElementById('chat-system-model-status'),
subviewStatus: document.getElementById('chat-system-model-subview-status'),
channelValue: document.getElementById('chat-system-model-channel-value'),
currentValue: document.getElementById('chat-system-model-current-value'),
modeValue: document.getElementById('chat-system-model-mode-value'),
effortValue: document.getElementById('chat-system-model-effort-value')
};
}
@@ -1140,11 +1201,28 @@ function currentChatReasoningEffort() {
return effort ? String(effort.value || '').trim() : '';
}
function currentChatReasoningMode() {
const mode = document.getElementById('chat-reasoning-mode');
const value = mode ? String(mode.value || 'default').trim() : 'default';
return ['default', 'off', 'on', 'auto'].includes(value) ? value : 'default';
}
function currentChatReasoningMenuLabel() {
const modeValue = currentChatReasoningMode();
if (modeValue === 'off') return chatTranslate('chat.reasoningModeOff', '关闭');
const effort = currentChatReasoningEffort();
return effort ? chatReasoningEffortLabel(effort) : reasoningSummaryModeLabel(modeValue);
}
function updateChatSystemModelPickerValues() {
const ui = chatSystemModelElements();
const model = currentSystemModelLabel();
const effort = chatReasoningEffortLabel(currentSystemReasoningEffort());
const channel = currentChatAIChannelLabel();
const model = currentChatModelLabel();
const mode = reasoningSummaryModeLabel(currentChatReasoningMode());
const effort = currentChatReasoningMenuLabel();
if (ui.channelValue) ui.channelValue.textContent = channel;
if (ui.currentValue) ui.currentValue.textContent = model;
if (ui.modeValue) ui.modeValue.textContent = mode;
if (ui.effortValue) ui.effortValue.textContent = effort;
const composerEffort = document.getElementById('chat-model-shortcut-effort');
if (composerEffort) composerEffort.textContent = effort;
@@ -1218,7 +1296,7 @@ function renderChatReasoningEffortOptions() {
const ui = chatSystemModelElements();
if (!ui.list) return;
ui.list.innerHTML = '';
const currentEffort = currentSystemReasoningEffort();
const currentEffort = currentChatReasoningEffort();
['', 'low', 'medium', 'high', 'xhigh', 'max'].forEach(function (effort) {
const option = document.createElement('button');
option.type = 'button';
@@ -1247,61 +1325,68 @@ function renderChatReasoningEffortOptions() {
});
}
async function selectChatReasoningEffort(effort) {
if (chatSystemModelSaving) return;
if (typeof requirePermission === 'function' && !requirePermission('config:write')) return;
const chosen = ['', 'low', 'medium', 'high', 'xhigh', 'max'].includes(String(effort || '').trim())
? String(effort || '').trim()
: '';
function renderChatReasoningModeOptions() {
const ui = chatSystemModelElements();
chatSystemModelSaving = true;
if (ui.list) {
ui.list.querySelectorAll('button').forEach(function (button) { button.disabled = true; });
}
setChatSystemModelStatus(chatTranslate('chat.systemModelSaving', '正在保存…'), 'loading');
try {
const latestResponse = await apiFetch('/api/config');
if (!latestResponse.ok) {
throw new Error(await readChatSystemModelError(latestResponse, chatTranslate('chat.systemModelSaveFailed', '保存失败')));
if (!ui.list) return;
ui.list.innerHTML = '';
const currentMode = currentChatReasoningMode();
['default', 'off', 'on', 'auto'].forEach(function (mode) {
const option = document.createElement('button');
option.type = 'button';
option.className = 'chat-system-model-option';
option.setAttribute('role', 'option');
option.setAttribute('aria-selected', mode === currentMode ? 'true' : 'false');
if (mode === currentMode) option.classList.add('is-selected');
const label = document.createElement('span');
label.className = 'chat-system-model-option-label chat-system-effort-option-label';
label.textContent = reasoningSummaryModeLabel(mode);
option.appendChild(label);
if (mode === currentMode) {
const current = document.createElement('span');
current.className = 'chat-system-model-current';
current.textContent = chatTranslate('chat.systemModelCurrent', '当前');
option.appendChild(current);
}
const latest = await latestResponse.json();
const state = chatSystemModelConfigState(latest);
state.ai.channels[state.channelId] = {
...state.channel,
reasoning: { ...(state.channel.reasoning || {}), effort: chosen }
};
const updateResponse = await apiFetch('/api/config', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ai: state.ai })
option.addEventListener('click', function (event) {
event.preventDefault();
event.stopPropagation();
selectChatReasoningMode(mode);
});
if (!updateResponse.ok) {
throw new Error(await readChatSystemModelError(updateResponse, chatTranslate('chat.systemModelSaveFailed', '保存失败')));
}
const applyResponse = await apiFetch('/api/config/apply', { method: 'POST' });
if (!applyResponse.ok) {
throw new Error(await readChatSystemModelError(applyResponse, chatTranslate('chat.systemModelApplyFailed', '应用模型失败')));
}
chatAIChannels = state.ai.channels;
chatDefaultAIChannel = state.channelId;
await initChatAgentModeFromConfig();
updateChatComposerSessionShortcuts();
renderChatReasoningEffortOptions();
setChatSystemModelStatus(chatTranslate('chat.systemModelSaved', '已自动保存'), 'success');
if (chatSystemModelCloseTimer) window.clearTimeout(chatSystemModelCloseTimer);
chatSystemModelCloseTimer = window.setTimeout(function () {
chatSystemModelSaving = false;
closeChatSystemModelPicker(true);
}, 650);
return;
} catch (error) {
console.error('selectChatReasoningEffort', error);
setChatSystemModelStatus(error.message || chatTranslate('chat.systemModelSaveFailed', '保存失败'), 'error');
}
chatSystemModelSaving = false;
if (ui.list) {
ui.list.querySelectorAll('button').forEach(function (button) { button.disabled = false; });
}
ui.list.appendChild(option);
});
}
function finishChatReasoningPickerUpdate() {
persistChatReasoningPrefs();
setChatSystemModelStatus(chatTranslate('chat.reasoningSessionUpdated', '会话推理设置已更新'), 'success');
if (chatSystemModelCloseTimer) window.clearTimeout(chatSystemModelCloseTimer);
chatSystemModelCloseTimer = window.setTimeout(function () {
chatSystemModelSaving = false;
closeChatSystemModelPicker(true);
}, 450);
}
function selectChatReasoningMode(mode) {
if (chatSystemModelSaving) return;
const chosen = ['default', 'off', 'on', 'auto'].includes(String(mode || '').trim())
? String(mode || '').trim()
: 'default';
const modeControl = document.getElementById('chat-reasoning-mode');
if (!modeControl) return;
chatSystemModelSaving = true;
modeControl.value = chosen;
finishChatReasoningPickerUpdate();
}
function selectChatReasoningEffort(effort) {
if (chatSystemModelSaving) return;
const raw = String(effort || '').trim();
const chosen = ['', 'low', 'medium', 'high', 'xhigh', 'max'].includes(raw) ? raw : '';
const effortControl = document.getElementById('chat-reasoning-effort');
if (!effortControl) return;
chatSystemModelSaving = true;
effortControl.value = chosen;
finishChatReasoningPickerUpdate();
}
function renderChatSystemModelRetry() {
@@ -1313,12 +1398,62 @@ function renderChatSystemModelRetry() {
retry.className = 'chat-system-model-retry';
retry.textContent = chatTranslate('chat.systemModelRetry', '重新获取');
retry.addEventListener('click', function (retryEvent) {
closeChatSystemModelPicker(true);
openChatSystemModelPicker(retryEvent);
retryEvent.preventDefault();
retryEvent.stopPropagation();
fetchChatSystemModelsForChannel(resolveChatPickerChannelId(), { force: true });
});
ui.list.appendChild(retry);
}
function renderChatAIChannelOptions() {
const ui = chatSystemModelElements();
if (!ui.list) return;
ui.list.innerHTML = '';
const selected = selectedChatAIChannelId();
const choices = [{ id: '', label: chatTranslate('chat.aiChannelDefault', '跟随默认通道') }]
.concat(Object.keys(chatAIChannels).sort().map(function (id) {
const channel = chatAIChannels[id] || {};
return { id: id, label: channel.name || id };
}));
choices.forEach(function (choice) {
const option = document.createElement('button');
option.type = 'button';
option.className = 'chat-system-model-option';
option.setAttribute('role', 'option');
option.setAttribute('aria-selected', choice.id === selected ? 'true' : 'false');
if (choice.id === selected) option.classList.add('is-selected');
const label = document.createElement('span');
label.className = 'chat-system-model-option-label chat-system-channel-option-label';
label.textContent = choice.label;
option.appendChild(label);
if (choice.id === selected) {
const current = document.createElement('span');
current.className = 'chat-system-model-current';
current.textContent = chatTranslate('chat.systemModelCurrent', '当前');
option.appendChild(current);
}
option.addEventListener('click', function (event) {
event.preventDefault();
event.stopPropagation();
selectChatAIChannel(choice.id);
});
ui.list.appendChild(option);
});
}
async function selectChatAIChannel(channelId) {
const select = document.getElementById('chat-ai-channel-select');
if (!select) return;
const resolved = resolveChatAIChannelId(channelId);
select.value = resolved || '';
persistChatAIChannelPref();
refreshSessionSettingsSelects();
updateChatSystemModelPickerValues();
openChatSystemModelView('main');
setChatSystemModelStatus(chatTranslate('chat.systemModelLoading', '正在获取模型列表…'), 'loading');
await fetchChatSystemModelsForChannel(resolveChatPickerChannelId(), { force: true });
}
function openChatSystemModelView(view, event) {
if (event) {
event.preventDefault();
@@ -1335,6 +1470,18 @@ function openChatSystemModelView(view, event) {
ui.main.hidden = true;
ui.subview.hidden = false;
ui.subview.dataset.view = view;
if (view === 'channel') {
if (ui.subviewTitle) ui.subviewTitle.textContent = chatTranslate('chat.aiChannelLabel', 'AI 通道');
setChatSystemModelStatus('', '');
renderChatAIChannelOptions();
return;
}
if (view === 'mode') {
if (ui.subviewTitle) ui.subviewTitle.textContent = chatTranslate('chat.reasoningModeLabel', '推理模式');
setChatSystemModelStatus('', '');
renderChatReasoningModeOptions();
return;
}
if (view === 'effort') {
if (ui.subviewTitle) ui.subviewTitle.textContent = chatTranslate('chat.reasoningEffortLabel', '推理强度');
setChatSystemModelStatus('', '');
@@ -1374,7 +1521,7 @@ async function selectChatSystemModel(model) {
throw new Error(await readChatSystemModelError(latestResponse, chatTranslate('chat.systemModelSaveFailed', '保存失败')));
}
const latest = await latestResponse.json();
const state = chatSystemModelConfigState(latest);
const state = chatSystemModelConfigState(latest, resolveChatPickerChannelId());
state.ai.channels[state.channelId] = { ...state.channel, model: chosen };
const updateResponse = await apiFetch('/api/config', {
method: 'PUT',
@@ -1389,7 +1536,7 @@ async function selectChatSystemModel(model) {
throw new Error(await readChatSystemModelError(applyResponse, chatTranslate('chat.systemModelApplyFailed', '应用模型失败')));
}
chatAIChannels = state.ai.channels;
chatDefaultAIChannel = state.channelId;
chatDefaultAIChannel = resolveChatAIChannelId(state.ai.default_channel) || state.channelId;
updateChatComposerSessionShortcuts();
await initChatAgentModeFromConfig();
chatSystemModelCurrent = chosen;
@@ -1413,6 +1560,89 @@ async function selectChatSystemModel(model) {
}
}
function chatSystemModelCacheKey(channelId, channel) {
return [
String(channelId || ''),
String(channel && channel.provider || 'openai'),
String(channel && channel.base_url || '').trim()
].join('|');
}
async function fetchChatSystemModelsForChannel(channelId, options) {
const opts = options || {};
const ui = chatSystemModelElements();
if (!ui.menu || !ui.list || ui.menu.hidden) return;
const resolvedChannelId = resolveChatAIChannelId(channelId) || chatDefaultAIChannel;
const channel = resolvedChannelId ? chatAIChannels[resolvedChannelId] || {} : {};
const cacheKey = chatSystemModelCacheKey(resolvedChannelId, channel);
const cached = chatSystemModelCache.get(cacheKey);
const requestId = ++chatSystemModelRequestSeq;
chatSystemModelCurrent = String(channel.model || '').trim();
chatSystemModelOptions = [];
chatSystemModelLoadError = '';
if (!opts.force && cached && Date.now() - cached.fetchedAt < CHAT_SYSTEM_MODEL_CACHE_TTL_MS) {
chatSystemModelOptions = cached.models.slice();
if (ui.subview && !ui.subview.hidden && ui.subview.dataset.view === 'model') {
renderChatSystemModelOptions(chatSystemModelOptions, chatSystemModelCurrent);
}
const cachedCount = [chatSystemModelCurrent].concat(chatSystemModelOptions)
.map(function (model) { return String(model || '').trim(); })
.filter(function (model, index, all) { return model && all.indexOf(model) === index; })
.length;
setChatSystemModelStatus(
chatTranslate('chat.systemModelLoaded', '已获取 {count} 个模型').replace('{count}', String(cachedCount)),
'success'
);
return;
}
if (ui.subview && !ui.subview.hidden && ui.subview.dataset.view === 'model') {
ui.list.innerHTML = '';
}
setChatSystemModelStatus(chatTranslate('chat.systemModelLoading', '正在获取模型列表…'), 'loading');
try {
if (!String(channel.api_key || '').trim()) {
throw new Error(chatTranslate('chat.systemModelNeedApiKey', '请先在系统设置中配置 API Key'));
}
const listResponse = await apiFetch('/api/config/list-models', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
provider: channel.provider || 'openai',
base_url: String(channel.base_url || '').trim(),
api_key: String(channel.api_key || '').trim()
})
});
const result = await listResponse.json().catch(function () { return {}; });
if (!listResponse.ok || !result.success) {
throw new Error(result.error || chatTranslate('chat.systemModelLoadFailed', '获取模型失败'));
}
if (requestId !== chatSystemModelRequestSeq || ui.menu.hidden) return;
chatSystemModelOptions = Array.isArray(result.models) ? result.models.slice() : [];
chatSystemModelCache.set(cacheKey, {
models: chatSystemModelOptions.slice(),
fetchedAt: Date.now()
});
const count = [chatSystemModelCurrent].concat(chatSystemModelOptions)
.map(function (model) { return String(model || '').trim(); })
.filter(function (model, index, all) { return model && all.indexOf(model) === index; })
.length;
if (ui.subview && !ui.subview.hidden && ui.subview.dataset.view === 'model') {
renderChatSystemModelOptions(chatSystemModelOptions, chatSystemModelCurrent);
}
setChatSystemModelStatus(
chatTranslate('chat.systemModelLoaded', '已获取 {count} 个模型').replace('{count}', String(count)),
'success'
);
} catch (error) {
if (requestId !== chatSystemModelRequestSeq || ui.menu.hidden) return;
chatSystemModelLoadError = error.message || chatTranslate('chat.systemModelLoadFailed', '获取模型失败');
if (ui.subview && !ui.subview.hidden && ui.subview.dataset.view === 'model') {
renderChatSystemModelRetry();
}
setChatSystemModelStatus(chatSystemModelLoadError, 'error');
}
}
async function openChatSystemModelPicker(event) {
if (event) {
event.preventDefault();
@@ -1435,58 +1665,8 @@ async function openChatSystemModelPicker(event) {
if (ui.main) ui.main.hidden = false;
if (ui.subview) ui.subview.hidden = true;
ui.list.innerHTML = '';
chatSystemModelOptions = [];
chatSystemModelCurrent = currentSystemModelLabel();
chatSystemModelLoadError = '';
updateChatSystemModelPickerValues();
setChatSystemModelStatus(chatTranslate('chat.systemModelLoading', '正在获取模型列表…'), 'loading');
const requestId = ++chatSystemModelRequestSeq;
try {
const configResponse = await apiFetch('/api/config');
if (!configResponse.ok) {
throw new Error(await readChatSystemModelError(configResponse, chatTranslate('chat.systemModelLoadFailed', '获取模型失败')));
}
const cfg = await configResponse.json();
const state = chatSystemModelConfigState(cfg);
const channel = state.channel || {};
if (!String(channel.api_key || '').trim()) {
throw new Error(chatTranslate('chat.systemModelNeedApiKey', '请先在系统设置中配置 API Key'));
}
const listResponse = await apiFetch('/api/config/list-models', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
provider: channel.provider || 'openai',
base_url: String(channel.base_url || '').trim(),
api_key: String(channel.api_key || '').trim()
})
});
const result = await listResponse.json().catch(function () { return {}; });
if (!listResponse.ok || !result.success) {
throw new Error(result.error || chatTranslate('chat.systemModelLoadFailed', '获取模型失败'));
}
if (requestId !== chatSystemModelRequestSeq || ui.menu.hidden) return;
chatSystemModelCurrent = String(channel.model || '').trim();
chatSystemModelOptions = Array.isArray(result.models) ? result.models.slice() : [];
const count = [chatSystemModelCurrent].concat(chatSystemModelOptions)
.map(function (model) { return String(model || '').trim(); })
.filter(function (model, index, all) { return model && all.indexOf(model) === index; })
.length;
if (ui.subview && !ui.subview.hidden && ui.subview.dataset.view === 'model') {
renderChatSystemModelOptions(chatSystemModelOptions, chatSystemModelCurrent);
}
setChatSystemModelStatus(
chatTranslate('chat.systemModelLoaded', '已获取 {count} 个模型').replace('{count}', String(count)),
'success'
);
} catch (error) {
if (requestId !== chatSystemModelRequestSeq || ui.menu.hidden) return;
chatSystemModelLoadError = error.message || chatTranslate('chat.systemModelLoadFailed', '获取模型失败');
if (ui.subview && !ui.subview.hidden && ui.subview.dataset.view === 'model') {
renderChatSystemModelRetry();
}
setChatSystemModelStatus(chatSystemModelLoadError, 'error');
}
await fetchChatSystemModelsForChannel(resolveChatPickerChannelId());
}
function truncateChatAIChannelSummaryLabel(label) {
@@ -1502,6 +1682,7 @@ function persistChatAIChannelPref() {
else localStorage.removeItem(AI_CHANNEL_STORAGE_KEY);
} catch (e) {}
updateChatReasoningSummary();
updateChatSystemModelPickerValues();
}
function reasoningSummaryModeLabel(mode) {
@@ -1533,9 +1714,8 @@ function updateChatReasoningSummary() {
}
const channelPart = currentChatAIChannelLabel();
const modelPart = currentChatModelLabel();
const parts = [truncateChatAIChannelSummaryLabel(channelPart), reasoningPart, hitlPart].filter(Boolean);
el.textContent = parts.join(' / ');
el.title = [channelPart, reasoningPart, hitlPart].filter(Boolean).join(' / ');
el.textContent = hitlPart;
el.title = hitlPart;
updateChatComposerSessionShortcuts({
channel: channelPart,
model: modelPart,
@@ -1549,16 +1729,17 @@ function updateChatComposerSessionShortcuts(summary) {
const modelEl = document.getElementById('chat-model-shortcut-text');
const hitlEl = document.getElementById('chat-hitl-shortcut-text');
if (modelEl) {
// 输入框右侧展示系统默认主模型;审批模型只出现在 HITL 入口。
const label = currentSystemModelLabel();
// 输入框右侧展示当前会话通道的模型;审批模型只出现在 HITL 入口。
const label = currentChatModelLabel();
modelEl.textContent = truncateChatAIChannelSummaryLabel(label);
modelEl.title = label;
const shortcut = document.getElementById('chat-model-shortcut');
if (shortcut) {
const effort = chatReasoningEffortLabel(currentSystemReasoningEffort());
const action = chatTranslate('chat.modelSettingsAria', '选择模型与推理强度');
shortcut.setAttribute('aria-label', action + '' + label + ' · ' + effort);
shortcut.title = action + '' + label + ' · ' + effort;
const channel = currentChatAIChannelLabel();
const effort = currentChatReasoningMenuLabel();
const action = chatTranslate('chat.modelSettingsAria', '选择 AI 通道、模型与推理设置');
shortcut.setAttribute('aria-label', action + '' + channel + ' · ' + label + ' · ' + effort);
shortcut.title = action + '' + channel + ' · ' + label + ' · ' + effort;
}
updateChatSystemModelPickerValues();
}
@@ -1625,6 +1806,18 @@ function ownsLiveChatStream(liveStream) {
return !!liveStream && window.__csAgentLiveStream === liveStream;
}
function shouldIgnoreLiveChatStreamEvent(
liveStream,
activeLiveStream = window.__csAgentLiveStream,
navigationSeq = chatConversationNavigationSeq
) {
return !liveStream ||
activeLiveStream !== liveStream ||
liveStream.active !== true ||
liveStream.detached === true ||
liveStream.navigationSeq !== navigationSeq;
}
function clearLiveChatStreamIfOwned(liveStream) {
if (!ownsLiveChatStream(liveStream)) return false;
liveStream.active = false;
@@ -2065,11 +2258,22 @@ async function sendMessage() {
const input = document.getElementById('chat-input');
let message = input.value.trim();
const hasAttachments = chatAttachments && chatAttachments.length > 0;
const requestConversationId = currentConversationId;
const requestNavigationSeq = chatConversationNavigationSeq;
if (!message && !hasAttachments) {
return;
}
// A restored conversation renders from the local cache first, while its
// authoritative HITL config is fetched separately. Do not let a fast send
// reuse the temporary/default reviewer (historically "human") before that
// fetch completes, otherwise refreshing could turn Audit Agent review into
// a human approval for the next tool call.
const hitlConversationAtSendStart = String(currentConversationId || '').trim();
await waitForHitlConfigReady(hitlConversationAtSendStart);
if (String(currentConversationId || '').trim() !== hitlConversationAtSendStart) return;
// Enter 会直接调用 sendMessage;同一会话在其他标签页已启动任务时,
// 必须在渲染用户气泡和发起 POST 前做一次权威状态同步,避免生成一轮“已有任务执行中”伪对话。
if (currentConversationId && typeof loadActiveTasks === 'function') {
@@ -2109,6 +2313,12 @@ async function sendMessage() {
message = CHAT_FILE_DEFAULT_PROMPT;
}
// 发送前的任务状态/附件检查可能包含异步等待。若用户已主动切换会话,
// 保留当前页面,不再把这次尚未发出的请求写入新的可见对话。
if (requestNavigationSeq !== chatConversationNavigationSeq) {
return;
}
// 显示用户消息(含附件名,便于用户确认)
const displayMessage = hasAttachments
? message + '\n' + chatAttachments.map(a => '📎 ' + a.fileName).join('\n')
@@ -2144,7 +2354,7 @@ async function sendMessage() {
// 构建请求体(含附件)
const body = {
message: message,
conversationId: currentConversationId,
conversationId: requestConversationId,
role: typeof getCurrentRole === 'function' ? getCurrentRole() : ''
};
if (window.__csNextChatFinalizationPolicy && typeof window.__csNextChatFinalizationPolicy === 'object') {
@@ -2205,7 +2415,8 @@ async function sendMessage() {
conversationId: streamConversationId || null,
progressId: progressId,
abortController: requestAbortController,
detached: false
detached: false,
navigationSeq: requestNavigationSeq
};
window.__csAgentLiveStream = liveStreamState;
if (streamConversationId && typeof window.notifyConversationTaskStarted === 'function') {
@@ -2256,18 +2467,18 @@ async function sendMessage() {
if (streamConversationId && streamConversationId !== eventConvId) {
return;
}
if (!streamConversationId && eventData.type === 'conversation') {
if (!streamConversationId) {
streamConversationId = eventConvId;
liveStreamState.conversationId = eventConvId;
justBoundConversation = true;
// 旧请求可能在用户切换对话后才收到 conversation 事件。
// 只完成本地任务绑定,不允许它重新抢占当前对话或新的主流状态。
if (!ownsLiveChatStream(liveStreamState) || liveStreamState.detached) {
updateProgressConversation(progressId, eventConvId);
return;
}
}
}
// 切换对话后仍可能收到旧响应流中已缓冲的 conversation、response_start
// 或 response 事件。它们只能补齐后台任务归属,不能重新抢占当前对话。
if (shouldIgnoreLiveChatStreamEvent(liveStreamState)) {
if (eventConvId) updateProgressConversation(progressId, eventConvId);
return;
}
if (!justBoundConversation && !isStreamStillVisibleForRequest()) {
return;
}
@@ -3890,6 +4101,9 @@ function renderProcessDetails(messageId, processDetails, options) {
contentDiv.appendChild(timeline);
detailsContainer.appendChild(contentDiv);
}
if (typeof window.ensureProcessDetailsReturnLatestControl === 'function') {
window.ensureProcessDetailsReturnLatestControl(timeline);
}
// processDetails === null 表示“尚未加载(懒加载)”;messages.reasoningContent 可先展示
const isLazyNotLoaded = isLazyRequest;
@@ -3901,6 +4115,9 @@ function renderProcessDetails(messageId, processDetails, options) {
timeline.innerHTML = '<div class="progress-timeline-empty">' + lazyHint + '</div>';
bindProcessDetailsLazyHint(timeline, messageId);
timeline.classList.remove('expanded');
if (typeof window.updateProcessDetailsReturnLatestControl === 'function') {
window.updateProcessDetailsReturnLatestControl(timeline);
}
prefetchProcessDetailsSummaryHint(messageId, messageElement);
return;
}
@@ -3934,6 +4151,9 @@ function renderProcessDetails(messageId, processDetails, options) {
if (!isProcessDetailsUserExpanded(messageId)) {
timeline.classList.remove('expanded');
}
if (typeof window.updateProcessDetailsReturnLatestControl === 'function') {
window.updateProcessDetailsReturnLatestControl(timeline);
}
}
return;
}
@@ -4299,6 +4519,12 @@ function finishProcessDetailsRender(messageElement, processDetails, isLazyNotLoa
window.restoreWorkflowHitlInlineForConversation(convId);
}
}
if (typeof window.ensureProcessDetailsReturnLatestControl === 'function') {
window.ensureProcessDetailsReturnLatestControl(timeline);
}
if (typeof window.updateProcessDetailsReturnLatestControl === 'function') {
window.updateProcessDetailsReturnLatestControl(timeline);
}
}
/** 懒加载折叠态:后台拉摘要,提示迭代规模而不加载全量详情 */
@@ -5485,6 +5711,11 @@ async function startNewConversation(options = {}) {
const requestedProjectId = hasExplicitProjectId
? String(options.projectId || '').trim()
: String(inheritedProjectId || '').trim();
markChatConversationNavigation('', true);
if (typeof window.cancelScheduledChatConversationFromHash === 'function') {
window.cancelScheduledChatConversationFromHash();
}
clearChatConversationHash();
cancelPendingConversationLoad();
detachLiveChatStreamForNavigation('', true);
if (typeof window.cancelRunningTaskEventStream === 'function') {
@@ -5609,7 +5840,8 @@ function createConversationListItem(conversation) {
item.onclick = (e) => {
e.preventDefault();
e.stopPropagation();
loadConversation(conversation.id);
const targetConversationId = String(item.dataset.conversationId || '').trim();
if (targetConversationId) loadConversation(targetConversationId);
};
return item;
}
@@ -5925,16 +6157,30 @@ async function prefetchLastAssistantProcessDetails() {
}
async function loadConversation(conversationId) {
conversationId = String(conversationId || '').trim();
if (!conversationId) return;
// Keep the visible conversation addressable across a full page refresh.
// Sidebar/project entries call loadConversation directly (rather than the
// router helper), so without this synchronization #chat loses the active
// conversation and reload falls back to the welcome screen instead of
// reconnecting the running task event stream.
markChatConversationNavigation(conversationId);
if (typeof window.cancelScheduledChatConversationFromHash === 'function') {
window.cancelScheduledChatConversationFromHash();
}
syncChatConversationHash(conversationId);
const seq = ++loadConversationRequestSeq;
const previousConversationId = currentConversationId;
cancelPendingConversationLoad();
detachLiveChatStreamForNavigation(conversationId);
// 用户单击即代表新的可见会话。必须在任何网络等待之前提交该选择,
// 否则每 2 秒的活跃任务刷新仍会把旧会话识别为可见,并排队重载旧补流,
// 反过来取消这次切换。
currentConversationId = conversationId;
try {
window.currentConversationId = conversationId;
} catch (e) { /* ignore */ }
loadConversationPendingId = conversationId;
const conversationLoadController = new AbortController();
loadConversationAbortController = conversationLoadController;
if (typeof window.selectChatProjectConversationItem === 'function') {
@@ -5965,6 +6211,14 @@ async function loadConversation(conversationId) {
return;
}
if (response && !response.ok) {
if (seq === loadConversationRequestSeq) {
currentConversationId = previousConversationId;
try {
window.currentConversationId = previousConversationId || '';
} catch (e) { /* ignore */ }
if (previousConversationId) syncChatConversationHash(previousConversationId);
else clearChatConversationHash();
}
showChatToast('加载对话失败: ' + (conversation.error || '未知错误'), 'error');
return;
}
@@ -6034,7 +6288,12 @@ async function loadConversation(conversationId) {
}
}).catch(() => {})
: Promise.resolve();
void hitlSyncPromise;
hitlConfigSyncConversationId = conversationId;
hitlConfigSyncPromise = Promise.resolve(hitlSyncPromise);
await hitlConfigSyncPromise;
if (seq !== loadConversationRequestSeq || currentConversationId !== conversationId) {
return;
}
updateActiveConversation();
// 如果攻击链模态框打开且显示的不是当前对话,关闭它
@@ -6246,8 +6505,16 @@ async function loadConversation(conversationId) {
}
} catch (error) {
if (error && error.name === 'AbortError') return;
if (seq === loadConversationRequestSeq && typeof window.selectChatProjectConversationItem === 'function') {
window.selectChatProjectConversationItem(previousConversationId);
if (seq === loadConversationRequestSeq) {
currentConversationId = previousConversationId;
try {
window.currentConversationId = previousConversationId || '';
} catch (e) { /* ignore */ }
if (previousConversationId) syncChatConversationHash(previousConversationId);
else clearChatConversationHash();
if (typeof window.selectChatProjectConversationItem === 'function') {
window.selectChatProjectConversationItem(previousConversationId);
}
}
console.error('加载对话失败:', error);
showChatToast('加载对话失败: ' + (error && error.message ? error.message : String(error)), 'error');
@@ -6258,6 +6525,9 @@ async function loadConversation(conversationId) {
if (loadConversationAbortController === conversationLoadController) {
loadConversationAbortController = null;
}
if (seq === loadConversationRequestSeq && loadConversationPendingId === conversationId) {
loadConversationPendingId = '';
}
}
}
@@ -9921,7 +10191,8 @@ function createConversationListItemWithMenu(conversation, isPinned) {
if (currentGroupId) {
exitGroupDetail();
}
loadConversation(conversation.id);
const targetConversationId = String(item.dataset.conversationId || '').trim();
if (targetConversationId) loadConversation(targetConversationId);
};
return item;
+66 -16
View File
@@ -22,29 +22,67 @@ test('输入区提供独立审批入口并暴露可配置等待时限', () => {
assert.match(chat, /body\.hitl = \{[\s\S]*?timeoutSeconds: normalizeHitlTimeoutForChat\(hitlCfg\.timeoutSeconds/);
});
test('输入框可直接保存系统模型和系统推理强度且审批模型只出现在审计 Agent 入口', () => {
test('超长人工审批内容在限高区域内滚动且操作按钮始终可见', () => {
assert.match(styles, /\.chat-hitl-approval-dock \{[\s\S]*?max-height: min\(62dvh, 560px\);[\s\S]*?padding: 18px 20px 74px;[\s\S]*?overflow: hidden;/);
assert.match(styles, /\.chat-hitl-approval-scroll-region \{[\s\S]*?max-height: max\(76px, calc\(min\(62dvh, 560px\) - 94px\)\);[\s\S]*?overflow-y: auto;[\s\S]*?overscroll-behavior: contain;/);
assert.match(styles, /\.chat-hitl-approval-dock \.hitl-edit-args \{[\s\S]*?max-height: min\(28dvh, 220px\);[\s\S]*?overflow: auto;/);
assert.match(styles, /\.chat-hitl-approval-dock \.hitl-inline-actions \{[\s\S]*?position: absolute;[\s\S]*?bottom: 16px;[\s\S]*?box-shadow: none;/);
assert.match(styles, /\.chat-hitl-approval-dock \.hitl-approval-heading h3 \{[\s\S]*?-webkit-line-clamp: 3;/);
assert.match(monitor, /function wrapChatHitlApprovalScrollRegion\(dock\)/);
assert.match(monitor, /while \(dock\.firstChild && dock\.firstChild !== actions\)/);
assert.match(monitor, /wrapChatHitlApprovalScrollRegion\(dock\);/);
assert.match(monitor, /url\.length > 160[\s\S]*?requestVisitLongUrl/);
assert.equal(zh.hitl.requestVisitLongUrl, '允许 CyberStrikeAI 访问此地址?');
assert.equal(en.hitl.requestVisitLongUrl, 'Allow CyberStrikeAI to visit this address?');
});
test('刷新恢复会话时先完成权威审批配置同步再允许发送', () => {
assert.match(chat, /function waitForHitlConfigReady\(conversationId\)/);
assert.match(chat, /await waitForHitlConfigReady\(hitlConversationAtSendStart\)/);
assert.match(chat, /hitlConfigSyncConversationId = conversationId;[\s\S]{0,240}await hitlConfigSyncPromise;/);
assert.match(chat, /await hitlConfigSyncPromise;[\s\S]{0,220}seq !== loadConversationRequestSeq/);
assert.match(fs.readFileSync('web/static/js/hitl.js', 'utf8'), /window\.csaiHitlDefaultReviewerReady = initHitlDefaultReviewerFromServer\(\)/);
});
test('同一会话的审批配置写入串行化以防止旧请求后到覆盖新选择', () => {
const hitlPage = fs.readFileSync('web/static/js/hitl.js', 'utf8');
assert.match(hitlPage, /const hitlConversationConfigSaveQueues = new Map\(\)/);
assert.match(hitlPage, /const previous = hitlConversationConfigSaveQueues\.get\(normalizedConversationId\) \|\| Promise\.resolve\(\)/);
assert.match(hitlPage, /const queued = previous\.catch\(function \(\) \{\}\)\.then\(async function \(\)/);
});
test('输入框可按会话通道获取模型并双向同步会话推理且审批模型只出现在审计 Agent 入口', () => {
assert.match(chat, /function currentSystemModelLabel\(\)/);
assert.match(chat, /chatDefaultAIChannel \? chatAIChannels\[chatDefaultAIChannel\]/);
assert.match(chat, /function currentHitlAuditModelLabel\(\)/);
assert.match(chat, /const label = currentSystemModelLabel\(\)/);
assert.match(chat, /const label = currentChatModelLabel\(\)/);
assert.doesNotMatch(chat, /const label = data\.model \|\| currentChatModelLabel\(\)/);
assert.match(chat, /const approvalModel = auditAgent \? currentHitlAuditModelLabel\(\) : ''/);
assert.match(chat, /hitlAuditModel\.model\.trim\(\)/);
assert.match(template, /id="chat-model-shortcut"[^>]+onclick="openChatSystemModelPicker\(event\)"/);
assert.match(template, /id="chat-system-model-menu"[^>]+hidden/);
assert.doesNotMatch(template, /id="chat-reasoning-shortcut"/);
assert.match(template, /openChatSystemModelView\('model', event\)[\s\S]{0,1200}openChatSystemModelView\('effort', event\)/);
assert.doesNotMatch(template, /session-settings-group-ai/);
assert.match(template, /class="chat-ai-session-state" hidden[\s\S]{0,500}id="chat-ai-channel-select"/);
assert.match(template, /openChatSystemModelView\('channel', event\)[\s\S]{0,1200}openChatSystemModelView\('model', event\)[\s\S]{0,1200}openChatSystemModelView\('mode', event\)[\s\S]{0,1200}openChatSystemModelView\('effort', event\)/);
assert.match(chat, /function renderChatReasoningEffortOptions\(\)/);
assert.match(chat, /function currentSystemReasoningEffort\(\)[\s\S]{0,500}reasoning\.effort/);
assert.match(chat, /function renderChatReasoningModeOptions\(\)/);
assert.match(chat, /case 'low': return 'low'[\s\S]{0,300}case 'max': return 'max'/);
assert.match(chat, /chatTranslate\('chat\.reasoningEffortUnset', '不指定'\)/);
assert.match(chat, /function selectChatReasoningEffort\(effort\)[\s\S]{0,2400}reasoning: \{ \.\.\.\(state\.channel\.reasoning \|\| \{\}\), effort: chosen \}/);
assert.match(chat, /function selectChatReasoningEffort\(effort\)[\s\S]{0,4200}body: JSON\.stringify\(\{ ai: state\.ai \}\)[\s\S]{0,900}apiFetch\('\/api\/config\/apply'/);
assert.match(chat, /function openChatSystemModelPicker\(event\)[\s\S]{0,4200}apiFetch\('\/api\/config\/list-models'/);
assert.match(chat, /\['default', 'off', 'on', 'auto'\]/);
assert.match(chat, /\['', 'low', 'medium', 'high', 'xhigh', 'max'\]/);
assert.match(chat, /function selectChatReasoningMode\(mode\)[\s\S]{0,700}modeControl\.value = chosen[\s\S]{0,200}finishChatReasoningPickerUpdate\(\)/);
assert.match(chat, /function selectChatReasoningEffort\(effort\)[\s\S]{0,700}effortControl\.value = chosen[\s\S]{0,200}finishChatReasoningPickerUpdate\(\)/);
assert.match(chat, /function fetchChatSystemModelsForChannel\(channelId, options\)[\s\S]{0,4200}apiFetch\('\/api\/config\/list-models'/);
assert.match(chat, /function selectChatAIChannel\(channelId\)[\s\S]{0,900}fetchChatSystemModelsForChannel\(resolveChatPickerChannelId\(\), \{ force: true \}\)/);
assert.match(chat, /const chatSystemModelCache = new Map\(\)/);
assert.match(chat, /Date\.now\(\) - cached\.fetchedAt < CHAT_SYSTEM_MODEL_CACHE_TTL_MS/);
assert.match(chat, /function selectChatSystemModel\(model\)[\s\S]{0,2600}method: 'PUT'[\s\S]{0,900}apiFetch\('\/api\/config\/apply'/);
assert.match(chat, /body: JSON\.stringify\(\{ ai: state\.ai \}\)/);
assert.equal(zh.chat.modelSettingsAria, '选择模型与推理强度');
assert.equal(en.chat.modelSettingsAria, 'Choose model and reasoning effort');
assert.equal(zh.chat.modelSettingsAria, '选择 AI 通道、模型与推理设置');
assert.equal(en.chat.modelSettingsAria, 'Choose AI channel, model, and reasoning settings');
assert.equal(zh.chat.reasoningSessionUpdated, '会话推理设置已更新');
assert.equal(en.chat.reasoningSessionUpdated, 'Session reasoning updated');
});
test('审批请求按浏览器、命令、文件和通用工具动态描述', () => {
@@ -242,7 +280,7 @@ test('多对话并发时释放隐藏主流且旧请求不能覆盖新对话状
assert.match(chat, /liveStream\.detached = true;[\s\S]{0,240}controller\.abort\(\)/);
assert.match(chat, /const requestAbortController = new AbortController\(\)/);
assert.match(chat, /signal: requestAbortController\.signal/);
assert.match(chat, /if \(!ownsLiveChatStream\(liveStreamState\) \|\| liveStreamState\.detached\)/);
assert.match(chat, /shouldIgnoreLiveChatStreamEvent\(liveStreamState\)/);
assert.match(chat, /const clearedOwnedStream = clearLiveChatStreamIfOwned\(liveStreamState\)/);
assert.match(chat, /detachLiveChatStreamForNavigation\(conversationId\)/);
assert.match(chat, /detachLiveChatStreamForNavigation\('', true\)/);
@@ -251,12 +289,24 @@ test('多对话并发时释放隐藏主流且旧请求不能覆盖新对话状
assert.match(monitor, /function scrollProcessDetailsToLatest\(assistantMessageId, smooth = true\)/);
assert.match(monitor, /timeline\.scrollTop = targetTop/);
assert.match(chat, /let loadConversationAbortController = null/);
assert.match(chat, /cancelPendingConversationLoad\(\);[\s\S]{0,220}const conversationLoadController = new AbortController\(\)/);
assert.match(chat, /cancelPendingConversationLoad\(\);[\s\S]{0,900}const conversationLoadController = new AbortController\(\)/);
assert.match(chat, /signal: conversationLoadController\.signal/);
assert.match(template, /monitor\.js\?v=20260813-9/);
assert.match(template, /chat-scroll\.js\?v=20260813-6/);
assert.match(template, /chat\.js\?v=20260813-4/);
assert.match(template, /style\.css\?v=20260813-6/);
assert.match(template, /monitor\.js\?v=20260819-3/);
assert.match(template, /chat-scroll\.js\?v=20260815-1/);
assert.match(template, /chat\.js\?v=20260819-5/);
assert.match(template, /style\.css\?v=20260819-4/);
});
test('彻底停止始终使用弹窗锁定的会话且状态刷新后仍会取消', () => {
const start = monitor.indexOf("async function performHardCancelProgressTask(progressId, conversationId = '')");
const end = monitor.indexOf('function progressElapsedText(', start);
assert.notEqual(start, -1);
assert.notEqual(end, -1);
const hardCancelSource = monitor.slice(start, end);
assert.match(monitor, /performHardCancelProgressTask\(progressId, conversationId\)/);
assert.match(hardCancelSource, /const targetConversationId = String\(conversationId \|\| \(state && state\.conversationId\) \|\| ''\)\.trim\(\)/);
assert.match(hardCancelSource, /await requestCancel\(targetConversationId\)/);
assert.doesNotMatch(hardCancelSource, /if \(!state \|\| !state\.conversationId\)/);
});
test('输入区 Agent 审查文字保留足够行高且不会裁切字形', () => {
@@ -301,7 +351,7 @@ test('审批状态主动轮询并在服务不可用时立即关闭旧审批', ()
assert.match(monitor, /renderActiveTasks\(\[\]\);[\s\S]{0,260}hitlPendingInterruptTracker\.update\(\[\]\)/);
assert.match(projects, /function syncProjectConversationApprovalStatuses\(items\)/);
assert.match(projects, /window\.syncProjectConversationApprovalStatuses/);
assert.match(template, /projects\.js\?v=20260812-6/);
assert.match(template, /projects\.js\?v=20260819-1/);
});
test('旧会话首次升级到五分钟默认审批时限,仍允许用户之后主动选择不限时', () => {
+29 -18
View File
@@ -100,6 +100,7 @@ const HITL_LOGS_PAGE_SIZE_KEY = 'cyberstrike_hitl_logs_page_size';
const HITL_PENDING_PAGE_SIZE_KEY = 'cyberstrike_hitl_pending_page_size';
const HITL_TIMEOUT_DEFAULT_MIGRATION_PREFIX = 'cyberstrike-hitl-timeout-default-v1:';
const HITL_PAGE_SIZE_OPTIONS = [10, 20, 50, 100];
const hitlConversationConfigSaveQueues = new Map();
function hitlPaginationT(key, opts, fallback) {
if (typeof window.t === 'function') {
@@ -495,29 +496,39 @@ async function saveHitlPageWhitelist() {
async function saveHitlConversationConfig(conversationId, config) {
if (!conversationId || !config) return false;
const normalizedConversationId = String(conversationId).trim();
const mode = hitlModeNormalize(config.mode || 'off');
const enabled = typeof config.enabled === 'boolean' ? config.enabled : (mode !== 'off');
const sensitiveTools = hitlSensitiveToolsToArray(config);
const timeoutSeconds = normalizeHitlTimeoutSeconds(config.timeoutSeconds, 0);
const reviewer = hitlReviewerNormalize(config.reviewer || 'human');
const resp = await hitlApiFetch('/api/hitl/config', {
method: 'PUT',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
conversationId: conversationId,
enabled: enabled,
mode: mode,
reviewer: reviewer,
sensitiveTools: sensitiveTools,
timeoutSeconds: timeoutSeconds
})
const previous = hitlConversationConfigSaveQueues.get(normalizedConversationId) || Promise.resolve();
const queued = previous.catch(function () {}).then(async function () {
const resp = await hitlApiFetch('/api/hitl/config', {
method: 'PUT',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
conversationId: normalizedConversationId,
enabled: enabled,
mode: mode,
reviewer: reviewer,
sensitiveTools: sensitiveTools,
timeoutSeconds: timeoutSeconds
})
});
if (!resp.ok) {
const msg = await readHitlApiError(resp);
throw new Error(msg || ('HTTP ' + resp.status));
}
return true;
});
hitlConversationConfigSaveQueues.set(normalizedConversationId, queued);
return queued.finally(function () {
if (hitlConversationConfigSaveQueues.get(normalizedConversationId) === queued) {
hitlConversationConfigSaveQueues.delete(normalizedConversationId);
}
});
if (!resp.ok) {
const msg = await readHitlApiError(resp);
throw new Error(msg || ('HTTP ' + resp.status));
}
return true;
}
async function syncHitlConfigFromServer(conversationId) {
@@ -1809,7 +1820,7 @@ document.addEventListener('DOMContentLoaded', function () {
if (typeof window.bindHitlReviewerToggleListeners === 'function') {
window.bindHitlReviewerToggleListeners();
}
initHitlDefaultReviewerFromServer();
window.csaiHitlDefaultReviewerReady = initHitlDefaultReviewerFromServer();
setTimeout(reconcileHitlUiState, 0);
});
+311 -14
View File
@@ -6,6 +6,7 @@ const ACTIVE_TASK_REFRESH_INTERVAL = 2000; // 运行态与审批态需要及时
const TASK_FINAL_STATUSES = new Set(['failed', 'timeout', 'cancelled', 'completed']);
const hitlInterruptToolItemMap = new Map();
let activeTasksLoadPromise = null;
let activeTasksVisualSignature = '';
const CHAT_TASK_SYNC_CHANNEL_NAME = 'cyberstrike-chat-task-sync-v1';
let chatTaskSyncChannel = null;
let visibleConversationReplaySyncPromise = null;
@@ -1432,7 +1433,7 @@ async function submitUserInterruptHardCancel() {
const { progressId, conversationId } = userInterruptModalPending;
closeUserInterruptModal();
if (progressId) {
await performHardCancelProgressTask(progressId);
await performHardCancelProgressTask(progressId, conversationId);
return;
}
if (!conversationId) {
@@ -1448,11 +1449,12 @@ async function submitUserInterruptHardCancel() {
}
/** 彻底停止任务(原「停止任务」行为) */
async function performHardCancelProgressTask(progressId) {
async function performHardCancelProgressTask(progressId, conversationId = '') {
const state = progressTaskState.get(progressId);
const stopBtn = document.getElementById(`${progressId}-stop-btn`);
const targetConversationId = String(conversationId || (state && state.conversationId) || '').trim();
if (!state || !state.conversationId) {
if (!targetConversationId) {
if (stopBtn) {
stopBtn.disabled = true;
setTimeout(() => {
@@ -1463,7 +1465,7 @@ async function performHardCancelProgressTask(progressId) {
return;
}
if (state.cancelling) {
if (state && state.cancelling) {
return;
}
@@ -1474,7 +1476,7 @@ async function performHardCancelProgressTask(progressId) {
}
try {
await requestCancel(state.conversationId);
await requestCancel(targetConversationId);
loadActiveTasks();
} catch (error) {
console.error('取消任务失败:', error);
@@ -1611,6 +1613,9 @@ function toggleProgressDetails(progressId) {
timeline.classList.add('expanded');
toggleBtns.forEach((btn) => { btn.textContent = collapseT; });
}
if (typeof updateProcessDetailsReturnLatestControl === 'function') {
updateProcessDetailsReturnLatestControl(timeline);
}
syncProgressElapsedSummary(progressId);
}
@@ -1800,12 +1805,170 @@ function integrateProgressToMCPSection(progressId, assistantMessageId, mcpExecut
const PROCESS_DETAILS_PAGE_SIZE = 50;
const processDetailsAutoLoadObservers = new WeakMap();
const processDetailsReturnLatestControls = new WeakMap();
const processDetailsLatestFollowStates = new Map();
const PROCESS_DETAILS_RESTORE_FOLLOW_MS = 6000;
const PROCESS_DETAILS_FOLLOW_SCROLLBAR_GUTTER_PX = 18;
// 只有用户真正滚到底部才恢复自动跟随;保留 2px 兼容亚像素滚动。
const PROCESS_DETAILS_FOLLOW_RESUME_THRESHOLD_PX = 2;
function processDetailsReturnLatestLabel() {
if (typeof window.t !== 'function') return '回到最新迭代';
const value = window.t('chat.returnToLatestProcessDetail');
return value && value !== 'chat.returnToLatestProcessDetail' ? value : '回到最新迭代';
}
function processDetailsDistanceFromLatest(timeline) {
if (!timeline) return 0;
return Math.max(0, timeline.scrollHeight - timeline.clientHeight - timeline.scrollTop);
}
function isProcessDetailsTimelineStreaming(container) {
if (!container) return false;
if (container.classList && container.classList.contains('is-streaming')) return true;
return !!(container.closest && container.closest('.progress-container.is-streaming, .process-details-container.is-streaming'));
}
function getProcessDetailsLatestFollowStateForTimeline(timeline) {
let matched = null;
processDetailsLatestFollowStates.forEach(function (state) {
if (!matched && state && state.timeline === timeline && !state.stopped) {
matched = state;
}
});
return matched;
}
function updateProcessDetailsReturnLatestControl(timeline) {
const state = processDetailsReturnLatestControls.get(timeline);
if (!state || !state.button) return false;
const scrollable = timeline.scrollHeight > timeline.clientHeight + 2;
const awayFromLatest = processDetailsDistanceFromLatest(timeline) > PROCESS_DETAILS_FOLLOW_RESUME_THRESHOLD_PX;
const expanded = timeline.classList && timeline.classList.contains('expanded');
const followState = getProcessDetailsLatestFollowStateForTimeline(timeline);
// 运行中粘底会在 MutationObserver 与下一帧滚底之间短暂离底;
// 只要用户还没主动上滑 detached,就不要把“回到最新迭代”按钮闪出来。
const followingLatest = !!(followState && !followState.detached);
const shouldShow = !followingLatest && expanded && scrollable && awayFromLatest;
const label = processDetailsReturnLatestLabel();
state.button.hidden = !shouldShow;
state.button.title = label;
state.button.setAttribute('aria-label', label);
state.button.classList.toggle('has-pending-new', shouldShow && state.hasPendingNewBelow);
state.button.classList.toggle('is-streaming', shouldShow && isProcessDetailsTimelineStreaming(state.container));
return shouldShow;
}
function syncProcessDetailsLatestFollowAfterManualReturn(timeline) {
processDetailsLatestFollowStates.forEach(function (state) {
if (!state || state.timeline !== timeline) return;
state.detached = false;
state.hasPendingNewBelow = false;
state.lastScrollTop = timeline.scrollTop;
if (!state.stopped && typeof state.scheduleFollowLatest === 'function') {
state.scheduleFollowLatest();
}
});
}
function markProcessDetailsReturnLatestPending(timeline) {
const state = processDetailsReturnLatestControls.get(timeline);
if (!state) return false;
if (processDetailsDistanceFromLatest(timeline) > PROCESS_DETAILS_FOLLOW_RESUME_THRESHOLD_PX) {
state.hasPendingNewBelow = true;
}
updateProcessDetailsReturnLatestControl(timeline);
return true;
}
function ensureProcessDetailsReturnLatestControl(timeline) {
if (!timeline || timeline.nodeType !== 1) return false;
let state = processDetailsReturnLatestControls.get(timeline);
if (state) {
updateProcessDetailsReturnLatestControl(timeline);
return true;
}
const container = timeline.closest
? timeline.closest('.progress-container, .process-details-container')
: null;
const host = (container && container.querySelector && container.querySelector('.process-details-content')) || container || timeline.parentElement;
if (!host) return false;
host.classList.add('process-details-return-latest-host');
const button = document.createElement('button');
button.type = 'button';
button.className = 'process-details-return-latest';
button.hidden = true;
button.innerHTML = '<svg viewBox="0 0 20 20" fill="none" aria-hidden="true"><path d="M5.5 7.5 10 12l4.5-4.5" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>';
host.appendChild(button);
state = {
container: container || host,
host: host,
button: button,
hasPendingNewBelow: false,
resizeObserver: null,
mutationObserver: null
};
processDetailsReturnLatestControls.set(timeline, state);
const clearPointer = function (event) {
if (event) event.stopPropagation();
};
const scrollToLatest = function (event) {
if (event) {
event.preventDefault();
event.stopPropagation();
}
const targetTop = Math.max(0, timeline.scrollHeight - timeline.clientHeight);
if (typeof timeline.scrollTo === 'function') {
timeline.scrollTo({ top: targetTop, behavior: 'smooth' });
} else {
timeline.scrollTop = targetTop;
}
state.hasPendingNewBelow = false;
syncProcessDetailsLatestFollowAfterManualReturn(timeline);
updateProcessDetailsReturnLatestControl(timeline);
setTimeout(function () {
if (timeline.isConnected) {
updateProcessDetailsReturnLatestControl(timeline);
}
}, 360);
button.blur();
};
const onScroll = function () {
if (processDetailsDistanceFromLatest(timeline) <= PROCESS_DETAILS_FOLLOW_RESUME_THRESHOLD_PX) {
state.hasPendingNewBelow = false;
}
updateProcessDetailsReturnLatestControl(timeline);
};
button.addEventListener('pointerdown', clearPointer);
button.addEventListener('mousedown', clearPointer);
button.addEventListener('touchstart', clearPointer, { passive: true });
button.addEventListener('click', scrollToLatest);
timeline.addEventListener('scroll', onScroll, { passive: true });
if (typeof ResizeObserver === 'function') {
state.resizeObserver = new ResizeObserver(function () {
updateProcessDetailsReturnLatestControl(timeline);
});
state.resizeObserver.observe(timeline);
}
if (typeof MutationObserver === 'function') {
state.mutationObserver = new MutationObserver(function () {
updateProcessDetailsReturnLatestControl(timeline);
});
state.mutationObserver.observe(timeline, { childList: true, subtree: true, characterData: true });
}
updateProcessDetailsReturnLatestControl(timeline);
return true;
}
window.ensureProcessDetailsReturnLatestControl = ensureProcessDetailsReturnLatestControl;
window.updateProcessDetailsReturnLatestControl = updateProcessDetailsReturnLatestControl;
window.markProcessDetailsReturnLatestPending = markProcessDetailsReturnLatestPending;
function processDetailsContinuousLabel(kind) {
if (kind === 'older') {
return typeof window.t === 'function' ? window.t('chat.loadingEarlierDetails') : '正在加载更早记录…';
@@ -1943,6 +2106,11 @@ function scrollProcessDetailsToLatest(assistantMessageId, smooth = true) {
} else {
timeline.scrollTop = targetTop;
}
const state = processDetailsReturnLatestControls.get(timeline);
if (state) {
state.hasPendingNewBelow = false;
updateProcessDetailsReturnLatestControl(timeline);
}
return true;
}
@@ -1985,6 +2153,7 @@ function startProcessDetailsLatestFollow(assistantMessageId, options) {
if (!container || !timeline) return false;
stopProcessDetailsLatestFollow(key);
ensureProcessDetailsReturnLatestControl(timeline);
const persistent = !!opts.persistent;
const durationMs = Number.isFinite(Number(opts.durationMs))
? Math.max(250, Number(opts.durationMs))
@@ -1993,6 +2162,8 @@ function startProcessDetailsLatestFollow(assistantMessageId, options) {
stopped: false,
detached: false,
persistent: persistent,
timeline: timeline,
hasPendingNewBelow: false,
lastScrollTop: timeline.scrollTop,
userScrollIntentUntil: 0,
touchLastY: null,
@@ -2010,6 +2181,7 @@ function startProcessDetailsLatestFollow(assistantMessageId, options) {
cancelAnimationFrame(state.rafId);
state.rafId = 0;
}
updateProcessDetailsReturnLatestControl(timeline);
};
const onWheel = function (event) {
if (!event) return;
@@ -2071,9 +2243,14 @@ function startProcessDetailsLatestFollow(assistantMessageId, options) {
distance <= PROCESS_DETAILS_FOLLOW_RESUME_THRESHOLD_PX
) {
state.detached = false;
state.hasPendingNewBelow = false;
scheduleFollowLatest();
}
if (distance <= PROCESS_DETAILS_FOLLOW_RESUME_THRESHOLD_PX) {
state.hasPendingNewBelow = false;
}
state.lastScrollTop = currentTop;
updateProcessDetailsReturnLatestControl(timeline);
};
timeline.addEventListener('wheel', onWheel, { passive: true });
timeline.addEventListener('pointerdown', onPointerDown, { passive: true });
@@ -2102,7 +2279,9 @@ function startProcessDetailsLatestFollow(assistantMessageId, options) {
} else {
scrollProcessDetailsToLatest(String(assistantMessageId || ''), false);
}
state.hasPendingNewBelow = false;
state.lastScrollTop = timeline.scrollTop;
updateProcessDetailsReturnLatestControl(timeline);
// 内层迭代区与外层对话区分别粘底;用户上滑内层后,本状态会暂停两者的自动跟随。
if (window.CyberStrikeChatScroll &&
typeof window.CyberStrikeChatScroll.scrollIfPinned === 'function') {
@@ -2110,9 +2289,15 @@ function startProcessDetailsLatestFollow(assistantMessageId, options) {
}
};
const scheduleFollowLatest = function () {
if (state.stopped || state.detached || state.rafId) return;
if (state.stopped || state.rafId) return;
if (state.detached) {
state.hasPendingNewBelow = true;
markProcessDetailsReturnLatestPending(timeline);
return;
}
state.rafId = requestAnimationFrame(followLatest);
};
state.scheduleFollowLatest = scheduleFollowLatest;
state.observer = new MutationObserver(scheduleFollowLatest);
state.observer.observe(timeline, { childList: true, subtree: true, characterData: true });
@@ -2416,6 +2601,9 @@ function toggleProcessDetails(progressId, assistantMessageId) {
} else if (timeline) {
setExpanded(!timeline.classList.contains('expanded'));
}
if (timeline && typeof updateProcessDetailsReturnLatestControl === 'function') {
updateProcessDetailsReturnLatestControl(timeline);
}
if (typeof window.syncAssistantTurnSummary === 'function') {
window.syncAssistantTurnSummary(document.getElementById(assistantMessageId));
}
@@ -4096,7 +4284,9 @@ function describeHitlApprovalRequest(data) {
if (isBrowser) {
kind = 'browser';
question = url
? hitlApprovalTemplate('hitl.requestVisitUrl', '允许 CyberStrikeAI 访问 {{url}}', { url: url })
? (url.length > 160
? hitlApprovalTranslate('hitl.requestVisitLongUrl', '允许 CyberStrikeAI 访问此地址?')
: hitlApprovalTemplate('hitl.requestVisitUrl', '允许 CyberStrikeAI 访问 {{url}}', { url: url }))
: hitlApprovalTranslate('hitl.requestBrowser', '允许 CyberStrikeAI 使用浏览器?');
primary = url;
} else if (isCommand) {
@@ -4106,7 +4296,9 @@ function describeHitlApprovalRequest(data) {
} else if (isFile) {
kind = 'file';
question = path
? hitlApprovalTemplate('hitl.requestFile', '允许 CyberStrikeAI 修改 {{path}}', { path: path })
? (path.length > 160
? hitlApprovalTranslate('hitl.requestModifyLongPath', '允许 CyberStrikeAI 修改此文件?')
: hitlApprovalTemplate('hitl.requestFile', '允许 CyberStrikeAI 修改 {{path}}', { path: path }))
: hitlApprovalTranslate('hitl.requestFiles', '允许 CyberStrikeAI 修改文件?');
primary = path;
}
@@ -4649,6 +4841,20 @@ function clearChatHitlApprovalDock(interruptId) {
if (container) container.classList.remove('has-hitl-approval');
}
function wrapChatHitlApprovalScrollRegion(dock) {
if (!dock) return;
const actions = Array.prototype.find.call(dock.children, function (child) {
return child.classList && child.classList.contains('hitl-inline-actions');
});
if (!actions) return;
const scrollRegion = document.createElement('div');
scrollRegion.className = 'chat-hitl-approval-scroll-region';
while (dock.firstChild && dock.firstChild !== actions) {
scrollRegion.appendChild(dock.firstChild);
}
dock.insertBefore(scrollRegion, actions);
}
function renderChatHitlApprovalDock(data) {
const dock = document.getElementById('chat-hitl-approval-dock');
if (!dock || !data || !data.interruptId) return false;
@@ -4669,6 +4875,7 @@ function renderChatHitlApprovalDock(data) {
allowEdit: allowEdit,
argsJSON: JSON.stringify(hitlApprovalArguments(data), null, 2)
});
wrapChatHitlApprovalScrollRegion(dock);
dock.hidden = false;
const container = dock.closest('.chat-input-container');
if (container) container.classList.add('has-hitl-approval');
@@ -5633,6 +5840,13 @@ function parseToolCallArgsFromData(data) {
return args;
}
function toolCallArgsEmpty(args) {
if (args == null) return true;
if (typeof args !== 'object') return false;
if (Array.isArray(args)) return args.length === 0;
return Object.keys(args).length === 0;
}
function formatToolCallTimelineTitle(toolName, index, total) {
const name = toolName || (typeof window.t === 'function' ? window.t('chat.unknownTool') : '未知工具');
const idx = index || 0;
@@ -5924,6 +6138,10 @@ function mergeToolResultIntoCallItem(item, data, options) {
if (item.classList.contains('tool-call-collapsible')) {
const state = toolCallDetailStateByItemId.get(item.id) || {};
const resultArgs = parseToolCallArgsFromData(data);
if (toolCallArgsEmpty(state.args) && !toolCallArgsEmpty(resultArgs)) {
state.args = resultArgs;
}
state.resultData = data;
state.rawText = text;
state.resultDetailId = data.processDetailId || state.resultDetailId || '';
@@ -6028,6 +6246,13 @@ function coalesceProcessDetailsToolPairs(details) {
function absorbResult(targetDetail, resultDetail) {
const rd = resultDetail.data || {};
targetDetail.data = targetDetail.data || {};
if (toolCallArgsEmpty(parseToolCallArgsFromData(targetDetail.data))) {
const resultArgs = parseToolCallArgsFromData(rd);
if (!toolCallArgsEmpty(resultArgs)) {
targetDetail.data.argumentsObj = resultArgs;
targetDetail.data.arguments = JSON.stringify(resultArgs);
}
}
targetDetail.data._mergedResult = Object.assign({}, rd);
if (resultDetail.id) {
targetDetail.data._mergedResultDetailId = resultDetail.id;
@@ -6051,21 +6276,44 @@ function coalesceProcessDetailsToolPairs(details) {
createdAt: detail.createdAt,
data: Object.assign({}, data)
};
if (id) callsById.set(id, copy);
if (id) {
let list = callsById.get(id);
if (!list) {
list = [];
callsById.set(id, list);
}
list.push(copy);
}
fifoCalls.push(copy);
out.push(copy);
} else if (et === 'tool_result') {
} else if (et === 'tool_result') {
let target = null;
if (id && callsById.has(id)) {
target = callsById.get(id);
} else {
const list = callsById.get(id);
while (list.length) {
const candidate = list.shift();
if (candidate && candidate.data && !candidate.data._mergedResult) {
target = candidate;
break;
}
}
}
if (!target) {
const resultName = String(data.toolName || '').trim().toLowerCase();
let anyUnmatched = null;
for (let j = 0; j < fifoCalls.length; j++) {
const c = fifoCalls[j];
if (c && c.data && !c.data._mergedResult) {
if (!c || !c.data || c.data._mergedResult) continue;
if (!anyUnmatched) anyUnmatched = c;
const callName = String(c.data.toolName || '').trim().toLowerCase();
if (!resultName || !callName || callName === resultName) {
target = c;
break;
}
}
if (!target && id) {
target = anyUnmatched;
}
}
if (target) {
// agentFacing 或较新的 tool_result 覆盖旧合并(历史数据可能含 reduction 前全量正文)
@@ -6612,6 +6860,17 @@ function syncVisibleConversationTaskReplay(tasks) {
visibleConversationReplaySyncId = conversationId;
visibleConversationReplaySyncPromise = Promise.resolve()
.then(async function () {
// 用户可能在任务刷新排队后、此微任务执行前切换了会话。
// 不允许旧会话补流取消或覆盖用户刚发起的目标会话加载。
if (String(window.currentConversationId || '') !== conversationId) {
return false;
}
if (
typeof window.isChatConversationLoadPending === 'function' &&
window.isChatConversationLoadPending(conversationId)
) {
return false;
}
// 另一标签页已新增用户消息和运行中助手轮次;先重载轻量历史,避免把补流挂到旧助手消息上。
if (typeof window.loadConversation === 'function') {
await window.loadConversation(conversationId);
@@ -6647,6 +6906,33 @@ function getActiveTaskDisplayName(task) {
return message || unnamedTaskText;
}
function stableActiveTasksForDisplay(tasks) {
return (Array.isArray(tasks) ? tasks : []).slice().sort(function (a, b) {
const aStartedAt = Date.parse(a && a.startedAt ? a.startedAt : '');
const bStartedAt = Date.parse(b && b.startedAt ? b.startedAt : '');
const aTime = Number.isFinite(aStartedAt) ? aStartedAt : Number.MAX_SAFE_INTEGER;
const bTime = Number.isFinite(bStartedAt) ? bStartedAt : Number.MAX_SAFE_INTEGER;
if (aTime !== bTime) return aTime - bTime;
return String(a && a.conversationId || '').localeCompare(String(b && b.conversationId || ''));
});
}
function activeTasksRenderSignature(tasks) {
const language = typeof i18next !== 'undefined' && i18next.language ? i18next.language : getCurrentTimeLocale();
return JSON.stringify({
language: language,
tasks: (Array.isArray(tasks) ? tasks : []).map(function (task) {
return {
conversationId: task && task.conversationId || '',
title: task && task.title || '',
message: task && task.message || '',
startedAt: task && task.startedAt || '',
status: task && task.status || ''
};
})
});
}
function updateActiveTaskConversationTitle(conversationId, newTitle) {
const bar = document.getElementById('active-tasks-bar');
if (!bar || !conversationId) return;
@@ -6663,7 +6949,7 @@ function renderActiveTasks(tasks) {
const bar = document.getElementById('active-tasks-bar');
if (!bar) return;
const normalizedTasks = Array.isArray(tasks) ? tasks : [];
const normalizedTasks = stableActiveTasksForDisplay(tasks);
conversationExecutionTracker.update(normalizedTasks);
window.dispatchEvent(new CustomEvent('conversation-task-state-changed', {
detail: { tasks: normalizedTasks }
@@ -6684,10 +6970,20 @@ function renderActiveTasks(tasks) {
if (normalizedTasks.length === 0) {
bar.style.display = 'none';
bar.innerHTML = '';
activeTasksVisualSignature = '';
return;
}
bar.style.display = 'flex';
const nextVisualSignature = activeTasksRenderSignature(normalizedTasks);
if (
nextVisualSignature === activeTasksVisualSignature &&
bar.querySelectorAll('.active-task-item').length === normalizedTasks.length
) {
return;
}
const previousScrollLeft = bar.scrollLeft;
activeTasksVisualSignature = nextVisualSignature;
bar.innerHTML = '';
function openActiveTaskConversation(conversationId) {
@@ -6766,6 +7062,7 @@ function renderActiveTasks(tasks) {
bar.appendChild(item);
});
bar.scrollLeft = previousScrollLeft;
}
function reconcileHitlApprovalStateWithActiveTasks(tasks) {
+7 -5
View File
@@ -3352,15 +3352,17 @@ function appendChatProjectConversationItem(list, conversation, project) {
});
button.appendChild(label);
button.addEventListener('click', async () => {
button.addEventListener('click', async (event) => {
const targetConversationId = String(event.currentTarget && event.currentTarget.dataset.conversationId || '').trim();
if (!targetConversationId) return;
projectConversationPreviewSuppressedUntil = Date.now() + 700;
hideProjectConversationPreview(true);
selectChatProjectConversationItem(conversation.id);
selectChatProjectConversationItem(targetConversationId);
if (typeof window.loadConversation === 'function') {
await window.loadConversation(conversation.id);
await window.loadConversation(targetConversationId);
}
if (window.currentConversationId === conversation.id && completed) {
markProjectConversationViewed(conversation.id, completed.completedAt);
if (window.currentConversationId === targetConversationId && completed) {
markProjectConversationViewed(targetConversationId, completed.completedAt);
renderChatProjectFolders(projectsCacheAll);
}
});
+9
View File
@@ -18,6 +18,12 @@ function buildHashForPage(pageId) {
let chatConversationFromHashSeq = 0;
function cancelScheduledChatConversationFromHash() {
chatConversationFromHashSeq++;
setChatConversationRestorePending('', false);
}
window.cancelScheduledChatConversationFromHash = cancelScheduledChatConversationFromHash;
function setChatConversationRestorePending(conversationId, pending) {
const container = document.querySelector('.chat-container');
if (!container) return;
@@ -123,6 +129,9 @@ function switchPage(pageId) {
if (!targetPage) return;
if (pageId !== 'chat') {
setChatConversationRestorePending('', false);
if (currentPage === 'chat' && typeof window.abandonChatConversationForPageNavigation === 'function') {
window.abandonChatConversationForPageNavigation();
}
}
// 导航点击会修改 hash,随后浏览器还会触发 hashchange。
@@ -0,0 +1,25 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
test('tool_result 合并回工具调用卡时会回填空参数', () => {
const source = fs.readFileSync('web/static/js/monitor.js', 'utf8');
assert.match(source, /function toolCallArgsEmpty\(args\)/);
assert.match(source, /const resultArgs = parseToolCallArgsFromData\(data\);/);
assert.match(source, /toolCallArgsEmpty\(state\.args\) && !toolCallArgsEmpty\(resultArgs\)/);
assert.match(source, /state\.args = resultArgs;/);
});
test('历史 process_details 合并时也会从 tool_result 补齐 tool_call 参数', () => {
const source = fs.readFileSync('web/static/js/monitor.js', 'utf8');
assert.match(source, /function absorbResult\(targetDetail, resultDetail\)/);
assert.match(source, /targetDetail\.data\.argumentsObj = resultArgs;/);
assert.match(source, /targetDetail\.data\.arguments = JSON\.stringify\(resultArgs\);/);
});
test('同一 toolCallId 的多次调用按 FIFO 合并结果,避免后一次覆盖导致结果记录缺失', () => {
const source = fs.readFileSync('web/static/js/monitor.js', 'utf8');
assert.match(source, /list\.push\(copy\)/);
assert.match(source, /const candidate = list\.shift\(\)/);
assert.match(source, /callName === resultName/);
});
+45 -42
View File
@@ -31,11 +31,11 @@
}
})();
</script>
<link rel="stylesheet" href="/static/css/style.css?v=20260813-6">
<link rel="stylesheet" href="/static/css/style.css?v=20260819-4">
<link rel="stylesheet" href="/static/css/chat-plan-progress.css?v=20260813-4">
<link rel="stylesheet" href="/static/css/c2.css">
<link rel="stylesheet" href="/static/vendor/xterm.css">
<script src="/static/js/router.js?v=20260813-2"></script>
<script src="/static/js/router.js?v=20260819-3"></script>
</head>
<body>
<div id="login-overlay" class="login-overlay" style="display: none;">
@@ -1025,39 +1025,8 @@
</span>
</button>
<div id="conversation-reasoning-body" class="conversation-reasoning-body" role="region">
<p class="chat-reasoning-panel-hint" data-i18n="chat.sessionSettingsHint">AI 通道、推理设置与人机协同只影响后续消息。</p>
<p class="chat-reasoning-panel-hint" data-i18n="chat.sessionSettingsHint">人机协同设置只影响后续消息。</p>
<div class="chat-reasoning-fields">
<div class="session-settings-group session-settings-group-ai">
<div class="session-settings-group-title">AI</div>
<div class="chat-reasoning-field">
<label class="chat-reasoning-field-label" for="chat-ai-channel-select"><span data-i18n="chat.aiChannelLabel">AI 通道</span></label>
<select id="chat-ai-channel-select" class="chat-reasoning-select" onchange="persistChatAIChannelPref()">
<option value="" data-i18n="chat.aiChannelDefault">跟随默认通道</option>
</select>
</div>
<div class="session-settings-inline">
<div class="chat-reasoning-field">
<label class="chat-reasoning-field-label" for="chat-reasoning-mode"><span data-i18n="chat.reasoningModeLabel">模式</span></label>
<select id="chat-reasoning-mode" class="chat-reasoning-select" onchange="persistChatReasoningPrefs()">
<option value="default" data-i18n="chat.reasoningModeDefault">跟随系统</option>
<option value="off" data-i18n="chat.reasoningModeOff">关闭</option>
<option value="on" data-i18n="chat.reasoningModeOn">开启</option>
<option value="auto" data-i18n="chat.reasoningModeAuto">自动</option>
</select>
</div>
<div class="chat-reasoning-field">
<label class="chat-reasoning-field-label" for="chat-reasoning-effort"><span data-i18n="chat.reasoningEffortLabel">推理强度</span></label>
<select id="chat-reasoning-effort" class="chat-reasoning-select" onchange="persistChatReasoningPrefs()">
<option value="" data-i18n="chat.reasoningEffortUnset">不指定</option>
<option value="low">low</option>
<option value="medium">medium</option>
<option value="high">high</option>
<option value="xhigh">xhigh</option>
<option value="max">max</option>
</select>
</div>
</div>
</div>
<div class="chat-reasoning-field session-hitl-field session-settings-group">
<div class="session-settings-group-title" data-i18n="chat.hitlTitle">人机协同</div>
<div id="hitl-apply-feedback" class="hitl-apply-feedback" role="status" aria-live="polite"></div>
@@ -1332,16 +1301,42 @@
</button>
</div>
<div class="chat-composer-footer-trailing">
<div class="chat-ai-session-state" hidden>
<select id="chat-ai-channel-select" onchange="persistChatAIChannelPref()">
<option value="" data-i18n="chat.aiChannelDefault">跟随默认通道</option>
</select>
<select id="chat-reasoning-mode" onchange="persistChatReasoningPrefs()">
<option value="default" data-i18n="chat.reasoningModeDefault">跟随系统</option>
<option value="off" data-i18n="chat.reasoningModeOff">关闭</option>
<option value="on" data-i18n="chat.reasoningModeOn">开启</option>
<option value="auto" data-i18n="chat.reasoningModeAuto">自动</option>
</select>
<select id="chat-reasoning-effort" onchange="persistChatReasoningPrefs()">
<option value="" data-i18n="chat.reasoningEffortUnset">不指定</option>
<option value="low">low</option>
<option value="medium">medium</option>
<option value="high">high</option>
<option value="xhigh">xhigh</option>
<option value="max">max</option>
</select>
</div>
<div id="chat-model-shortcut-wrap" class="chat-model-shortcut-wrap">
<button type="button" id="chat-model-shortcut" class="chat-session-shortcut chat-session-meta" onclick="openChatSystemModelPicker(event)" data-i18n="chat.modelSettingsAria" data-i18n-attr="aria-label,title" data-i18n-skip-text="true" aria-label="选择模型与推理强度" title="选择模型与推理强度" aria-haspopup="dialog" aria-expanded="false" aria-controls="chat-system-model-menu">
<button type="button" id="chat-model-shortcut" class="chat-session-shortcut chat-session-meta" onclick="openChatSystemModelPicker(event)" data-i18n="chat.modelSettingsAria" data-i18n-attr="aria-label,title" data-i18n-skip-text="true" aria-label="选择 AI 通道、模型与推理设置" title="选择 AI 通道、模型与推理设置" aria-haspopup="dialog" aria-expanded="false" aria-controls="chat-system-model-menu">
<span id="chat-model-shortcut-text">默认通道</span>
<span id="chat-model-shortcut-effort" class="chat-model-shortcut-effort">不指定</span>
<svg class="chat-system-model-caret" width="12" height="12" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M6 9l6 6 6-6" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
<div id="chat-system-model-menu" class="chat-system-model-menu" role="dialog" aria-label="模型与推理强度" hidden>
<div id="chat-system-model-menu" class="chat-system-model-menu" role="dialog" aria-label="AI 通道、模型与推理设置" hidden>
<div id="chat-system-model-main" class="chat-system-model-main">
<button type="button" class="chat-system-model-setting-row" onclick="openChatSystemModelView('channel', event)">
<span class="chat-system-model-setting-label" data-i18n="chat.aiChannelLabel">AI 通道</span>
<span class="chat-system-model-setting-value">
<span id="chat-system-model-channel-value">默认通道</span>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M9 6l6 6-6 6" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>
</span>
</button>
<button type="button" class="chat-system-model-setting-row" onclick="openChatSystemModelView('model', event)">
<span class="chat-system-model-setting-label" data-i18n="chat.systemModelField">模型</span>
<span class="chat-system-model-setting-value">
@@ -1349,6 +1344,14 @@
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M9 6l6 6-6 6" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>
</span>
</button>
<div class="chat-system-model-section-divider" role="separator"></div>
<button type="button" class="chat-system-model-setting-row" onclick="openChatSystemModelView('mode', event)">
<span class="chat-system-model-setting-label" data-i18n="chat.reasoningModeLabel">推理模式</span>
<span class="chat-system-model-setting-value">
<span id="chat-system-model-mode-value">跟随系统</span>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M9 6l6 6-6 6" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>
</span>
</button>
<button type="button" class="chat-system-model-setting-row" onclick="openChatSystemModelView('effort', event)">
<span class="chat-system-model-setting-label" data-i18n="chat.reasoningEffortLabel">推理强度</span>
<span class="chat-system-model-setting-value">
@@ -4017,7 +4020,7 @@
<div class="settings-form">
<div class="form-group">
<label for="zoomeye-base-url">Base URL</label>
<input type="text" id="zoomeye-base-url" placeholder="https://api.zoomeye.org/v2/search(可选)" />
<input type="text" id="zoomeye-base-url" placeholder="https://api.zoomeye.ai/v2/search(可选)" />
<small class="form-hint">留空则使用默认地址。</small>
</div>
<div class="form-group">
@@ -4034,7 +4037,7 @@
<div class="settings-form">
<div class="form-group">
<label for="quake-base-url">Base URL</label>
<input type="text" id="quake-base-url" placeholder="https://quake.360.cn/api/v3/search/quake_service(可选)" />
<input type="text" id="quake-base-url" placeholder="https://quake.360.net/api/v3/search/quake_service(可选)" />
<small class="form-hint">留空则使用默认地址。</small>
</div>
<div class="form-group">
@@ -6841,10 +6844,10 @@
<script src="/static/js/agents.js"></script>
<script src="/static/js/dashboard.js"></script>
<script src="/static/js/chat-scroll.js?v=20260815-1"></script>
<script src="/static/js/monitor.js?v=20260815-1"></script>
<script src="/static/js/chat.js?v=20260813-4"></script>
<script src="/static/js/monitor.js?v=20260819-3"></script>
<script src="/static/js/chat.js?v=20260819-5"></script>
<script src="/static/js/chat-plan-progress.js?v=20260815-1"></script>
<script src="/static/js/hitl.js?v=20260811-4"></script>
<script src="/static/js/hitl.js?v=20260819-1"></script>
<script src="/static/js/settings.js?v=20260717-1"></script>
<script src="/static/js/audit-datetime-picker.js"></script>
<script src="/static/js/audit.js"></script>
@@ -6855,7 +6858,7 @@
<script src="/static/js/knowledge.js"></script>
<script src="/static/js/skills.js"></script>
<script src="/static/js/fact-graph.js"></script>
<script src="/static/js/projects.js?v=20260812-6"></script>
<script src="/static/js/projects.js?v=20260819-1"></script>
<script src="/static/js/vulnerability.js?v=14"></script>
<script src="/static/js/webshell.js"></script>
<script src="/static/js/chat-files.js"></script>