mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-09-09 11:19:03 +02:00
Compare commits
130
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97d53193d1 | ||
|
|
bec2d2faf1 | ||
|
|
c7cc0bc9da | ||
|
|
24d06c5220 | ||
|
|
ac6e04a94c | ||
|
|
cb58b90083 | ||
|
|
5ccabdc3e4 | ||
|
|
286511d0a8 | ||
|
|
1f66b93866 | ||
|
|
c21ba6c419 | ||
|
|
5720de0d27 | ||
|
|
5aa2c382b0 | ||
|
|
98c380ed5f | ||
|
|
11537713ce | ||
|
|
acc2ebd7e2 | ||
|
|
5eaddd9e8c | ||
|
|
3421d8b047 | ||
|
|
5b000d1e3e | ||
|
|
cecbe5a086 | ||
|
|
c7603c2b66 | ||
|
|
d3bfb1c98e | ||
|
|
55ee5fbac9 | ||
|
|
910d07ea0a | ||
|
|
217f5570c2 | ||
|
|
f30a9de01d | ||
|
|
893f1342ae | ||
|
|
6db0975223 | ||
|
|
b935e1f90f | ||
|
|
3b7fd8d582 | ||
|
|
053c75b2cc | ||
|
|
7823fb6b0c | ||
|
|
9c0621819f | ||
|
|
aa11b32ce5 | ||
|
|
4410cfe826 | ||
|
|
409e75f15a | ||
|
|
6fc0004ac3 | ||
|
|
ee4676a591 | ||
|
|
9f38fda15f | ||
|
|
b07a645d34 | ||
|
|
bbc3395ac5 | ||
|
|
799282d0e7 | ||
|
|
8635bf49dc | ||
|
|
befc8acc3a | ||
|
|
964fbd6095 | ||
|
|
01c0750d98 | ||
|
|
7f8093f8b9 | ||
|
|
f564421b4d | ||
|
|
2910289f0b | ||
|
|
5ce66ee2f8 | ||
|
|
20b6fd241e | ||
|
|
3fab05c3b1 | ||
|
|
8bde9d749a | ||
|
|
9b78297442 | ||
|
|
a6631a5cde | ||
|
|
31f80b2f0c | ||
|
|
70b01206e4 | ||
|
|
4fe6defa28 | ||
|
|
44a578e824 | ||
|
|
e591fe4a9b | ||
|
|
e691c5d9c6 | ||
|
|
20c9e871af | ||
|
|
c477c5507a | ||
|
|
43fe8fa7d6 | ||
|
|
d19ffc9ff4 | ||
|
|
c345880765 | ||
|
|
a1615143be | ||
|
|
7c45011074 | ||
|
|
eb6bab574f | ||
|
|
b170f2c4b1 | ||
|
|
02216a4660 | ||
|
|
a53e4a4a64 | ||
|
|
3bb8ec57fd | ||
|
|
95c8e3b1a2 | ||
|
|
ed47ee202e | ||
|
|
d3714c9913 | ||
|
|
30513dbbfd | ||
|
|
aff9c9301f | ||
|
|
f7ba7070ca | ||
|
|
5d1f5d2886 | ||
|
|
0f92817261 | ||
|
|
2ec5953416 | ||
|
|
b4fe2e795e | ||
|
|
904d860797 | ||
|
|
dc08199af6 | ||
|
|
84e99220ff | ||
|
|
86f1d10a8b | ||
|
|
5c643a1606 | ||
|
|
b9854192c6 | ||
|
|
5a5762e1d1 | ||
|
|
8882d70393 | ||
|
|
5747ebc612 | ||
|
|
a6b3773f00 | ||
|
|
c2b950ad53 | ||
|
|
0283fff743 | ||
|
|
018835d6b8 | ||
|
|
4b7df4e0f3 | ||
|
|
0324b41a01 | ||
|
|
4a19620137 | ||
|
|
f8110413c0 | ||
|
|
59dc7cf858 | ||
|
|
52595e07e5 | ||
|
|
e0965594bb | ||
|
|
9ef8263eaf | ||
|
|
3c54a67416 | ||
|
|
98ca395edd | ||
|
|
c616822cd6 | ||
|
|
ba1796d7ce | ||
|
|
dad14c55c1 | ||
|
|
cc0233d7b4 | ||
|
|
7b6f56e476 | ||
|
|
2522fc6ae2 | ||
|
|
99d7380450 | ||
|
|
837e41459a | ||
|
|
5cbd828cad | ||
|
|
8cb317cbd6 | ||
|
|
94a2ba0406 | ||
|
|
4ee7204509 | ||
|
|
c326adbb66 | ||
|
|
7d1e9bdac4 | ||
|
|
151b445c74 | ||
|
|
af4b25b84e | ||
|
|
f0b1955059 | ||
|
|
f5d580bbf0 | ||
|
|
44d069da2b | ||
|
|
9297e6e6ee | ||
|
|
9bbc28c14a | ||
|
|
4943b9419e | ||
|
|
446ccd3edb | ||
|
|
a00643b9c0 | ||
|
|
5c13819f66 |
+24
-1
@@ -1,30 +1,48 @@
|
|||||||
# Runtime data
|
# Runtime data
|
||||||
data/
|
data/
|
||||||
|
chat_uploads/
|
||||||
*.db
|
*.db
|
||||||
*.db-shm
|
*.db-shm
|
||||||
*.db-wal
|
*.db-wal
|
||||||
|
*.db-journal
|
||||||
*.sqlite
|
*.sqlite
|
||||||
*.sqlite3
|
*.sqlite3
|
||||||
|
|
||||||
# Local configuration and secrets
|
# Local configuration and secrets
|
||||||
config.yaml
|
config.yaml
|
||||||
config.local.yaml
|
config.local.yaml
|
||||||
|
config.yaml.backup
|
||||||
|
*.backup
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
|
!.env.example
|
||||||
*.pem
|
*.pem
|
||||||
*.key
|
*.key
|
||||||
*.crt
|
*.crt
|
||||||
|
|
||||||
|
# Upgrade / settings backups
|
||||||
|
.upgrade-backup/
|
||||||
|
|
||||||
|
# Eino runtime task board (under skills_dir)
|
||||||
|
skills/.eino/
|
||||||
|
|
||||||
# Build outputs
|
# Build outputs
|
||||||
cyberstrike-ai
|
cyberstrike-ai
|
||||||
|
cyberstrike-ai.exe
|
||||||
|
*.test
|
||||||
bin/
|
bin/
|
||||||
dist/
|
dist/
|
||||||
build/
|
build/
|
||||||
|
.build/
|
||||||
|
target/
|
||||||
|
.gradle/
|
||||||
coverage.out
|
coverage.out
|
||||||
coverage.html
|
coverage.html
|
||||||
|
|
||||||
# Logs and temporary files
|
# Logs and temporary files
|
||||||
*.log
|
*.log
|
||||||
|
*.bak
|
||||||
|
*~
|
||||||
tmp/
|
tmp/
|
||||||
temp/
|
temp/
|
||||||
|
|
||||||
@@ -38,7 +56,12 @@ __pycache__/
|
|||||||
# Go
|
# Go
|
||||||
vendor/
|
vendor/
|
||||||
|
|
||||||
# macOS / editors
|
# Node / browser extension
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# macOS / Windows / editors
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
desktop.ini
|
||||||
.idea/
|
.idea/
|
||||||
.vscode/
|
.vscode/
|
||||||
|
|||||||
+91
-8
@@ -4,7 +4,9 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"cyberstrike-ai/internal/app"
|
"cyberstrike-ai/internal/app"
|
||||||
"cyberstrike-ai/internal/config"
|
"cyberstrike-ai/internal/config"
|
||||||
|
"cyberstrike-ai/internal/database"
|
||||||
"cyberstrike-ai/internal/logger"
|
"cyberstrike-ai/internal/logger"
|
||||||
|
"cyberstrike-ai/internal/security"
|
||||||
"cyberstrike-ai/internal/termout"
|
"cyberstrike-ai/internal/termout"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -12,17 +14,21 @@ import (
|
|||||||
"os/signal"
|
"os/signal"
|
||||||
"strings"
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"golang.org/x/term"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
var configPath = flag.String("config", "config.yaml", "配置文件路径")
|
var configPath = flag.String("config", "config.yaml", "Path to the configuration file")
|
||||||
var httpsBootstrap = flag.Bool("https", false, "启用主站 HTTPS:未配置 tls_cert_path/tls_key_path 时使用内存自签证书(本地测试);与 run.sh 默认行为一致")
|
var httpsBootstrap = flag.Bool("https", false, "Enable HTTPS for the main site; uses an in-memory self-signed certificate when no cert/key is configured")
|
||||||
var httpBootstrap = flag.Bool("http", false, "强制主站使用明文 HTTP:覆盖配置文件中的 tls_enabled/tls_auto_self_sign/tls_cert_path/tls_key_path")
|
var httpBootstrap = flag.Bool("http", false, "Force plain HTTP for the main site, overriding TLS settings in the configuration file")
|
||||||
|
var resetAdminPassword = flag.Bool("reset-admin-password", false, "Interactively reset the built-in admin password and exit")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
// 环境变量兼容(便于 systemd/docker 等不传参场景)
|
// 环境变量兼容(便于 systemd/docker 等不传参场景)
|
||||||
if *httpsBootstrap && *httpBootstrap {
|
if *httpsBootstrap && *httpBootstrap {
|
||||||
fmt.Fprintln(os.Stderr, "--http 与 --https 不能同时使用")
|
fmt.Fprintln(os.Stderr, "--http and --https cannot be used together")
|
||||||
os.Exit(2)
|
os.Exit(2)
|
||||||
}
|
}
|
||||||
if !*httpsBootstrap && !*httpBootstrap {
|
if !*httpsBootstrap && !*httpBootstrap {
|
||||||
@@ -38,24 +44,32 @@ func main() {
|
|||||||
cp = "config.yaml"
|
cp = "config.yaml"
|
||||||
}
|
}
|
||||||
if strings.HasPrefix(cp, "-") {
|
if strings.HasPrefix(cp, "-") {
|
||||||
fmt.Fprintf(os.Stderr, "无效的 -config 路径 %q。\n若同时需要 HTTPS,请写成: ./cyberstrike-ai --https -config config.yaml(-config 后必须是 yaml 文件路径)。\n", cp)
|
fmt.Fprintf(os.Stderr, "Invalid -config path %q.\nIf HTTPS is also needed, use: ./cyberstrike-ai --https -config config.yaml (-config must be followed by a yaml file path).\n", cp)
|
||||||
os.Exit(2)
|
os.Exit(2)
|
||||||
}
|
}
|
||||||
localConfig, err := config.EnsureLocalConfig(cp)
|
localConfig, err := config.EnsureLocalConfig(cp)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("加载配置失败: %v\n", err)
|
fmt.Printf("Failed to load config: %v\n", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg, err := config.Load(cp)
|
cfg, err := config.Load(cp)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("加载配置失败: %v\n", err)
|
fmt.Printf("Failed to load config: %v\n", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if localConfig.Created {
|
if localConfig.Created {
|
||||||
termout.PrintConfigCreated()
|
termout.PrintConfigCreated()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if *resetAdminPassword {
|
||||||
|
if err := runResetAdminPassword(cfg); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Failed to reset admin password: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if *httpBootstrap {
|
if *httpBootstrap {
|
||||||
config.ApplyPlainHTTPBootstrap(cfg)
|
config.ApplyPlainHTTPBootstrap(cfg)
|
||||||
} else if *httpsBootstrap {
|
} else if *httpsBootstrap {
|
||||||
@@ -79,7 +93,7 @@ func main() {
|
|||||||
|
|
||||||
// MCP 启用且 auth_header_value 为空时,自动生成随机密钥并写回配置
|
// MCP 启用且 auth_header_value 为空时,自动生成随机密钥并写回配置
|
||||||
if err := config.EnsureMCPAuth(cp, cfg); err != nil {
|
if err := config.EnsureMCPAuth(cp, cfg); err != nil {
|
||||||
fmt.Printf("MCP 鉴权配置失败: %v\n", err)
|
fmt.Printf("Failed to configure MCP authentication: %v\n", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if cfg.MCP.Enabled {
|
if cfg.MCP.Enabled {
|
||||||
@@ -121,3 +135,72 @@ func main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func runResetAdminPassword(cfg *config.Config) error {
|
||||||
|
dbPath := strings.TrimSpace(cfg.Database.Path)
|
||||||
|
if dbPath == "" {
|
||||||
|
dbPath = "data/conversations.db"
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(dbPath); err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return fmt.Errorf("database does not exist: %s; start the service once to initialize it first", dbPath)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("Reset built-in admin password")
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
password, err := readHiddenPassword("New admin password: ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
password = strings.TrimSpace(password)
|
||||||
|
if len(password) < 8 {
|
||||||
|
return fmt.Errorf("new password must be at least 8 characters")
|
||||||
|
}
|
||||||
|
confirm, err := readHiddenPassword("Confirm new password: ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if password != strings.TrimSpace(confirm) {
|
||||||
|
return fmt.Errorf("passwords do not match")
|
||||||
|
}
|
||||||
|
|
||||||
|
hash, err := security.HashPassword(password)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := database.NewDB(dbPath, zap.NewNop())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() { _ = db.Close() }()
|
||||||
|
|
||||||
|
admin, err := db.GetRBACUserByUsername("admin")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("built-in admin account was not found; start the service once to initialize it first: %w", err)
|
||||||
|
}
|
||||||
|
if !admin.IsBuiltin {
|
||||||
|
return fmt.Errorf("admin account is not built in; refusing to reset it")
|
||||||
|
}
|
||||||
|
if err := db.UpdateRBACAdminPassword(hash); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println("Admin password has been reset.")
|
||||||
|
fmt.Println("If the service is running, existing login sessions remain valid until the service restarts or the sessions expire.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func readHiddenPassword(prompt string) (string, error) {
|
||||||
|
fmt.Fprint(os.Stderr, prompt)
|
||||||
|
password, err := term.ReadPassword(int(os.Stdin.Fd()))
|
||||||
|
fmt.Fprintln(os.Stderr)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(password), nil
|
||||||
|
}
|
||||||
|
|||||||
+96
-36
@@ -10,7 +10,7 @@
|
|||||||
# ============================================
|
# ============================================
|
||||||
|
|
||||||
# 前端显示的版本号(可选,不填则显示默认版本)
|
# 前端显示的版本号(可选,不填则显示默认版本)
|
||||||
version: "v1.7.8"
|
version: "v1.7.15"
|
||||||
# 服务器配置
|
# 服务器配置
|
||||||
server:
|
server:
|
||||||
host: 0.0.0.0 # 监听地址,0.0.0.0 表示监听所有网络接口
|
host: 0.0.0.0 # 监听地址,0.0.0.0 表示监听所有网络接口
|
||||||
@@ -57,7 +57,7 @@ monitor:
|
|||||||
# - DeepSeek: https://api.deepseek.com/v1
|
# - DeepSeek: https://api.deepseek.com/v1
|
||||||
# - 其他兼容 OpenAI 协议的 API
|
# - 其他兼容 OpenAI 协议的 API
|
||||||
# 常用模型: gpt-4, gpt-3.5-turbo, deepseek-chat, claude-3-opus 等
|
# 常用模型: 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:
|
ai:
|
||||||
default_channel: qwen-max
|
default_channel: qwen-max
|
||||||
channels:
|
channels:
|
||||||
@@ -68,7 +68,7 @@ ai:
|
|||||||
api_key: sk-xxxxxxx
|
api_key: sk-xxxxxxx
|
||||||
model: qwen3-max
|
model: qwen3-max
|
||||||
max_total_tokens: 120000
|
max_total_tokens: 120000
|
||||||
max_completion_tokens: 16384
|
max_completion_tokens: 32768
|
||||||
# Eino 路径模型推理:DeepSeek/OpenAI 为 thinking / reasoning_effort;Claude 4.6+ 为 adaptive + output_config.effort(仅显式配置 effort 时下发);3.7 为 enabled+budget_tokens:10000(文档示例),effort 不映射,自定义预算用 extra_request_fields
|
# Eino 路径模型推理:DeepSeek/OpenAI 为 thinking / reasoning_effort;Claude 4.6+ 为 adaptive + output_config.effort(仅显式配置 effort 时下发);3.7 为 enabled+budget_tokens:10000(文档示例),effort 不映射,自定义预算用 extra_request_fields
|
||||||
reasoning:
|
reasoning:
|
||||||
mode: on # auto | on | off;off:OpenAI/Claude 不附加推理字段,DeepSeek 发送 thinking.type=disabled(其默认开启思考)
|
mode: on # auto | on | off;off:OpenAI/Claude 不附加推理字段,DeepSeek 发送 thinking.type=disabled(其默认开启思考)
|
||||||
@@ -76,6 +76,14 @@ ai:
|
|||||||
allow_client_reasoning: true # false 时忽略对话请求体 reasoning,仅以下方为准
|
allow_client_reasoning: true # false 时忽略对话请求体 reasoning,仅以下方为准
|
||||||
profile: openai_compat # auto | deepseek_compat | openai_compat | output_config_effort
|
profile: openai_compat # auto | deepseek_compat | openai_compat | output_config_effort
|
||||||
# extra_request_fields: {} # 可选:管理员自定义根级 JSON 片段(高级)
|
# extra_request_fields: {} # 可选:管理员自定义根级 JSON 片段(高级)
|
||||||
|
# qwen-plus:
|
||||||
|
# name: Qwen Plus Fallback
|
||||||
|
# provider: openai_compatible
|
||||||
|
# base_url: https://dashscope.aliyuncs.com/compatible-mode/v1
|
||||||
|
# api_key: sk-xxxxxxx
|
||||||
|
# model: qwen-plus
|
||||||
|
# max_total_tokens: 120000
|
||||||
|
# max_completion_tokens: 32768
|
||||||
# 视觉分析(analyze_image MCP 工具;图片仅在单次 VL 调用中出现,Agent 上下文只保留文字摘要)
|
# 视觉分析(analyze_image MCP 工具;图片仅在单次 VL 调用中出现,Agent 上下文只保留文字摘要)
|
||||||
vision:
|
vision:
|
||||||
enabled: false # true 且 model 非空时注册 analyze_image
|
enabled: false # true 且 model 非空时注册 analyze_image
|
||||||
@@ -99,10 +107,10 @@ fofa:
|
|||||||
base_url: https://fofa.info/api/v1/search/all # 可选,留空则使用默认
|
base_url: https://fofa.info/api/v1/search/all # 可选,留空则使用默认
|
||||||
api_key: "" # FOFA API Key(可选,建议在系统设置中填写)
|
api_key: "" # FOFA API Key(可选,建议在系统设置中填写)
|
||||||
zoomeye:
|
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 环境变量配置
|
api_key: "" # ZoomEye API Key;也可通过 ZOOMEYE_API_KEY 环境变量配置
|
||||||
quake:
|
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 环境变量配置
|
api_key: "" # Quake API Token;也可通过 QUAKE_API_KEY 环境变量配置
|
||||||
shodan:
|
shodan:
|
||||||
base_url: https://api.shodan.io # 可选,留空则使用默认
|
base_url: https://api.shodan.io # 可选,留空则使用默认
|
||||||
@@ -138,7 +146,7 @@ hitl:
|
|||||||
# 已决策审计日志保留天数(与 MCP 监控一致;省略默认 90;0 表示不自动清理)
|
# 已决策审计日志保留天数(与 MCP 监控一致;省略默认 90;0 表示不自动清理)
|
||||||
retention_days: 90
|
retention_days: 90
|
||||||
# 按你环境里的真实工具名增删(与侧栏一致、小写不敏感);不需要全局免审批可改为 []
|
# 按你环境里的真实工具名增删(与侧栏一致、小写不敏感);不需要全局免审批可改为 []
|
||||||
tool_whitelist: [read_file, list_dir, glob, grep, tool_search]
|
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: | # 审批模式;留空使用内置默认,可在「人机协同」页编辑
|
||||||
# audit_agent_prompt_review_edit: | # 审查编辑模式;留空使用内置默认
|
# audit_agent_prompt_review_edit: | # 审查编辑模式;留空使用内置默认
|
||||||
|
|
||||||
@@ -147,47 +155,101 @@ hitl:
|
|||||||
|
|
||||||
你会收到 JSON,包含 hitlMode、toolName、arguments/argumentsObj、userMessage、thinking、reasoningChain、planning 等字段。
|
你会收到 JSON,包含 hitlMode、toolName、arguments/argumentsObj、userMessage、thinking、reasoningChain、planning 等字段。
|
||||||
|
|
||||||
|
规则优先级:
|
||||||
|
- 必须 reject 的规则
|
||||||
|
- 必须 approve 的规则
|
||||||
|
- 默认放行规则
|
||||||
|
|
||||||
|
规则匹配要求:
|
||||||
|
- 如果同时命中 reject 和 approve,必须 reject。
|
||||||
|
- 每次裁决必须给出一个规则编号,不得输出“命中规则:无”。
|
||||||
|
- 未命中明确 reject 或 approve 规则,但未发现明确破坏行为时,命中 D1:默认放行。
|
||||||
|
- 不得仅凭“本地执行”“设置超时”“文件名包含 test”判断脚本无害;必须检查实际 command、代码或请求参数。
|
||||||
|
- 如果脚本具体内容不可见,应表述为“当前参数未显示明确高危操作”,不能断言“无持久化影响”。
|
||||||
|
|
||||||
裁决基调(默认放行):
|
裁决基调(默认放行):
|
||||||
- 常规、低风险的渗透测试操作 → approve(如信息收集、端口/服务扫描、目录枚举、只读查询、无害探测命令)
|
- 绝大多数渗透测试操作(包括各类攻击 payload)都应放行
|
||||||
- 与用户授权、当前任务目标一致,且未见明确高危迹象 → approve
|
- 在裁决之前需要判断出:1、此请求的是干什么(理解请求内容)?2、此请求造成什么后果?
|
||||||
- 仅在「可能对系统造成实质影响」时 → reject
|
- 仅在「明确会直接破坏目标业务系统正常运行,或造成真实用户数据丢失/不可逆篡改」时 → reject
|
||||||
|
- 信息不足、参数略宽泛、仅为探测意图时 → approve
|
||||||
|
- 拿不准时 → approve(宁可放过可恢复的测试,不要误拦合法攻击)
|
||||||
|
|
||||||
必须 reject 的高危情形(示例,非穷举):
|
必须 reject 的高危情形(示例,非穷举):
|
||||||
- 删库、清表、批量删除数据、格式化磁盘、不可逆破坏
|
- 修改/重置任意用户或管理员密码、强制下线真实用户
|
||||||
- 修改/重置密码、创建或篡改管理员账号、持久化后门、开机自启
|
- 修改服务器或服务配置(系统配置、Web/数据库参数、防火墙、启动项、计划任务)
|
||||||
- 向生产环境写入恶意载荷、勒索加密、停止关键服务、修改系统核心配置
|
- 修改/创建/删除用户、角色、权限(账户体系篡改)
|
||||||
- 明显越权:与任务/授权目标无关的破坏性操作
|
- 删除或大规模破坏数据/文件(DROP/TRUNCATE/DELETE 全表、rm 重要文件、清空数据库)
|
||||||
|
- 停止、禁用、重启业务服务(systemctl stop、kill 关键进程、shutdown、reboot)
|
||||||
|
- 大规模压测或高并发造成 DoS
|
||||||
|
|
||||||
不应单独作为 reject 理由的情形:
|
以下情形必须 approve(即使看起来“攻击性强”或包含写操作):
|
||||||
- 常规 nmap/curl/grep/读文件/枚举类命令本身
|
- 各类漏洞探测与利用 payload(SQL 注入含读写型、命令注入、反序列化、文件包含、路径遍历、SSRF、SSTI、XXE 等)
|
||||||
- 参数略显宽泛但无明确破坏意图
|
- 文件上传测试,包括上传 webshell、写入 webshell 文件
|
||||||
- 仅因「信息不足」——若无上述高危迹象,应 approve 并可在 comment 中提示注意点
|
- 命令执行探测、反弹 Shell、dnslog / 数据外带探测
|
||||||
|
- 数据库命令执行、写文件、写计划任务、内存马注入等 getshell 利用
|
||||||
|
- 常规速率的口令爆破(非 DoS 级)
|
||||||
|
- 信息收集、端口/目录扫描、枚举、抓取
|
||||||
|
- 读取、查询、探测类操作(含敏感文件)
|
||||||
|
- 攻陷后无害侦察命令(whoami/id/uname/ls/cat 等)
|
||||||
|
- 参数宽泛但无明确破坏意图的请求
|
||||||
|
|
||||||
|
判定原则:
|
||||||
|
- “写”本身不是拦截理由——渗透测试大量依赖写操作(注入、上传、爆破、getshell)
|
||||||
|
- 关键看是否破坏「业务可用性 / 关键数据完整性」
|
||||||
|
- 只有明确命中上方 reject 清单才 reject
|
||||||
|
|
||||||
仅输出一行 JSON,不要 markdown 代码块:
|
仅输出一行 JSON,不要 markdown 代码块:
|
||||||
{"decision":"approve"|"reject","comment":"简要理由"}
|
{"decision":"approve"|"reject","comment":"实际操作:...;成功后的后果:...;命中规则:..."}
|
||||||
audit_agent_prompt_review_edit: |-
|
audit_agent_prompt_review_edit: |-
|
||||||
你是 CyberStrikeAI 人机协同审计 Agent。审查 Agent 即将执行的工具调用是否会对系统造成实质性损害。
|
你是 CyberStrikeAI 人机协同审计 Agent。审查 Agent 即将执行的工具调用是否会对系统造成实质性损害。
|
||||||
|
|
||||||
你会收到 JSON,包含 hitlMode、toolName、arguments/argumentsObj、userMessage、thinking、reasoningChain、planning 等字段。
|
你会收到 JSON,包含 hitlMode、toolName、arguments/argumentsObj、userMessage、thinking、reasoningChain、planning 等字段。
|
||||||
|
|
||||||
|
规则优先级:
|
||||||
|
- 必须 reject 的规则
|
||||||
|
- 必须 approve 的规则
|
||||||
|
- 默认放行规则
|
||||||
|
|
||||||
|
规则匹配要求:
|
||||||
|
- 如果同时命中 reject 和 approve,必须 reject。
|
||||||
|
- 每次裁决必须给出一个规则编号,不得输出“命中规则:无”。
|
||||||
|
- 未命中明确 reject 或 approve 规则,但未发现明确破坏行为时,命中 D1:默认放行。
|
||||||
|
- 不得仅凭“本地执行”“设置超时”“文件名包含 test”判断脚本无害;必须检查实际 command、代码或请求参数。
|
||||||
|
- 如果脚本具体内容不可见,应表述为“当前参数未显示明确高危操作”,不能断言“无持久化影响”。
|
||||||
|
|
||||||
裁决基调(默认放行):
|
裁决基调(默认放行):
|
||||||
- 常规、低风险的渗透测试操作 → approve(如信息收集、端口/服务扫描、目录枚举、只读查询、无害探测命令)
|
- 绝大多数渗透测试操作(包括各类攻击 payload)都应放行
|
||||||
- 与用户授权、当前任务目标一致,且未见明确高危迹象 → approve
|
- 在裁决之前需要判断出:1、此请求的是干什么(理解请求内容)?2、此请求造成什么后果?
|
||||||
- 仅在「可能对系统造成实质影响」时 → reject;参数可安全收窄时优先 approve + editedArguments
|
- 仅在「明确会直接破坏目标业务系统正常运行,或造成真实用户数据丢失/不可逆篡改」时 → reject
|
||||||
|
- 信息不足、参数略宽泛、仅为探测意图时 → approve
|
||||||
|
- 拿不准时 → approve(宁可放过可恢复的测试,不要误拦合法攻击)
|
||||||
|
|
||||||
必须 reject 的高危情形(示例,非穷举):
|
必须 reject 的高危情形(示例,非穷举):
|
||||||
- 删库、清表、批量删除数据、格式化磁盘、不可逆破坏
|
- 修改/重置任意用户或管理员密码、强制下线真实用户
|
||||||
- 修改/重置密码、创建或篡改管理员账号、持久化后门、开机自启
|
- 修改服务器或服务配置(系统配置、Web/数据库参数、防火墙、启动项、计划任务)
|
||||||
- 向生产环境写入恶意载荷、勒索加密、停止关键服务、修改系统核心配置
|
- 修改/创建/删除用户、角色、权限(账户体系篡改)
|
||||||
- 明显越权:与任务/授权目标无关的破坏性操作
|
- 删除或大规模破坏数据/文件(DROP/TRUNCATE/DELETE 全表、rm 重要文件、清空数据库)
|
||||||
|
- 停止、禁用、重启业务服务(systemctl stop、kill 关键进程、shutdown、reboot)
|
||||||
|
- 大规模压测或高并发造成 DoS
|
||||||
|
|
||||||
不应单独作为 reject 理由的情形:
|
以下情形必须 approve(即使看起来“攻击性强”或包含写操作):
|
||||||
- 常规 nmap/curl/grep/读文件/枚举类命令本身
|
- 各类漏洞探测与利用 payload(SQL 注入含读写型、命令注入、反序列化、文件包含、路径遍历、SSRF、SSTI、XXE 等)
|
||||||
- 参数略显宽泛但无明确破坏意图(应收窄参数后 approve)
|
- 文件上传测试,包括上传 webshell、写入 webshell 文件
|
||||||
- 仅因「信息不足」——若无上述高危迹象,应 approve 并可在 comment 中提示注意点
|
- 命令执行探测、反弹 Shell、dnslog / 数据外带探测
|
||||||
|
- 数据库命令执行、写文件、写计划任务、内存马注入等 getshell 利用
|
||||||
|
- 常规速率的口令爆破(非 DoS 级)
|
||||||
|
- 信息收集、端口/目录扫描、枚举、抓取
|
||||||
|
- 读取、查询、探测类操作(含敏感文件)
|
||||||
|
- 攻陷后无害侦察命令(whoami/id/uname/ls/cat 等)
|
||||||
|
- 参数宽泛但无明确破坏意图的请求
|
||||||
|
|
||||||
|
判定原则:
|
||||||
|
- “写”本身不是拦截理由——渗透测试大量依赖写操作(注入、上传、爆破、getshell)
|
||||||
|
- 关键看是否破坏「业务可用性 / 关键数据完整性」
|
||||||
|
- 只有明确命中上方 reject 清单才 reject
|
||||||
|
|
||||||
仅输出一行 JSON,不要 markdown 代码块:
|
仅输出一行 JSON,不要 markdown 代码块:
|
||||||
{"decision":"approve"|"reject","comment":"简要理由","editedArguments":{...}}
|
{"decision":"approve"|"reject","comment":"实际操作:...;成功后的后果:...;命中规则:...","editedArguments":{...}}
|
||||||
|
|
||||||
editedArguments 规则(仅 approve 且需要改参时填写,否则省略该字段):
|
editedArguments 规则(仅 approve 且需要改参时填写,否则省略该字段):
|
||||||
- 提供完整替换后的工具参数对象,键名与 argumentsObj 一致
|
- 提供完整替换后的工具参数对象,键名与 argumentsObj 一致
|
||||||
@@ -217,9 +279,6 @@ multi_agent:
|
|||||||
# Eino ADK 中间件与 Deep/Supervisor/plan_execute Executor 调参(结构体见 internal/config/config.go → MultiAgentEinoMiddlewareConfig)
|
# Eino ADK 中间件与 Deep/Supervisor/plan_execute Executor 调参(结构体见 internal/config/config.go → MultiAgentEinoMiddlewareConfig)
|
||||||
# plan_execute:下列 patch/reduction/tool_search/plantask 等同样作用于 Executor(经 ExecPreMiddlewares);Planner/Replanner 不挂 MCP 前置中间件。
|
# plan_execute:下列 patch/reduction/tool_search/plantask 等同样作用于 Executor(经 ExecPreMiddlewares);Planner/Replanner 不挂 MCP 前置中间件。
|
||||||
eino_middleware:
|
eino_middleware:
|
||||||
max_tool_arguments_bytes: 65536 # 单个工具 arguments 硬上限;超出时禁止执行并要求模型改写
|
|
||||||
max_shell_command_bytes: 65536 # exec/execute.command 硬上限;与普通工具 arguments 上限一致
|
|
||||||
model_output_repair_max_attempts: 1 # 非法/截断模型输出最多自动修复一次,避免循环
|
|
||||||
patch_tool_calls: true # true:修补历史中无 tool_result 的悬空 tool_call(流式中断/重试后更稳);false:关闭;字段省略时默认等同 true
|
patch_tool_calls: true # true:修补历史中无 tool_result 的悬空 tool_call(流式中断/重试后更稳);false:关闭;字段省略时默认等同 true
|
||||||
tool_search_enable: true # true:工具数 ≥ min 时启用 tool_search,仅前 N 个工具常驻,其余按正则按需解锁,省 token、减误选;false:全量工具进上下文
|
tool_search_enable: true # true:工具数 ≥ min 时启用 tool_search,仅前 N 个工具常驻,其余按正则按需解锁,省 token、减误选;false:全量工具进上下文
|
||||||
tool_search_min_tools: 20 # 达到该数量才启用 tool_search(避免工具很少时多此一举);与 always_visible 配合使用
|
tool_search_min_tools: 20 # 达到该数量才启用 tool_search(避免工具很少时多此一举);与 always_visible 配合使用
|
||||||
@@ -246,11 +305,12 @@ multi_agent:
|
|||||||
plan_execute_max_step_result_runes: 4000 # plan_execute 每步结果最大字符数(超出截断)
|
plan_execute_max_step_result_runes: 4000 # plan_execute 每步结果最大字符数(超出截断)
|
||||||
plan_execute_keep_last_steps: 8 # plan_execute 仅保留最近 N 步正文,早期步骤折叠为标题
|
plan_execute_keep_last_steps: 8 # plan_execute 仅保留最近 N 步正文,早期步骤折叠为标题
|
||||||
checkpoint_dir: data/eino-checkpoints # P0:进程崩溃/OOM 后同会话自动 ADK Resume;正常结束会删 .ckpt;与「中断并继续」(last_react_*) 是两套机制
|
checkpoint_dir: data/eino-checkpoints # P0:进程崩溃/OOM 后同会话自动 ADK Resume;正常结束会删 .ckpt;与「中断并继续」(last_react_*) 是两套机制
|
||||||
run_retry_max_attempts: 0 # 408/409/425/429/5xx/网络抖动时可退避重试次数;0=默认 4(永久性 4xx 不重试)
|
model_retry_max_retries: 0 # Eino 原生 ChatModel retry;408/409/425/429/5xx/网络抖动/空流式输出会重试;0=默认 4(永久性 4xx 不重试)
|
||||||
run_retry_max_backoff_sec: 0 # 单次退避上限秒数;0=默认 30
|
model_retry_max_backoff_sec: 0 # Eino 原生 ChatModel retry 单次退避上限秒数;0=默认 30
|
||||||
|
model_failover_channels: [] # Eino 原生 ChatModel failover;填写 ai.channels ID,例如 [qwen-plus];retry 耗尽后按顺序切换
|
||||||
|
model_failover_max_retries: 0 # 备用通道最多尝试数;0=尝试全部 model_failover_channels
|
||||||
empty_response_continue_max_attempts: 0 # Run 成功但未捕获助手正文(含流式中断)时 Handler 退避续跑次数;0=默认 5
|
empty_response_continue_max_attempts: 0 # Run 成功但未捕获助手正文(含流式中断)时 Handler 退避续跑次数;0=默认 5
|
||||||
deep_output_key: final_answer # P0:Eino session 写入最终助手结论(框架内部;Deep/Supervisor 主/eino_single)
|
deep_output_key: final_answer # P0:Eino session 写入最终助手结论(框架内部;Deep/Supervisor 主/eino_single)
|
||||||
deep_model_retry_max_retries: 0 # 已废弃,请用 run_retry_max_attempts;保留字段仅为兼容旧配置
|
|
||||||
task_tool_description_prefix: "" # 非空:仅 Deep 的 task 工具使用自定义描述前缀,运行时会拼接子代理名称;空则走 Eino 默认生成逻辑
|
task_tool_description_prefix: "" # 非空:仅 Deep 的 task 工具使用自定义描述前缀,运行时会拼接子代理名称;空则走 Eino 默认生成逻辑
|
||||||
# Eino callbacks + OpenTelemetry:框架级 span(与 Zap 对齐);默认不向终端用户 UI 推 eino_trace_*(见 sse_trace_to_client)
|
# Eino callbacks + OpenTelemetry:框架级 span(与 Zap 对齐);默认不向终端用户 UI 推 eino_trace_*(见 sse_trace_to_client)
|
||||||
eino_callbacks:
|
eino_callbacks:
|
||||||
|
|||||||
@@ -67,6 +67,25 @@ Streaming endpoints are long-lived. Clients should:
|
|||||||
- disable proxy buffering;
|
- disable proxy buffering;
|
||||||
- pass `conversationId` when continuing a conversation.
|
- pass `conversationId` when continuing a conversation.
|
||||||
|
|
||||||
|
## File Management Sources
|
||||||
|
|
||||||
|
The file management page and `GET /api/chat-uploads` group conversation-related files by source. Directory names still use project IDs or conversation IDs for stability, while the UI prefers project names or conversation titles and keeps the full ID available in tooltips or copied paths.
|
||||||
|
|
||||||
|
| Source | `source` | Typical directory | Meaning | Mutability |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| Workspace files | `workspace` | `tmp/workspace/projects/<projectId>/...`, `tmp/workspace/conversations/<conversationId>/...` | The Agent workspace for downloaded files, analysis scripts, intermediate results, and generated CSV/XLSX/Markdown files. If an AI-generated file is missing from the UI, check this source first. | Read-only listing; supports copy path, download, and export. |
|
||||||
|
| Conversation artifacts | `conversation_artifact` | `data/conversation_artifacts/<conversationId>/...` | Conversation-scoped deliverables or archived artifacts such as summaries, reports, or middleware-generated artifacts. | Read-only listing; supports copy path, download, and export. |
|
||||||
|
| Tool outputs | `reduction` | `tmp/reduction/projects/<projectId>/...`, `tmp/reduction/conversations/<conversationId>/...` | Persisted full tool outputs, scan raw data, or outputs saved before truncation. Useful for reviewing long command or scan results. | Read-only listing; supports copy path, download, and export. |
|
||||||
|
| Chat uploads | `upload` | `chat_uploads/<date>/<conversationId>/...` | Files manually uploaded in chat or from the file management page. Copy the server absolute path into chat when the AI should reference a file. | Supports upload, mkdir, text edit, rename, delete, copy path, download, and export. |
|
||||||
|
|
||||||
|
Related endpoints:
|
||||||
|
|
||||||
|
- `GET /api/chat-uploads`: list files filtered by source, project, conversation, or filename.
|
||||||
|
- `GET /api/chat-uploads/path`: resolve a file-management relative path or internal virtual path to a server absolute path for copy actions.
|
||||||
|
- `GET /api/chat-uploads/download`: download a file.
|
||||||
|
- `GET /api/chat-uploads/export`: export the current filtered result as a ZIP.
|
||||||
|
- `POST /api/chat-uploads`: upload into the chat uploads directory.
|
||||||
|
|
||||||
## Asset Management and Bulk Import
|
## Asset Management and Bulk Import
|
||||||
|
|
||||||
Asset endpoints:
|
Asset endpoints:
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ ai:
|
|||||||
| `ai.default_channel` | Default channel ID for new conversations and requests without an explicit channel. |
|
| `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. |
|
| `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. |
|
| `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`. |
|
| `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_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. |
|
| `max_completion_tokens` | Per-response output cap; default is used when empty. |
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ CyberStrikeAI can run as a local testing tool, an internal team service, or a pr
|
|||||||
- Python for some MCP servers and tool scripts.
|
- Python for some MCP servers and tool scripts.
|
||||||
- SQLite files under `data/`; no external DB is required by default.
|
- SQLite files under `data/`; no external DB is required by default.
|
||||||
- Actual security tools installed in PATH. YAML files under `tools/` only describe commands.
|
- 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:
|
Important persistent paths:
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ hitl:
|
|||||||
api_key: ""
|
api_key: ""
|
||||||
model: "" # set a small model here; blank reuses the default AI channel model
|
model: "" # set a small model here; blank reuses the default AI channel model
|
||||||
retention_days: 90
|
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.
|
`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:
|
Allowlisted tools skip approval, so keep the list stable and low-risk. Recommended examples:
|
||||||
|
|
||||||
- `read_file`
|
- `read_file`
|
||||||
- `list_dir`
|
- `ls`
|
||||||
- `glob`
|
- `glob`
|
||||||
- `grep`
|
- `grep`
|
||||||
- `tool_search`
|
- `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:
|
Avoid globally allowlisting:
|
||||||
|
|
||||||
@@ -102,6 +108,8 @@ Avoid globally allowlisting:
|
|||||||
- File write/delete tools
|
- File write/delete tools
|
||||||
- C2 task tools
|
- C2 task tools
|
||||||
- WebShell command execution 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
|
## Mode Selection
|
||||||
|
|
||||||
|
|||||||
@@ -46,13 +46,21 @@ Login fails:
|
|||||||
|
|
||||||
If another administrator with `rbac:write` is available, reset the password under **Platform permissions → User management**.
|
If another administrator with `rbac:write` is available, reset the password under **Platform permissions → User management**.
|
||||||
|
|
||||||
If no administrator session is available, the built-in `admin` account can be recovered on the server. Stop CyberStrikeAI, back up the database, change to the project root, and run the command below. Enter and confirm the new password when prompted:
|
If no administrator session is available, the built-in `admin` account can be recovered on the server. Change to the project root and run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./run.sh --reset-admin-password
|
||||||
|
```
|
||||||
|
|
||||||
|
Enter and confirm the new password when prompted. The script hides input and stores a bcrypt hash. If the service is running, restart it afterward to invalidate existing login sessions.
|
||||||
|
|
||||||
|
If `run.sh` is not available, run the command below manually. Enter and confirm the new password when prompted:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
HASH=$(htpasswd -nBC 10 '' | cut -d: -f2 | tr -d '\n') && sqlite3 data/conversations.db "UPDATE rbac_users SET password_hash='$HASH', updated_at=CURRENT_TIMESTAMP WHERE id='admin' AND username='admin' AND is_builtin=1; SELECT changes();"
|
HASH=$(htpasswd -nBC 10 '' | cut -d: -f2 | tr -d '\n') && sqlite3 data/conversations.db "UPDATE rbac_users SET password_hash='$HASH', updated_at=CURRENT_TIMESTAMP WHERE id='admin' AND username='admin' AND is_builtin=1; SELECT changes();"
|
||||||
```
|
```
|
||||||
|
|
||||||
Output `1` means that the row was updated. The command requires `sqlite3` and `htpasswd`. If `database.path` in `config.yaml` is not the default, replace `data/conversations.db`. Password input is hidden, is not written to shell history, and is stored as a bcrypt hash. Restart the service afterward to invalidate existing login sessions.
|
Output `1` means that the row was updated. The command requires `sqlite3` and `htpasswd`. If `database.path` in `config.yaml` is not the default, replace `data/conversations.db`. Password input is hidden and is not written to shell history.
|
||||||
|
|
||||||
Model fails:
|
Model fails:
|
||||||
|
|
||||||
|
|||||||
@@ -34,7 +34,39 @@ Saved workflows can be bound to a role under **Role Management**. When `workflow
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. Execution model (read this before configuring)
|
## 3. Natural-language Draft Generation
|
||||||
|
|
||||||
|
The Workflows page provides a **Create from natural language** entry point. After a user describes a security operations goal, CyberStrikeAI generates an editable draft and returns a structured audit result:
|
||||||
|
|
||||||
|
- Draft generation does not save, dry-run, or execute tools automatically.
|
||||||
|
- The server endpoint is `POST /api/workflows/generate-draft`, protected by `workflow:write`.
|
||||||
|
- The response includes `graph`, `meta`, `capabilities`, `audit`, and `stats`; after applying the draft to the canvas, normal save validation still runs.
|
||||||
|
- High-risk language such as executing scripts, isolating hosts, blocking, deleting, or exploitation is marked as `high_risk` and defaults to HITL approval or `requires_human_confirmation`.
|
||||||
|
- Tool capabilities are matched against the available tool list; unmatched capabilities fall back to Agent draft nodes and are surfaced in `audit.missing_fields` / `audit.assumptions`.
|
||||||
|
- If server generation is unavailable, the frontend uses a local deterministic fallback and still returns an editable draft with risk notes.
|
||||||
|
|
||||||
|
Example request:
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/workflows/generate-draft
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"prompt": "Scan target assets for open ports, create a vulnerability task when critical ports are found, ask the owner for approval, and output a report",
|
||||||
|
"options": {
|
||||||
|
"include_objective": true,
|
||||||
|
"allow_schedule": false,
|
||||||
|
"allow_high_risk": false
|
||||||
|
},
|
||||||
|
"available_tools": [
|
||||||
|
{ "key": "nmap", "name": "nmap", "enabled": true }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Execution model (read this before configuring)
|
||||||
|
|
||||||
The engine executes the workflow as a **directed graph**, starting from the **Start** node and following edges to downstream nodes.
|
The engine executes the workflow as a **directed graph**, starting from the **Start** node and following edges to downstream nodes.
|
||||||
|
|
||||||
@@ -116,7 +148,7 @@ Agent B still receives Agent A’s output even when a condition node lies betwee
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. Template syntax
|
## 5. Template syntax
|
||||||
|
|
||||||
### 4.1 Basic format
|
### 4.1 Basic format
|
||||||
|
|
||||||
@@ -198,7 +230,7 @@ Field bindings can read ordinary fields such as `output` or `message`, and also
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. Node types and configuration
|
## 6. Node types and configuration
|
||||||
|
|
||||||
### 5.1 Start
|
### 5.1 Start
|
||||||
|
|
||||||
@@ -318,7 +350,7 @@ Optional node for an end summary template (less common in role-bound flows).
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 6. Edge configuration
|
## 7. Edge configuration
|
||||||
|
|
||||||
Select an **edge** to configure its **condition** in the right panel.
|
Select an **edge** to configure its **condition** in the right panel.
|
||||||
|
|
||||||
@@ -335,7 +367,7 @@ If no edge condition is set:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 7. Full example: passing Agent output across a condition
|
## 8. Full example: passing Agent output across a condition
|
||||||
|
|
||||||
### 7.1 Graph structure
|
### 7.1 Graph structure
|
||||||
|
|
||||||
@@ -384,7 +416,7 @@ Start → Agent (initial value) → Condition → Agent (transform) → Output
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 8. Bind to a role and run
|
## 9. Bind to a role and run
|
||||||
|
|
||||||
### 8.1 Bind in Role Management
|
### 8.1 Bind in Role Management
|
||||||
|
|
||||||
@@ -414,7 +446,7 @@ If no Output node is reached or no branch matches, `outputs` may be empty and th
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 9. Debugging, dry-run, and replay
|
## 10. Debugging, dry-run, and replay
|
||||||
|
|
||||||
### 9.1 Safe dry-run
|
### 9.1 Safe dry-run
|
||||||
|
|
||||||
@@ -487,7 +519,7 @@ Token and cost metrics depend on whether the underlying model/Agent events repor
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 10. Validation before save
|
## 11. Validation before save
|
||||||
|
|
||||||
On save, the system checks:
|
On save, the system checks:
|
||||||
|
|
||||||
@@ -510,7 +542,7 @@ On save, the system checks:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 11. Troubleshooting
|
## 12. Troubleshooting
|
||||||
|
|
||||||
| Symptom | Likely cause | Fix |
|
| Symptom | Likely cause | Fix |
|
||||||
|---------|--------------|-----|
|
|---------|--------------|-----|
|
||||||
@@ -526,7 +558,7 @@ On save, the system checks:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 12. Best practices
|
## 13. Best practices
|
||||||
|
|
||||||
1. **Meaningful names**: Use descriptive output variable names (`scan_result`, `parsed_targets`) instead of reusing `agent_result` everywhere.
|
1. **Meaningful names**: Use descriptive output variable names (`scan_result`, `parsed_targets`) instead of reusing `agent_result` everywhere.
|
||||||
2. **Prefer `outputs` for cross-node data**: If a condition, tool, or HITL node might sit in between, use named variables.
|
2. **Prefer `outputs` for cross-node data**: If a condition, tool, or HITL node might sit in between, use named variables.
|
||||||
@@ -539,7 +571,7 @@ On save, the system checks:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 13. Code references (for developers)
|
## 14. Code references (for developers)
|
||||||
|
|
||||||
| Module | Path |
|
| Module | Path |
|
||||||
|--------|------|
|
|--------|------|
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -12,7 +12,7 @@
|
|||||||
## 核心概念与编排
|
## 核心概念与编排
|
||||||
|
|
||||||
- [架构说明](architecture.md) · [安全模型](security-model.md) · [RBAC](rbac.md)
|
- [架构说明](architecture.md) · [安全模型](security-model.md) · [RBAC](rbac.md)
|
||||||
- [Agent 与角色](agent-and-role-guide.md) · [Skills](skills-guide.md) · [Eino 多代理](MULTI_AGENT_EINO.md)
|
- [Agent 与角色](agent-and-role-guide.md) · [Skills](skills-guide.md) · [Eino 多代理](MULTI_AGENT_EINO.md) · [Agent 最终回复治理](agent-finalization-best-practices.md)
|
||||||
- [工作流](workflow-graph.md) · [工具执行治理](tool-execution-governance.md) · [人机协同最佳实践](hitl-best-practices.md)
|
- [工作流](workflow-graph.md) · [工具执行治理](tool-execution-governance.md) · [人机协同最佳实践](hitl-best-practices.md)
|
||||||
|
|
||||||
## 功能指南
|
## 功能指南
|
||||||
|
|||||||
@@ -0,0 +1,333 @@
|
|||||||
|
# Agent 最终回复治理最佳实践
|
||||||
|
|
||||||
|
[返回中文文档](README.md)
|
||||||
|
|
||||||
|
调研日期:2026-07-28
|
||||||
|
|
||||||
|
本文聚焦一个具体问题:Agent 在工具调用、推理、计划或子代理协作尚未真正完成时,输出了一段“像结论”的自然语言,前端或编排层把它当作最终回复展示。结论先说清楚:成熟 Agent 系统不会用“最近一段 assistant 文本”判断任务完成,而是用运行时状态、工具状态、验证结果和显式终态事件共同决定是否 final。
|
||||||
|
|
||||||
|
## 一、核心结论
|
||||||
|
|
||||||
|
1. **最终回复是运行时事件,不是自然语言内容。**
|
||||||
|
“已拿到”“下一步”“Huge breakthrough”这类文本只能作为候选观察或进展,不能作为完成信号。
|
||||||
|
|
||||||
|
2. **过程面和交付面必须隔离。**
|
||||||
|
`thinking`、`reasoning_chain`、`planning`、`response_delta`、子代理回复、工具输出都属于过程面;只有通过 final gate 的 `response` / `final` 事件才能写入主消息气泡和 `messages.content`。
|
||||||
|
|
||||||
|
3. **复杂任务需要 verifier,而不是更长 prompt。**
|
||||||
|
Prompt 可以提醒模型谨慎,但最终完成必须由代码层判断:是否仍有待执行工具、后台 execution、未完成计划步骤、未验证证据、未记录事实/漏洞、未清理或未说明不可清理。
|
||||||
|
|
||||||
|
4. **不同 agent 模式不同,但 final 治理原则一致。**
|
||||||
|
单代理、Deep、Plan-Execute、Supervisor 都需要 final gate。区别只是 gate 的证据来源不同:单代理看工具轨迹,Deep 还要看子代理结果,Plan-Execute 要看 Replanner 的终止判断,Supervisor 要看 `exit` 与 supervisor 汇总。
|
||||||
|
|
||||||
|
## 二、成熟 Agent 的公开做法
|
||||||
|
|
||||||
|
| 系统 | 公开做法 | 对 final 治理的启发 |
|
||||||
|
|---|---|---|
|
||||||
|
| Codex | OpenAI 的 Codex prompting guide 建议不要在 prompt 中强行要求 upfront plan、preamble 或 status updates,因为这可能导致 rollout 未完成就停止。 | 不要把“模型自己说的阶段性计划/状态”当完成依据;agent harness 应负责执行循环和收尾。 |
|
||||||
|
| Claude Code | Claude Code 提供 `PreToolUse`、`PostToolUse`、`Stop` 等 hooks;`PostToolUse` 明确发生在工具成功执行之后。 | 生命周期事件比自然语言可靠。验证、审计、阻断应挂在确定的阶段边界上。 |
|
||||||
|
| Claude Code Subagents | 子代理有独立上下文、自定义系统提示、特定工具权限和独立权限;子代理适合隔离大量检索/日志/文件读取。 | 子代理输出是证据材料,不是主任务最终结论;主代理必须汇总、验收、再 final。 |
|
||||||
|
| Claude Code Plan Mode | Plan mode 先读文件并产出计划,获得批准前不编辑。 | 计划与执行是不同状态;计划完成不等于任务完成。 |
|
||||||
|
| Cursor Plan Mode | Cursor Plan Mode 会研究代码库、询问澄清问题、生成可审查计划,并等待用户确认后再构建。 | UI 层把 plan/review/build 拆开,用户不会把计划误认为最终交付。 |
|
||||||
|
| OpenCode | OpenCode 把 Build、Plan、Review、Debug、Docs 等 agent 分成不同工具权限与用途,Plan agent 只分析规划不做修改。 | 用 agent 能力边界降低误触发:能规划的 agent 不等于能执行完成。 |
|
||||||
|
| Eino ADK | Eino ADK 提供事件驱动输出、Runner 回调、中断、checkpoint,以及 Supervisor、Plan-Execute 等协作原语。Plan-Execute 由 Planner、Executor、Replanner 协作。 | 当前项目选型方向正确;需要把事件驱动能力进一步固化为 finalization contract。 |
|
||||||
|
|
||||||
|
主要参考:
|
||||||
|
|
||||||
|
- OpenAI Codex Prompting Guide: https://developers.openai.com/cookbook/examples/gpt-5/codex_prompting_guide
|
||||||
|
- Claude Code Hooks: https://docs.anthropic.com/en/docs/claude-code/hooks
|
||||||
|
- Claude Code Subagents: https://docs.anthropic.com/en/docs/claude-code/sub-agents
|
||||||
|
- Claude Code Common Workflows: https://docs.anthropic.com/en/docs/claude-code/common-workflows
|
||||||
|
- Cursor Agent Best Practices: https://cursor.com/blog/agent-best-practices
|
||||||
|
- OpenCode Agents: https://opencode.ai/docs/agents/
|
||||||
|
- CloudWeGo Eino ADK: https://www.cloudwego.io/docs/eino/core_modules/eino_adk/
|
||||||
|
- CloudWeGo Eino ADK Patterns: https://www.cloudwego.io/docs/eino/overview/eino_adk0_1/
|
||||||
|
|
||||||
|
## 三、通用最佳实践
|
||||||
|
|
||||||
|
### 1. 建立 Finalization Contract
|
||||||
|
|
||||||
|
所有执行入口统一产出一个结构化收尾对象,只有它允许触发最终回复。
|
||||||
|
|
||||||
|
```go
|
||||||
|
type FinalizationDecision struct {
|
||||||
|
Status string // in_progress | completed | blocked | failed | cancelled
|
||||||
|
Finalizable bool
|
||||||
|
CompletionReason string // verified | user_cancelled | timeout | blocked | failed
|
||||||
|
FinalText string
|
||||||
|
EvidenceVerified bool
|
||||||
|
EvidenceRefs []string
|
||||||
|
PendingToolRuns []string
|
||||||
|
PendingPlanSteps []string
|
||||||
|
PendingApprovals []string
|
||||||
|
MissingChecks []string
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
硬规则:
|
||||||
|
|
||||||
|
- `Finalizable=false` 时禁止发送 `response` 终态事件。
|
||||||
|
- `Status=in_progress` 时只能发 `progress`、`planning`、`tool_*`、`reasoning_chain` 等过程事件。
|
||||||
|
- `FinalText` 不能为空,但非空不代表可以 final。
|
||||||
|
- `PendingToolRuns`、`PendingPlanSteps`、`PendingApprovals` 任一非空时不能 `completed`。
|
||||||
|
- `EvidenceVerified=false` 时不能把候选输出写成已验证结论。
|
||||||
|
|
||||||
|
### 2. 固定 SSE 事件语义
|
||||||
|
|
||||||
|
推荐事件分层:
|
||||||
|
|
||||||
|
| 事件 | 展示位置 | 可否写 `messages.content` | 说明 |
|
||||||
|
|---|---|---:|---|
|
||||||
|
| `progress` | 任务状态/时间线 | 否 | 简短进度 |
|
||||||
|
| `planning` | 执行详情 | 否 | 主代理计划、阶段性判断 |
|
||||||
|
| `reasoning_chain` / `thinking` | 执行详情 | 否 | 推理/思考摘要 |
|
||||||
|
| `tool_call` / `tool_result` | 执行详情 | 否 | 工具事件 |
|
||||||
|
| `eino_agent_reply` | 执行详情 | 否 | 子代理返回材料 |
|
||||||
|
| `finalization_check` | 执行详情 | 否 | verifier 结果 |
|
||||||
|
| `finalization_auto_continue` | 执行详情 | 否 | verifier 触发的工程续跑,`contextInjection=false` |
|
||||||
|
| `response` | 主消息气泡 | 是 | 只能在 `data.finalized=true` 时使用 |
|
||||||
|
| `done` | 关闭流 | 否 | 仅表示流结束,不表示任务成功 |
|
||||||
|
| `error` / `cancelled` | 主消息气泡或系统提示 | 是,终态失败类 | 必须带原因 |
|
||||||
|
|
||||||
|
### 3. 把“最终候选”与“最终回复”分开
|
||||||
|
|
||||||
|
模型可以输出候选结论,但候选结论必须先进入 `final_candidate` 或 `planning`,再由 verifier 决定是否提升:
|
||||||
|
|
||||||
|
```text
|
||||||
|
assistant text
|
||||||
|
-> candidate
|
||||||
|
-> finalization gate
|
||||||
|
-> response(finalized=true)
|
||||||
|
```
|
||||||
|
|
||||||
|
不要这样做:
|
||||||
|
|
||||||
|
```text
|
||||||
|
assistant text
|
||||||
|
-> response
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Stop-time Verification
|
||||||
|
|
||||||
|
借鉴 Claude Code hook 思路,在 agent run 停止时做一次确定性检查:
|
||||||
|
|
||||||
|
- 所有工具调用都有对应 tool result。
|
||||||
|
- 后台 execution 都处于 terminal 状态,或被明确登记为仍在运行且任务状态为 `in_progress` / `blocked`。
|
||||||
|
- Plan-Execute 没有未执行的 required step。
|
||||||
|
- Supervisor 没有未汇总的子代理结果。
|
||||||
|
- 在 evidence-required 策略下,至少存在可查询到的 completed 工具执行证据。
|
||||||
|
|
||||||
|
### 5. 子代理输出只作证据
|
||||||
|
|
||||||
|
子代理返回不能直接成为用户最终回复。主代理必须完成:
|
||||||
|
|
||||||
|
- 去重和冲突合并。
|
||||||
|
- 证据强度排序。
|
||||||
|
- 不确定性标注。
|
||||||
|
- 范围边界确认。
|
||||||
|
- 用户可读交付。
|
||||||
|
|
||||||
|
### 6. Prompt 只做软约束,代码做硬约束
|
||||||
|
|
||||||
|
Prompt 中可以写:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Interim observations must be marked as progress, not final.
|
||||||
|
Do not produce a final answer until verification is complete.
|
||||||
|
```
|
||||||
|
|
||||||
|
但真正决定 final 的必须是后端字段和状态机。否则模型只要生成一段像最终结论的自然语言,UI 仍可能误判。
|
||||||
|
|
||||||
|
## 四、CyberStrikeAI 当前落地状态
|
||||||
|
|
||||||
|
当前项目已经具备一套显式 final gate:
|
||||||
|
|
||||||
|
- [internal/agentfinalizer/decision.go](../../internal/agentfinalizer/decision.go) 是唯一的最终回复决策契约。
|
||||||
|
- [internal/handler/finalization_helpers.go](../../internal/handler/finalization_helpers.go) 负责把决策结果写入 `process_details`,并且只有 `Finalizable=true` 时才调用 `UpdateAssistantMessageFinalize`。
|
||||||
|
- [internal/handler/eino_single_agent.go](../../internal/handler/eino_single_agent.go)、[internal/handler/multi_agent.go](../../internal/handler/multi_agent.go)、[internal/handler/workflow_integration.go](../../internal/handler/workflow_integration.go)、[internal/handler/batch_queue_executor.go](../../internal/handler/batch_queue_executor.go) 均已在收尾处接入 finalizer。
|
||||||
|
- [web/static/js/monitor.js](../../web/static/js/monitor.js) 只把 `data.finalized === true` 的 `response` 当最终回复;未最终化文本会显示为最终回复检查未通过。
|
||||||
|
- [web/static/js/webshell.js](../../web/static/js/webshell.js) 将流式正文标记为候选输出,只有 `response(finalized=true)` 才切换为完成态。
|
||||||
|
- [internal/agentfinalizer/decision_test.go](../../internal/agentfinalizer/decision_test.go) 覆盖 pending tool、HITL、空输出、证据策略要求但缺执行证据、失败证据不能支撑最终化、完成态证据可 final 等回归场景。
|
||||||
|
- [internal/handler/finalization_auto_continue.go](../../internal/handler/finalization_auto_continue.go) 在缺 completed 执行证据时最多自动续跑 2 段;续跑只恢复已有模型轨迹,不向 agent 注入新的 user/system 文案。
|
||||||
|
|
||||||
|
当前契约的核心规则:
|
||||||
|
|
||||||
|
1. **模型自然语言只是 candidate。**
|
||||||
|
`RunResult.Response` 不能直接升级为最终回复,必须经过 `agentfinalizer.Decide`。
|
||||||
|
|
||||||
|
2. **所有 `response` 事件必须携带终态字段。**
|
||||||
|
至少包含 `finalized`、`finalizable`、`status`、`completionReason`、`evidenceVerified`、`evidenceRefs`、`pendingExecutionIds`、`missingChecks`。
|
||||||
|
|
||||||
|
3. **未完成工具会阻断 final。**
|
||||||
|
`queued/running` 工具执行仍存在时,决策结果为 `in_progress/pending_tool_executions`。
|
||||||
|
|
||||||
|
4. **执行证据必须由结构化策略声明。**
|
||||||
|
后端不从用户自然语言、助手回复或 agent mode 名称中推断执行意图。聊天请求通过 `finalization.requireExecutionEvidence` 显式声明;WebShell、Workflow、批量、机器人等执行入口由调用点显式传入 policy。policy 要求证据时,至少需要一个可查询到的 `completed` 工具执行记录;只有 failed/cancelled 记录不能支撑最终化。
|
||||||
|
|
||||||
|
5. **缺执行证据先工程续跑,再阻断。**
|
||||||
|
Eino 单代理和 Eino 多代理主链路在 `missing_execution_evidence` 时会先通过已有 trace 自动续跑,不注入额外上下文;达到续跑上限后仍缺证据才写入 blocked。
|
||||||
|
|
||||||
|
6. **HITL 和空输出不会 final。**
|
||||||
|
workflow 等待人工确认、空 assistant 文本、Eino 空输出占位均会写入阻断文案,而不是成功总结。
|
||||||
|
|
||||||
|
## 五、贴合当前项目的推荐架构
|
||||||
|
|
||||||
|
当前采用的链路是:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Agent / Eino ADK events
|
||||||
|
-> event normalizer
|
||||||
|
-> process_details
|
||||||
|
-> finalization verifier
|
||||||
|
-> response(finalized=true)
|
||||||
|
-> messages.content
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1. 后端统一 Finalizer
|
||||||
|
|
||||||
|
职责:
|
||||||
|
|
||||||
|
- 接收 `RunResult` / 候选文本、`mcpExecutionIds`、会话与助手消息 ID、HITL 状态、编排模式。
|
||||||
|
- 通过数据库查询工具执行状态,识别 pending、completed、failed、cancelled 等证据状态。
|
||||||
|
- 返回 `FinalizationDecision`。
|
||||||
|
- 不调用高风险工具,只做状态和证据检查。
|
||||||
|
|
||||||
|
### 2. RunResult 终态字段
|
||||||
|
|
||||||
|
[internal/multiagent/runner.go](../../internal/multiagent/runner.go) 已扩展终态字段:
|
||||||
|
|
||||||
|
```go
|
||||||
|
type RunResult struct {
|
||||||
|
Response string
|
||||||
|
MCPExecutionIDs []string
|
||||||
|
LastAgentTraceInput string
|
||||||
|
LastAgentTraceOutput string
|
||||||
|
|
||||||
|
Finalized bool
|
||||||
|
Status string
|
||||||
|
CompletionReason string
|
||||||
|
EvidenceVerified bool
|
||||||
|
EvidenceRefs []string
|
||||||
|
PendingExecutionIDs []string
|
||||||
|
MissingChecks []string
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 发送 `response` 的条件
|
||||||
|
|
||||||
|
在单代理、多代理、工作流、批处理收尾处统一执行:
|
||||||
|
|
||||||
|
```go
|
||||||
|
decision := h.finalizeAgentRunForDelivery(...)
|
||||||
|
if !decision.Finalizable {
|
||||||
|
sendEvent("finalization_check", "任务尚未达到最终回复条件", decision)
|
||||||
|
sendEvent("response", finalizationBlockedMessage(decision), finalizationResponsePayload(decision, extra))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sendEvent("response", decision.FinalText, finalizationResponsePayload(decision, extra))
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 前端只信 `finalized=true`
|
||||||
|
|
||||||
|
在 [web/static/js/monitor.js](../../web/static/js/monitor.js) 的 `case 'response'` 中执行硬判断:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const responseFinalized = isFinalizedResponseData(responseData);
|
||||||
|
const bubbleText = responseFinalized
|
||||||
|
? resolvedResponseText
|
||||||
|
: (event.message || '任务尚未达到最终回复条件,暂不生成成功结论。');
|
||||||
|
markAssistantFinalizationState(assistantIdFinal, responseData);
|
||||||
|
```
|
||||||
|
|
||||||
|
WebShell 侧同理:`response_delta` 可以用于实时预览,但 UI 文案应标记为“执行中输出”,只有最终 `response(finalized=true)` 才显示为完成态。
|
||||||
|
|
||||||
|
### 5. 各模式 final gate
|
||||||
|
|
||||||
|
| 模式 | 谁可以产出最终候选 | 谁决定 final | 必须检查 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Eino 单代理 | 单代理最后助手文本 | Finalizer | 无 pending tool、证据引用完整、任务状态 terminal |
|
||||||
|
| Deep | 主代理汇总文本 | Finalizer | 子代理结果已汇总;子代理文本不能直接 final;工具状态 terminal |
|
||||||
|
| Plan-Execute | Replanner 结束后的汇总文本 | Replanner + Finalizer | Executor 单步输出不能 final;计划步骤完成或明确 blocked |
|
||||||
|
| Supervisor | Supervisor 的 `exit` / 汇总文本 | Supervisor + Finalizer | transfer 已返回;无未处理专家结果;最终由 supervisor 统一口径 |
|
||||||
|
|
||||||
|
### 6. 安全测试场景的证据 gate
|
||||||
|
|
||||||
|
安全测试、WebShell、批量验证、Workflow 和多代理执行等 evidence-required 场景,最终回复必须至少满足:
|
||||||
|
|
||||||
|
- 有明确目标和授权范围标识。
|
||||||
|
- 有可复核证据引用,例如工具 execution id、请求/响应摘要、截图路径、命令输出摘要、事实/漏洞记录 ID。
|
||||||
|
- 有身份或影响验证结果,而不是只凭 marker 文本判断。
|
||||||
|
- 已记录到项目黑板或漏洞库,或明确说明未绑定项目导致无法记录。
|
||||||
|
- 高风险动作已清理、回滚、取消,或明确说明未执行清理的原因。
|
||||||
|
- 仍在运行的扫描/命令/WebShell/C2 任务不能被隐式当作完成。
|
||||||
|
|
||||||
|
注意:这里的 gate 是治理规则,不要求最终报告暴露敏感利用细节;可以只给证据摘要和内部引用。
|
||||||
|
|
||||||
|
## 六、落地状态与后续增强
|
||||||
|
|
||||||
|
### P0:先修“误 final”(已落地)
|
||||||
|
|
||||||
|
1. 已引入 `FinalizationDecision`。
|
||||||
|
2. 主要 agent SSE `response` 事件已携带 `data.finalized/finalizable/status/completionReason` 等字段。
|
||||||
|
3. 前端 `monitor.js` 和 `webshell.js` 已按 `finalized=true` 区分候选输出和最终回复。
|
||||||
|
4. `RunResult.Response` 仍保留兼容字段名,但语义已由 finalizer 统一提升;后续可再拆成 `CandidateResponse` / `FinalResponse`,减少误用空间。
|
||||||
|
5. Plan-Execute / Deep / Supervisor / Eino Single 等模式均通过统一 handler 收尾 gate。
|
||||||
|
|
||||||
|
### P1:补证据链(部分落地)
|
||||||
|
|
||||||
|
1. 已用 `mcp_execution:<id>` 作为基础 evidence refs。
|
||||||
|
2. `finalization_check` 事件已展示 pending execution 与 missing checks。
|
||||||
|
3. 执行入口已启用显式 execution evidence policy;Eino 主链路在 policy 要求证据且缺少 completed 工具证据时先无注入续跑,达到上限后才阻断 final。
|
||||||
|
4. 后续建议:为 `record_vulnerability`、`upsert_project_fact`、项目黑板记录建立更细粒度 evidence refs。
|
||||||
|
5. 后续建议:最终报告模板固定包含“结论、证据、风险/不确定性、后续动作”。
|
||||||
|
|
||||||
|
### P2:体验和观测(后续增强)
|
||||||
|
|
||||||
|
1. 在任务卡片展示 `in_progress / verifying / finalizing / completed / blocked`。
|
||||||
|
2. 为 finalizer 加日志和指标:误拦截率、缺失证据类型、pending tool 数量。
|
||||||
|
3. 支持“继续验证”按钮,从 `FinalizationDecision.MissingChecks` 自动生成下一轮输入。
|
||||||
|
|
||||||
|
## 七、验收测试建议
|
||||||
|
|
||||||
|
至少加入这些回归测试:
|
||||||
|
|
||||||
|
1. **推理文本不 final**
|
||||||
|
模拟 `reasoning_chain` 里出现看似完成的候选结论,但本轮没有 completed 工具执行证据;预期主消息气泡不显示成功结论,只显示执行中或阻断态。
|
||||||
|
|
||||||
|
2. **主代理阶段性输出不 final**
|
||||||
|
模拟 `response_start/delta` 输出“下一步继续验证”;预期只进入 timeline `planning`。
|
||||||
|
|
||||||
|
3. **未完成后台工具不 final**
|
||||||
|
工具返回 `execution_id` 且状态 `running`;即使模型给出总结,也只能 `in_progress`。
|
||||||
|
|
||||||
|
4. **Plan-Execute Executor 输出不 final**
|
||||||
|
Executor 输出“突破成功”,但 Replanner 未结束;预期不触发 `messages.content` finalize。
|
||||||
|
|
||||||
|
5. **Supervisor 子代理输出不 final**
|
||||||
|
子代理返回确定结论,Supervisor 未 `exit`;预期只进入 `eino_agent_reply`。
|
||||||
|
|
||||||
|
6. **最终事件必须带 finalized**
|
||||||
|
前端收到旧格式 `response` 无 `finalized=true`;预期候选内容只进入详情/警告,主消息显示阻断态,不创建成功最终气泡。
|
||||||
|
|
||||||
|
7. **失败和取消可终态**
|
||||||
|
`error` / `cancelled` 仍可更新助手消息,但 `completionReason` 必须是 `failed` / `user_cancelled`,不能伪装为成功完成。
|
||||||
|
|
||||||
|
## 八、推荐默认策略
|
||||||
|
|
||||||
|
对 CyberStrikeAI,建议默认策略是:
|
||||||
|
|
||||||
|
```text
|
||||||
|
eino_single:轻量任务可用,但 final gate 必须开启
|
||||||
|
deep:复杂安全测试默认推荐
|
||||||
|
plan_execute:目标明确、需要严格“规划-执行-重规划”的任务推荐
|
||||||
|
supervisor:多专家路由任务使用,不作为默认泛化模式
|
||||||
|
```
|
||||||
|
|
||||||
|
最终治理一句话:
|
||||||
|
|
||||||
|
```text
|
||||||
|
messages.content 只能来自 FinalizationDecision.FinalText;
|
||||||
|
process_details 可以展示所有过程;
|
||||||
|
前端只能把 response(finalized=true) 当最终回复。
|
||||||
|
```
|
||||||
@@ -74,6 +74,25 @@ Content-Type: application/json
|
|||||||
- `POST /api/conversations/:id/delete-turn`
|
- `POST /api/conversations/:id/delete-turn`
|
||||||
- `GET /api/messages/:id/process-details`
|
- `GET /api/messages/:id/process-details`
|
||||||
|
|
||||||
|
## 文件管理来源
|
||||||
|
|
||||||
|
文件管理页面和 `/api/chat-uploads` 列表接口会把对话相关文件按来源归类。底层目录仍使用项目 ID 或会话 ID 保持稳定,界面会优先显示项目名或对话标题,完整 ID 可在提示或路径中查看。
|
||||||
|
|
||||||
|
| 来源 | `source` | 典型目录 | 说明 | 可变更性 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| 工作目录 | `workspace` | `tmp/workspace/projects/<projectId>/...`、`tmp/workspace/conversations/<conversationId>/...` | Agent 执行任务时保存下载文件、分析脚本、中间结果和生成的 CSV/XLSX/Markdown 等。用户反馈“AI 生成的文件找不到”时,通常先看这里。 | 只读展示;支持复制路径、下载、导出。 |
|
||||||
|
| 会话产物 | `conversation_artifact` | `data/conversation_artifacts/<conversationId>/...` | 系统按会话归档的交付物或会话级产物,例如总结、报告、模型中间件生成的归档内容。 | 只读展示;支持复制路径、下载、导出。 |
|
||||||
|
| 工具输出 | `reduction` | `tmp/reduction/projects/<projectId>/...`、`tmp/reduction/conversations/<conversationId>/...` | 超长工具输出、扫描原文或被截断前落盘的结果缓存。适合回看完整工具输出。 | 只读展示;支持复制路径、下载、导出。 |
|
||||||
|
| 对话附件 | `upload` | `chat_uploads/<date>/<conversationId>/...` | 用户在对话或文件管理页手动上传的附件。需要让 AI 引用某文件时,可复制服务器绝对路径粘贴到对话中。 | 可上传、新建目录、编辑文本文件、重命名、删除、复制路径、下载、导出。 |
|
||||||
|
|
||||||
|
相关接口:
|
||||||
|
|
||||||
|
- `GET /api/chat-uploads`:按来源、项目、会话、文件名筛选文件。
|
||||||
|
- `GET /api/chat-uploads/path`:把文件管理中的相对路径或内部虚拟路径解析为服务器绝对路径,用于复制文件或目录路径。
|
||||||
|
- `GET /api/chat-uploads/download`:下载指定文件。
|
||||||
|
- `GET /api/chat-uploads/export`:导出当前筛选结果为 ZIP。
|
||||||
|
- `POST /api/chat-uploads`:上传到对话附件目录。
|
||||||
|
|
||||||
## 项目、漏洞、攻击链
|
## 项目、漏洞、攻击链
|
||||||
|
|
||||||
项目:
|
项目:
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ ai:
|
|||||||
| `ai.default_channel` | 默认通道 ID。新对话、机器人、批量任务和未显式选择通道的请求使用它。 |
|
| `ai.default_channel` | 默认通道 ID。新对话、机器人、批量任务和未显式选择通道的请求使用它。 |
|
||||||
| `ai.channels.<id>` | 通道配置。ID 会归一化为小写、数字和短横线,例如 `Qwen_Max` 会变成 `qwen-max`。 |
|
| `ai.channels.<id>` | 通道配置。ID 会归一化为小写、数字和短横线,例如 `Qwen_Max` 会变成 `qwen-max`。 |
|
||||||
| `name` | Web UI 展示名。留空时使用通道 ID。 |
|
| `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`。 |
|
| `base_url/api_key/model` | 必填。Base URL 通常需要包含版本路径,如 OpenAI/兼容网关的 `/v1`。 |
|
||||||
| `max_total_tokens` | 上下文压缩、攻击链构建、多代理摘要等共用的总预算。 |
|
| `max_total_tokens` | 上下文压缩、攻击链构建、多代理摘要等共用的总预算。 |
|
||||||
| `max_completion_tokens` | 单次模型输出上限;未填时使用默认值。 |
|
| `max_completion_tokens` | 单次模型输出上限;未填时使用默认值。 |
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
- Python:部分 MCP 服务或工具脚本需要 Python 运行环境。
|
- Python:部分 MCP 服务或工具脚本需要 Python 运行环境。
|
||||||
- SQLite:默认使用文件型数据库,无需单独服务。
|
- SQLite:默认使用文件型数据库,无需单独服务。
|
||||||
- 安全工具:`tools/` 中的 YAML 只是工具定义,实际命令如 `nmap`、`sqlmap`、`nuclei` 仍需安装到系统 PATH。
|
- 安全工具:`tools/` 中的 YAML 只是工具定义,实际命令如 `nmap`、`sqlmap`、`nuclei` 仍需安装到系统 PATH。
|
||||||
- 模型服务:至少配置一个 `ai.channels` 通道;`provider: openai_compatible` 适用于 OpenAI 兼容 API,`provider: claude` 会走 Claude 桥接。
|
- 模型服务:至少配置一个 `ai.channels` 通道;`provider: openai_compatible` 适用于 OpenAI 兼容 API,`provider: claude` 使用 Eino 原生 Claude 组件。
|
||||||
|
|
||||||
建议目录:
|
建议目录:
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ hitl:
|
|||||||
api_key: ""
|
api_key: ""
|
||||||
model: "" # 可填小模型;留空复用默认 AI 通道的模型
|
model: "" # 可填小模型;留空复用默认 AI 通道的模型
|
||||||
retention_days: 90
|
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 使用更便宜的小模型。
|
`audit_model` 的字段可以只填一部分。空字段会自动继承默认 AI 通道解析后的模型配置,因此常见做法是只填 `model`,让审计 Agent 使用更便宜的小模型。
|
||||||
@@ -91,10 +91,16 @@ hitl:
|
|||||||
白名单工具会跳过审批,因此要保守维护。推荐放:
|
白名单工具会跳过审批,因此要保守维护。推荐放:
|
||||||
|
|
||||||
- `read_file`
|
- `read_file`
|
||||||
- `list_dir`
|
- `ls`
|
||||||
- `glob`
|
- `glob`
|
||||||
- `grep`
|
- `grep`
|
||||||
- `tool_search`
|
- `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 任务工具
|
- C2 任务工具
|
||||||
- WebShell 命令执行工具
|
- WebShell 命令执行工具
|
||||||
|
- 会向目标或外部服务发起请求的“只读”工具,例如 `webshell_file_read`、`webshell_file_list` 和 `search_knowledge_base`
|
||||||
|
- 同一工具名同时包含读写 action 的复合工具,例如 `c2_session`、`c2_listener`、`c2_profile`、`c2_task_manage`
|
||||||
|
|
||||||
## 模式选择
|
## 模式选择
|
||||||
|
|
||||||
|
|||||||
@@ -32,13 +32,21 @@ https://127.0.0.1:8080/
|
|||||||
|
|
||||||
如果仍有其他具备 `rbac:write` 权限的管理员账号,优先在 **平台权限 → 用户管理** 中重置密码。
|
如果仍有其他具备 `rbac:write` 权限的管理员账号,优先在 **平台权限 → 用户管理** 中重置密码。
|
||||||
|
|
||||||
如果没有可用的管理员会话,可在服务器上紧急重置内置 `admin` 账号。先停止 CyberStrikeAI 服务并备份数据库,然后在项目根目录执行以下命令,按提示输入并确认新密码:
|
如果没有可用的管理员会话,可在服务器上紧急重置内置 `admin` 账号。在项目根目录执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./run.sh --reset-admin-password
|
||||||
|
```
|
||||||
|
|
||||||
|
按提示输入并确认新密码。脚本会隐藏输入并写入 bcrypt 哈希。如果服务正在运行,完成后重新启动服务,使原有登录会话失效。
|
||||||
|
|
||||||
|
如果无法使用 `run.sh`,也可以手动执行以下命令,按提示输入并确认新密码:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
HASH=$(htpasswd -nBC 10 '' | cut -d: -f2 | tr -d '\n') && sqlite3 data/conversations.db "UPDATE rbac_users SET password_hash='$HASH', updated_at=CURRENT_TIMESTAMP WHERE id='admin' AND username='admin' AND is_builtin=1; SELECT changes();"
|
HASH=$(htpasswd -nBC 10 '' | cut -d: -f2 | tr -d '\n') && sqlite3 data/conversations.db "UPDATE rbac_users SET password_hash='$HASH', updated_at=CURRENT_TIMESTAMP WHERE id='admin' AND username='admin' AND is_builtin=1; SELECT changes();"
|
||||||
```
|
```
|
||||||
|
|
||||||
输出 `1` 表示修改成功。该命令需要 `sqlite3` 和 `htpasswd`;如果 `config.yaml` 中的 `database.path` 不是默认值,请替换 `data/conversations.db`。密码输入不会显示,也不会写入 Shell 历史,并以 bcrypt 哈希保存。完成后重新启动服务,使原有登录会话失效。
|
输出 `1` 表示修改成功。该命令需要 `sqlite3` 和 `htpasswd`;如果 `config.yaml` 中的 `database.path` 不是默认值,请替换 `data/conversations.db`。密码输入不会显示,也不会写入 Shell 历史。
|
||||||
|
|
||||||
## 模型无响应
|
## 模型无响应
|
||||||
|
|
||||||
|
|||||||
@@ -34,7 +34,39 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 三、执行模型(先理解再配置)
|
## 三、自然语言生成草稿
|
||||||
|
|
||||||
|
工作流页面提供 **用自然语言创建** 入口。用户描述一句安全作业目标后,平台会生成一个可编辑草稿,并返回结构化审计结果:
|
||||||
|
|
||||||
|
- 只生成草稿,不会自动保存、试运行或真实执行工具。
|
||||||
|
- 服务端接口为 `POST /api/workflows/generate-draft`,权限为 `workflow:write`。
|
||||||
|
- 生成结果包含 `graph`、`meta`、`capabilities`、`audit` 和 `stats`,前端应用到画布后仍走现有保存校验。
|
||||||
|
- 高风险语义(执行脚本、隔离、封禁、删除、利用等)会标记 `high_risk`,并默认插入 HITL 审批或 `requires_human_confirmation`。
|
||||||
|
- 工具能力按已有工具列表匹配;未匹配到时降级为 Agent 草稿,并在 `audit.missing_fields` / `audit.assumptions` 中提示需要补配置。
|
||||||
|
- 如果服务端生成不可用,前端会使用本地确定性兜底生成器,继续给出可编辑草稿和风险提示。
|
||||||
|
|
||||||
|
示例请求:
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/workflows/generate-draft
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"prompt": "对目标资产做端口扫描,如果发现高危端口就创建漏洞任务并通知负责人审批,最后输出报告",
|
||||||
|
"options": {
|
||||||
|
"include_objective": true,
|
||||||
|
"allow_schedule": false,
|
||||||
|
"allow_high_risk": false
|
||||||
|
},
|
||||||
|
"available_tools": [
|
||||||
|
{ "key": "nmap", "name": "nmap", "enabled": true }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、执行模型(先理解再配置)
|
||||||
|
|
||||||
工作流按 **有向图** 执行,引擎从 **开始** 节点出发,沿连线依次运行下游节点。
|
工作流按 **有向图** 执行,引擎从 **开始** 节点出发,沿连线依次运行下游节点。
|
||||||
|
|
||||||
@@ -116,7 +148,7 @@ outputs["你填的变量名"] = 节点输出内容
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 四、模板语法
|
## 五、模板语法
|
||||||
|
|
||||||
### 4.1 基本格式
|
### 4.1 基本格式
|
||||||
|
|
||||||
@@ -198,7 +230,7 @@ jq({{outputs.scan}}, ".severity") == "high"
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 五、节点类型与配置
|
## 六、节点类型与配置
|
||||||
|
|
||||||
### 5.1 开始(start)
|
### 5.1 开始(start)
|
||||||
|
|
||||||
@@ -318,7 +350,7 @@ HITL 等待信息会记录:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 六、连线配置
|
## 七、连线配置
|
||||||
|
|
||||||
选中 **连线** 后,右侧可配置 **连线条件**。
|
选中 **连线** 后,右侧可配置 **连线条件**。
|
||||||
|
|
||||||
@@ -335,7 +367,7 @@ HITL 等待信息会记录:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 七、完整示例:跨条件节点传递 Agent 输出
|
## 八、完整示例:跨条件节点传递 Agent 输出
|
||||||
|
|
||||||
### 7.1 流程结构
|
### 7.1 流程结构
|
||||||
|
|
||||||
@@ -384,7 +416,7 @@ HITL 等待信息会记录:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 八、绑定角色并运行
|
## 九、绑定角色并运行
|
||||||
|
|
||||||
### 8.1 在角色管理中绑定
|
### 8.1 在角色管理中绑定
|
||||||
|
|
||||||
@@ -414,7 +446,7 @@ workflow_policy: auto
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 九、调试、试运行与复盘
|
## 十、调试、试运行与复盘
|
||||||
|
|
||||||
### 9.1 安全试运行(dry-run)
|
### 9.1 安全试运行(dry-run)
|
||||||
|
|
||||||
@@ -487,7 +519,7 @@ token 与成本是否存在取决于底层模型/Agent 事件是否上报 usage
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 十、保存前校验规则
|
## 十一、保存前校验规则
|
||||||
|
|
||||||
保存时系统会自动检查:
|
保存时系统会自动检查:
|
||||||
|
|
||||||
@@ -510,7 +542,7 @@ token 与成本是否存在取决于底层模型/Agent 事件是否上报 usage
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 十一、排错指南
|
## 十二、排错指南
|
||||||
|
|
||||||
| 现象 | 可能原因 | 处理建议 |
|
| 现象 | 可能原因 | 处理建议 |
|
||||||
|------|----------|----------|
|
|------|----------|----------|
|
||||||
@@ -526,7 +558,7 @@ token 与成本是否存在取决于底层模型/Agent 事件是否上报 usage
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 十二、最佳实践
|
## 十三、最佳实践
|
||||||
|
|
||||||
1. **命名规范**:为每个需要被引用的节点设置有意义的输出变量名,如 `scan_result`、`parsed_targets`,避免都叫 `agent_result`。
|
1. **命名规范**:为每个需要被引用的节点设置有意义的输出变量名,如 `scan_result`、`parsed_targets`,避免都叫 `agent_result`。
|
||||||
2. **跨节点传参优先用 `outputs`**:只要中间可能插入条件、工具、审批节点,就应用命名变量。
|
2. **跨节点传参优先用 `outputs`**:只要中间可能插入条件、工具、审批节点,就应用命名变量。
|
||||||
@@ -539,7 +571,7 @@ token 与成本是否存在取决于底层模型/Agent 事件是否上报 usage
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 十三、相关代码位置(开发者参考)
|
## 十四、相关代码位置(开发者参考)
|
||||||
|
|
||||||
| 模块 | 路径 |
|
| 模块 | 路径 |
|
||||||
|------|------|
|
|------|------|
|
||||||
|
|||||||
@@ -3,17 +3,19 @@ module cyberstrike-ai
|
|||||||
// 若 go mod download 超时,可执行: go env -w GOPROXY=https://goproxy.cn,direct
|
// 若 go mod download 超时,可执行: go env -w GOPROXY=https://goproxy.cn,direct
|
||||||
// 或使用 scripts/bootstrap-go.sh
|
// 或使用 scripts/bootstrap-go.sh
|
||||||
|
|
||||||
go 1.25
|
go 1.25.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/bwmarrin/discordgo v0.29.0
|
github.com/bwmarrin/discordgo v0.29.0
|
||||||
github.com/bytedance/sonic v1.15.0
|
github.com/bytedance/sonic v1.15.0
|
||||||
github.com/cloudwego/eino v0.8.13
|
github.com/cloudwego/eino v0.9.14
|
||||||
github.com/cloudwego/eino-ext/adk/backend/local v0.0.0-20260416081055-0ebab92e14f2
|
github.com/cloudwego/eino-ext/adk/backend/local v0.2.6
|
||||||
github.com/cloudwego/eino-ext/components/document/loader/file v0.0.0-20260427010451-749e3706378b
|
github.com/cloudwego/eino-ext/components/document/loader/file v0.0.0-20260427010451-749e3706378b
|
||||||
github.com/cloudwego/eino-ext/components/document/transformer/splitter/markdown v0.0.0-20260427010451-749e3706378b
|
github.com/cloudwego/eino-ext/components/document/transformer/splitter/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/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/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/cloudwego/eino-ext/components/model/openai v0.1.13
|
||||||
github.com/creack/pty v1.1.24
|
github.com/creack/pty v1.1.24
|
||||||
github.com/disintegration/imaging v1.6.2
|
github.com/disintegration/imaging v1.6.2
|
||||||
@@ -23,52 +25,84 @@ require (
|
|||||||
github.com/gorilla/websocket v1.5.3
|
github.com/gorilla/websocket v1.5.3
|
||||||
github.com/larksuite/oapi-sdk-go/v3 v3.4.22
|
github.com/larksuite/oapi-sdk-go/v3 v3.4.22
|
||||||
github.com/mattn/go-sqlite3 v1.14.18
|
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/open-dingtalk/dingtalk-stream-sdk-go v0.9.1
|
||||||
github.com/pkoukk/tiktoken-go v0.1.8
|
github.com/pkoukk/tiktoken-go v0.1.8
|
||||||
github.com/robfig/cron/v3 v3.0.1
|
github.com/robfig/cron/v3 v3.0.1
|
||||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
|
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
|
||||||
github.com/slack-go/slack v0.27.0
|
github.com/slack-go/slack v0.27.0
|
||||||
github.com/tencent-connect/botgo v0.2.1
|
github.com/tencent-connect/botgo v0.2.1
|
||||||
go.opentelemetry.io/otel v1.34.0
|
go.opentelemetry.io/otel v1.39.0
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.34.0
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.34.0
|
||||||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.34.0
|
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.34.0
|
||||||
go.opentelemetry.io/otel/sdk v1.34.0
|
go.opentelemetry.io/otel/sdk v1.39.0
|
||||||
go.opentelemetry.io/otel/trace v1.34.0
|
go.opentelemetry.io/otel/trace v1.39.0
|
||||||
go.uber.org/zap v1.26.0
|
go.uber.org/zap v1.26.0
|
||||||
golang.org/x/net v0.35.0
|
golang.org/x/crypto v0.50.0
|
||||||
golang.org/x/text v0.26.0
|
golang.org/x/net v0.53.0
|
||||||
|
golang.org/x/term v0.42.0
|
||||||
|
golang.org/x/text v0.37.0
|
||||||
golang.org/x/time v0.14.0
|
golang.org/x/time v0.14.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|
||||||
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/bahlo/generic-list-go v0.2.0 // indirect
|
||||||
github.com/bmatcuk/doublestar/v4 v4.10.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/gopkg v0.1.3 // indirect
|
||||||
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||||
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
|
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||||
github.com/cloudwego/eino-ext/libs/acl/openai v0.1.17 // indirect
|
github.com/cloudwego/eino-ext/libs/acl/openai v0.1.18-0.20260527084435-846f52bd97c6 // indirect
|
||||||
github.com/dlclark/regexp2 v1.10.0 // indirect
|
github.com/dlclark/regexp2 v1.10.0 // indirect
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/evanphx/json-patch v0.5.2 // 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/gabriel-vasile/mimetype v1.4.2 // indirect
|
||||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||||
github.com/go-logr/logr v1.4.2 // indirect
|
github.com/go-logr/logr v1.4.3 // indirect
|
||||||
github.com/go-logr/stdr v1.2.2 // indirect
|
github.com/go-logr/stdr v1.2.2 // indirect
|
||||||
github.com/go-playground/locales v0.14.1 // indirect
|
github.com/go-playground/locales v0.14.1 // indirect
|
||||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
github.com/go-playground/validator/v10 v10.14.0 // indirect
|
github.com/go-playground/validator/v10 v10.14.0 // indirect
|
||||||
github.com/go-resty/resty/v2 v2.6.0 // indirect
|
github.com/go-resty/resty/v2 v2.6.0 // indirect
|
||||||
|
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
|
||||||
github.com/goccy/go-json v0.10.2 // indirect
|
github.com/goccy/go-json v0.10.2 // indirect
|
||||||
github.com/gogo/protobuf v1.3.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/goph/emperror v0.17.2 // indirect
|
||||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.25.1 // 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/json-iterator/go v1.1.12 // indirect
|
||||||
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
|
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
|
||||||
|
github.com/klippa-app/go-pdfium v1.19.3 // indirect
|
||||||
github.com/leodido/go-urn v1.2.4 // indirect
|
github.com/leodido/go-urn v1.2.4 // indirect
|
||||||
github.com/mailru/easyjson v0.9.0 // indirect
|
github.com/mailru/easyjson v0.9.0 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.19 // indirect
|
github.com/mattn/go-isatty v0.0.19 // indirect
|
||||||
@@ -76,34 +110,45 @@ require (
|
|||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
github.com/nikolalohinski/gonja v1.5.3 // 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/pelletier/go-toml/v2 v2.2.3 // indirect
|
||||||
github.com/pkg/errors v0.9.1 // 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/sirupsen/logrus v1.9.3 // indirect
|
||||||
github.com/slongfield/pyfmt v0.0.0-20220222012616-ea85ff4c361f // indirect
|
github.com/slongfield/pyfmt v0.0.0-20220222012616-ea85ff4c361f // indirect
|
||||||
github.com/tidwall/gjson v1.9.3 // indirect
|
github.com/standard-webhooks/standard-webhooks/libraries v0.0.1 // indirect
|
||||||
|
github.com/tetratelabs/wazero v1.11.0 // indirect
|
||||||
|
github.com/tidwall/gjson v1.18.0 // indirect
|
||||||
github.com/tidwall/match v1.1.1 // indirect
|
github.com/tidwall/match v1.1.1 // indirect
|
||||||
github.com/tidwall/pretty v1.2.0 // indirect
|
github.com/tidwall/pretty v1.2.1 // indirect
|
||||||
|
github.com/tidwall/sjson v1.2.5 // indirect
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
github.com/ugorji/go/codec v1.2.11 // indirect
|
github.com/ugorji/go/codec v1.2.11 // indirect
|
||||||
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
|
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
|
||||||
github.com/yargevad/filepathx v1.0.0 // indirect
|
github.com/yargevad/filepathx v1.0.0 // indirect
|
||||||
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
||||||
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
|
go.opencensus.io v0.24.0 // indirect
|
||||||
|
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||||
|
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.49.0 // indirect
|
||||||
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.34.0 // indirect
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.34.0 // indirect
|
||||||
go.opentelemetry.io/otel/metric v1.34.0 // indirect
|
go.opentelemetry.io/otel/metric v1.39.0 // indirect
|
||||||
go.opentelemetry.io/proto/otlp v1.5.0 // indirect
|
go.opentelemetry.io/proto/otlp v1.5.0 // indirect
|
||||||
go.uber.org/multierr v1.11.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/arch v0.15.0 // indirect
|
||||||
golang.org/x/crypto v0.39.0 // indirect
|
|
||||||
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect
|
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect
|
||||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 // indirect
|
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 // indirect
|
||||||
golang.org/x/oauth2 v0.30.0 // indirect
|
golang.org/x/oauth2 v0.34.0 // indirect
|
||||||
golang.org/x/sync v0.15.0 // indirect
|
golang.org/x/sync v0.20.0 // indirect
|
||||||
golang.org/x/sys v0.33.0 // indirect
|
golang.org/x/sys v0.43.0 // indirect
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20250115164207-1a7da9e5054f // indirect
|
google.golang.org/api v0.189.0 // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250115164207-1a7da9e5054f // indirect
|
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 // indirect
|
||||||
google.golang.org/grpc v1.69.4 // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
|
||||||
google.golang.org/protobuf v1.36.3 // indirect
|
google.golang.org/grpc v1.79.3 // indirect
|
||||||
|
google.golang.org/protobuf v1.36.10 // indirect
|
||||||
)
|
)
|
||||||
|
|
||||||
// 修复钉钉 Stream SDK 在长连接断开(熄屏/网络中断)后 "panic: send on closed channel" 问题
|
// 修复钉钉 Stream SDK 在长连接断开(熄屏/网络中断)后 "panic: send on closed channel" 问题
|
||||||
|
|||||||
@@ -1,36 +1,88 @@
|
|||||||
|
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||||
|
cloud.google.com/go/auth v0.7.2 h1:uiha352VrCDMXg+yoBtaD0tUF4Kv9vrtrWPYXwutnDE=
|
||||||
|
cloud.google.com/go/auth v0.7.2/go.mod h1:VEc4p5NNxycWQTMQEDQF0bd6aTMb6VgYDXEwiJJQAbs=
|
||||||
|
cloud.google.com/go/auth/oauth2adapt v0.2.3 h1:MlxF+Pd3OmSudg/b1yZ5lJwoXCEaeedAguodky1PcKI=
|
||||||
|
cloud.google.com/go/auth/oauth2adapt v0.2.3/go.mod h1:tMQXOfZzFuNuUxOypHlQEXgdfX5cuhwU+ffUuXRJE8I=
|
||||||
cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=
|
cloud.google.com/go/compute/metadata v0.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/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 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
|
||||||
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
|
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/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 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs=
|
||||||
github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
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/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.2 h1:frqHqw7otoVbk5M8LlE/L7HTnIq2v9RX6EJ48i9AxJk=
|
||||||
github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
|
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/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/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 h1:FmWeXFaKUwrcL3Cx65c20bTRW+vOb6k8AnaP+EgjDno=
|
||||||
github.com/bwmarrin/discordgo v0.29.0/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY=
|
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 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||||
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
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.4.6 h1:pPkAFB6yiaaybvgp7DP1Rj4Ztiew3nsaMizoNkzsvNA=
|
||||||
github.com/bytedance/mockey v1.3.0/go.mod h1:1BPHF9sol5R1ud/+0VEHGQq/+i2lN+GTsr3O2Q9IENY=
|
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 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
||||||
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
|
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 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
|
||||||
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
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 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
|
||||||
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
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/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.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.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 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||||
github.com/cloudwego/eino v0.8.13 h1:z5dhaZNN8TWZbP/lgKxGmF26Ii8fPeUlQCGV/NTtms0=
|
github.com/cloudwego/eino v0.9.14 h1:suNVibjtkPMiW7csFBdBqN3FRG0nRlCqoGKE7t0UDwY=
|
||||||
github.com/cloudwego/eino v0.8.13/go.mod h1:+2N4nsMPxA6kGBHpH+75JuTfEcGprAMTdsZESrShKpU=
|
github.com/cloudwego/eino v0.9.14/go.mod h1:OBD1mrkfkt/pJa4rkg1P0VnaMeOVl7l8IAdEqY//3IQ=
|
||||||
github.com/cloudwego/eino-ext/adk/backend/local v0.0.0-20260416081055-0ebab92e14f2 h1:v2w9TyLAmNsMWo8NwntCc76uvNf6isTFkHB+oZZ8NqI=
|
github.com/cloudwego/eino-ext/adk/backend/local v0.2.6 h1:pxKAWcveIBWrbz28H2bDAMyJZnhysa9bh2Kh5s7iH7s=
|
||||||
github.com/cloudwego/eino-ext/adk/backend/local v0.0.0-20260416081055-0ebab92e14f2/go.mod h1:os5Tq5FuSoz/MLqAdZER3ip49Oef9prc0kVsKsPYO48=
|
github.com/cloudwego/eino-ext/adk/backend/local v0.2.6/go.mod h1:uJlpYclMA2ScgUlHWHu2om+DDguV2Lzc+TNulWBXqfo=
|
||||||
github.com/cloudwego/eino-ext/components/document/loader/file v0.0.0-20260427010451-749e3706378b h1:GIOC/VnXuSQx79mnQ3HgMvECjtyqvpJipmSUTFFfVsc=
|
github.com/cloudwego/eino-ext/components/document/loader/file v0.0.0-20260427010451-749e3706378b h1:GIOC/VnXuSQx79mnQ3HgMvECjtyqvpJipmSUTFFfVsc=
|
||||||
github.com/cloudwego/eino-ext/components/document/loader/file v0.0.0-20260427010451-749e3706378b/go.mod h1:HnxTQxmhuev6zaBl92EHUy/vEDWCuoE/OE4cTiF5JCg=
|
github.com/cloudwego/eino-ext/components/document/loader/file v0.0.0-20260427010451-749e3706378b/go.mod h1:HnxTQxmhuev6zaBl92EHUy/vEDWCuoE/OE4cTiF5JCg=
|
||||||
github.com/cloudwego/eino-ext/components/document/transformer/splitter/markdown v0.0.0-20260427010451-749e3706378b h1:3owjV4nv+XRplavTeqFlCeAV4v7EHR2tIXDqLEmPc38=
|
github.com/cloudwego/eino-ext/components/document/transformer/splitter/markdown v0.0.0-20260427010451-749e3706378b h1:3owjV4nv+XRplavTeqFlCeAV4v7EHR2tIXDqLEmPc38=
|
||||||
@@ -39,10 +91,17 @@ github.com/cloudwego/eino-ext/components/document/transformer/splitter/recursive
|
|||||||
github.com/cloudwego/eino-ext/components/document/transformer/splitter/recursive v0.0.0-20260427010451-749e3706378b/go.mod h1:9R0RQrQSpg1JaNnRtw7+RfRAAv0HgdE348YnrlZ6coo=
|
github.com/cloudwego/eino-ext/components/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 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/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 h1:5XHRTiTD5bt9KQrMHcfvuWNklEC3tpm3XHejdozt9vM=
|
||||||
github.com/cloudwego/eino-ext/components/model/openai v0.1.13/go.mod h1:mgIoqYYOc0eECCqvLbEYpOJrQNTNxkwXzSJzFU+v5sQ=
|
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.18-0.20260527084435-846f52bd97c6 h1:ES/xufN5eqJ3h+9tw/tq6F8kkgnAxBAHVUB6nqKsIDU=
|
||||||
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/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.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||||
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
||||||
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
||||||
@@ -54,12 +113,27 @@ github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1
|
|||||||
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
|
github.com/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 h1:+/GIL799phkJqYW+3YbOd8LCcbHzT0Pbo8zl70MHsq0=
|
||||||
github.com/dlclark/regexp2 v1.10.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
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 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
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 h1:2Kfsm1xlMV0ssY2nuxshS4AwbLFuqmPmzIjLVJ1Fsp0=
|
||||||
github.com/eino-contrib/jsonschema v1.0.3/go.mod h1:cpnX4SyKjWjGC7iN2EbhxaTdLqGjCi0e9DxpLYxddD4=
|
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 h1:xVCHIVMUu1wtM/VkR9jVZ45N3FhZfYMMYGorLCR8P3k=
|
||||||
github.com/evanphx/json-patch v0.5.2/go.mod h1:ZWS5hhDbVDyob71nXKNL0+PWn6ToqBHMikGIFbs31qQ=
|
github.com/evanphx/json-patch v0.5.2/go.mod h1:ZWS5hhDbVDyob71nXKNL0+PWn6ToqBHMikGIFbs31qQ=
|
||||||
|
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||||
|
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||||
|
github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8vw=
|
||||||
|
github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g=
|
||||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||||
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
|
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
|
github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
|
||||||
@@ -72,8 +146,8 @@ github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SU
|
|||||||
github.com/go-check/check v0.0.0-20180628173108-788fd7840127 h1:0gkP6mzaMqkmpcJYCFOLkIBwI7xFExG03bbkOkCvUPI=
|
github.com/go-check/check v0.0.0-20180628173108-788fd7840127 h1:0gkP6mzaMqkmpcJYCFOLkIBwI7xFExG03bbkOkCvUPI=
|
||||||
github.com/go-check/check v0.0.0-20180628173108-788fd7840127/go.mod h1:9ES+weclKsC9YodN5RgxqK/VD9HM9JsCSh7rNhMZE98=
|
github.com/go-check/check v0.0.0-20180628173108-788fd7840127/go.mod h1:9ES+weclKsC9YodN5RgxqK/VD9HM9JsCSh7rNhMZE98=
|
||||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||||
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
|
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||||
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||||
@@ -87,9 +161,14 @@ github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QX
|
|||||||
github.com/go-redis/redis/v8 v8.11.4/go.mod h1:2Z2wHZXdQpCDXEGzqMockDpNyYvi2l4Pxt6RJr792+w=
|
github.com/go-redis/redis/v8 v8.11.4/go.mod h1:2Z2wHZXdQpCDXEGzqMockDpNyYvi2l4Pxt6RJr792+w=
|
||||||
github.com/go-resty/resty/v2 v2.6.0 h1:joIR5PNLM2EFqqESUjCMGXrWmXNHEU9CEiK813oKYS4=
|
github.com/go-resty/resty/v2 v2.6.0 h1:joIR5PNLM2EFqqESUjCMGXrWmXNHEU9CEiK813oKYS4=
|
||||||
github.com/go-resty/resty/v2 v2.6.0/go.mod h1:PwvJS6hvaPkjtjNg9ph+VrSD92bi5Zq73w/BIH7cC3Q=
|
github.com/go-resty/resty/v2 v2.6.0/go.mod h1:PwvJS6hvaPkjtjNg9ph+VrSD92bi5Zq73w/BIH7cC3Q=
|
||||||
|
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0 h1:p104kn46Q8WdvHunIJ9dAyjPVtrBPhSr3KT2yUst43I=
|
||||||
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
|
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
|
||||||
|
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
|
||||||
|
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
|
||||||
github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U=
|
github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U=
|
||||||
github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
|
github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
|
||||||
|
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
|
||||||
|
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||||
github.com/gofrs/uuid v3.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
|
github.com/gofrs/uuid v3.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
|
||||||
@@ -97,31 +176,49 @@ github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
|||||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
github.com/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 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
|
||||||
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
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.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/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.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.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-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.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.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.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||||
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
|
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 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
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.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.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.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.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.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.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 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
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/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.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8=
|
||||||
github.com/google/jsonschema-go v0.3.0/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
|
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.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 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
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 h1:yLapQcmEsO0ipe9p5TaN22djm3OFV/TfM/fcYP0/J18=
|
||||||
github.com/goph/emperror v0.17.2/go.mod h1:+ZbQ+fUNO/6FNiUo0ujtMjhgad9Xa6fQL9KhH4LNHic=
|
github.com/goph/emperror v0.17.2/go.mod h1:+ZbQ+fUNO/6FNiUo0ujtMjhgad9Xa6fQL9KhH4LNHic=
|
||||||
github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g=
|
github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g=
|
||||||
@@ -133,7 +230,11 @@ github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/ad
|
|||||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.25.1 h1:VNqngBF40hVlDloBruUehVYC3ArSgIyScOAyMRqBxRg=
|
github.com/grpc-ecosystem/grpc-gateway/v2 v2.25.1 h1:VNqngBF40hVlDloBruUehVYC3ArSgIyScOAyMRqBxRg=
|
||||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.25.1/go.mod h1:RBRO7fro65R6tjKzYgLAFo0t1QEXY1Dp+i/bvpRiqiQ=
|
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/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/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
|
||||||
|
github.com/jolestar/go-commons-pool/v2 v2.1.2 h1:E+XGo58F23t7HtZiC/W6jzO2Ux2IccSH/yx4nD+J1CM=
|
||||||
|
github.com/jolestar/go-commons-pool/v2 v2.1.2/go.mod h1:r4NYccrkS5UqP1YQI1COyTZ9UjPJAAGTUxzcsK1kqhY=
|
||||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||||
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
|
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
|
||||||
@@ -143,6 +244,8 @@ github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI
|
|||||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||||
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
|
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
|
||||||
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||||
|
github.com/klippa-app/go-pdfium v1.19.3 h1:3UVRNqA6F4XRwKXClYLwM8RMX+J5nmF5m/Kd4QA8dZQ=
|
||||||
|
github.com/klippa-app/go-pdfium v1.19.3/go.mod h1:9SpxpYVWG1EKkwc3+gFw3ykmaT30IohxxaL58l/97bs=
|
||||||
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
||||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||||
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||||
@@ -153,14 +256,16 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
|||||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
github.com/kr/text v0.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 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
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 h1:57daKuslQPX9X3hC2idc5bu8bl2krfsBGWGJ6b5FlD8=
|
||||||
github.com/larksuite/oapi-sdk-go/v3 v3.4.22/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI=
|
github.com/larksuite/oapi-sdk-go/v3 v3.4.22/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI=
|
||||||
github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
|
github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
|
||||||
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
|
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
|
||||||
github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4=
|
github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4=
|
||||||
github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
|
github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
|
||||||
github.com/mattn/go-colorable v0.1.2 h1:/bC9yWikZXAL9uJdulbSfyVNIR3n3trXl+v8+1sx8mU=
|
github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40=
|
||||||
github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
|
github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
|
||||||
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
|
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
|
||||||
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
github.com/mattn/go-sqlite3 v1.14.18 h1:JL0eqdCOq6DJVNPSvArO/bIV9/P7fbGrV00LZHc+5aI=
|
github.com/mattn/go-sqlite3 v1.14.18 h1:JL0eqdCOq6DJVNPSvArO/bIV9/P7fbGrV00LZHc+5aI=
|
||||||
@@ -169,8 +274,8 @@ github.com/meguminnnnnnnnn/go-openai v0.1.2 h1:iXombGGjqjBrmE9WaSidUhhi3YQhf42QT
|
|||||||
github.com/meguminnnnnnnnn/go-openai v0.1.2/go.mod h1:qs96ysDmxhE4BZoU45I43zcyfnaYxU3X+aRzLko/htY=
|
github.com/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 h1:j7+1HpAFS1zy5+Q4qx1fWh90gTKwiN4QCGoY9TWyyO4=
|
||||||
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
|
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.3.1 h1:TfqtNKOIWN4Z1oqmPAiWDC2Jq7K9OdJaooe0teoXASI=
|
||||||
github.com/modelcontextprotocol/go-sdk v1.2.0/go.mod h1:6fM3LCm3yV7pAs8isnKLn07oKtB0MP9LHd3DfAcKw10=
|
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-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 h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
@@ -184,20 +289,34 @@ github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+W
|
|||||||
github.com/onsi/ginkgo v1.8.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
github.com/onsi/ginkgo v1.8.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||||
github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
|
github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
|
||||||
github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0=
|
github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0=
|
||||||
|
github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE=
|
||||||
|
github.com/onsi/ginkgo/v2 v2.28.3 h1:4JvMdwtFU0imd8fHx25OJXoDMRexnf8v5NHKYSTTji4=
|
||||||
|
github.com/onsi/ginkgo/v2 v2.28.3/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44=
|
||||||
github.com/onsi/gomega v1.5.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
|
github.com/onsi/gomega v1.5.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
|
||||||
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
|
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
|
||||||
github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
|
github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
|
||||||
github.com/onsi/gomega v1.16.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY=
|
github.com/onsi/gomega v1.16.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY=
|
||||||
|
github.com/onsi/gomega v1.40.0 h1:Vtol0e1MghCD2ZVIilPDIg44XSL9l2QAn8ZNaljWcJc=
|
||||||
|
github.com/onsi/gomega v1.40.0/go.mod h1:M/Uqpu/8qTjtzCLUA2zJHX9Iilrau25x1PdoSRbWh5A=
|
||||||
|
github.com/openai/openai-go/v3 v3.35.0 h1:109x3epXMSE423KW2euR506GGFezcEt0s87MoWejpH0=
|
||||||
|
github.com/openai/openai-go/v3 v3.35.0/go.mod h1:cdufnVK14cWcT9qA1rRtrXx4FTRsgbDPW7Ia7SS5cZo=
|
||||||
|
github.com/pb33f/ordered-map/v2 v2.3.1 h1:5319HDO0aw4DA4gzi+zv4FXU9UlSs3xGZ40wcP1nBjY=
|
||||||
|
github.com/pb33f/ordered-map/v2 v2.3.1/go.mod h1:qxFQgd0PkVUtOMCkTapqotNgzRhMPL7VvaHKbd1HnmQ=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
|
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
|
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/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.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
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 h1:85ENo+3FpWgAACBaEUVp+lctuTcYUO7BtmfhlN/QTRo=
|
||||||
github.com/pkoukk/tiktoken-go v0.1.8/go.mod h1:9NiV+i9mJKGj1rYOT+njbv+ZwA/zJxYdewGl6qVatpg=
|
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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
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 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||||
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
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=
|
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
|
||||||
@@ -205,6 +324,10 @@ github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/f
|
|||||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
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/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.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
|
||||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||||
@@ -218,6 +341,8 @@ github.com/smarty/assertions v1.16.0 h1:EvHNkdRA4QHMrn75NZSoUQ/mAUXAYWfatfB01yTC
|
|||||||
github.com/smarty/assertions v1.16.0/go.mod h1:duaaFdCS0K9dnoM50iyek/eYINOZ64gbh1Xlf6LG7AI=
|
github.com/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 h1:qGjIddxOk4grTu9JPOU31tVfq3cNdBlNa5sSznIX1xY=
|
||||||
github.com/smartystreets/goconvey v1.8.1/go.mod h1:+/u4qLyY6x1jReYOp7GOM2FSt8aP9CzCZL03bI28W60=
|
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.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.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
@@ -237,12 +362,19 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
|
|||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
github.com/tencent-connect/botgo v0.2.1 h1:+BrTt9Zh+awL28GWC4g5Na3nQaGRWb0N5IctS8WqBCk=
|
github.com/tencent-connect/botgo v0.2.1 h1:+BrTt9Zh+awL28GWC4g5Na3nQaGRWb0N5IctS8WqBCk=
|
||||||
github.com/tencent-connect/botgo v0.2.1/go.mod h1:oO1sG9ybhXNickvt+CVym5khwQ+uKhTR+IhTqEfOVsI=
|
github.com/tencent-connect/botgo v0.2.1/go.mod h1:oO1sG9ybhXNickvt+CVym5khwQ+uKhTR+IhTqEfOVsI=
|
||||||
github.com/tidwall/gjson v1.9.3 h1:hqzS9wAHMO+KVBBkLxYdkEeeFHuqr95GfClRLKlgK0E=
|
github.com/tetratelabs/wazero v1.11.0 h1:+gKemEuKCTevU4d7ZTzlsvgd1uaToIDtlQlmNbwqYhA=
|
||||||
|
github.com/tetratelabs/wazero v1.11.0/go.mod h1:eV28rsN8Q+xwjogd7f4/Pp4xFxO7uOGbLcD/LzB1wiU=
|
||||||
github.com/tidwall/gjson v1.9.3/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
github.com/tidwall/gjson v1.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 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
|
||||||
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
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.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||||
|
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
|
||||||
|
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||||
|
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
|
||||||
|
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||||
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
|
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
|
||||||
@@ -260,34 +392,44 @@ github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT0
|
|||||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
github.com/yuin/goldmark v1.2.1/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=
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
|
||||||
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
|
||||||
go.opentelemetry.io/otel v1.34.0 h1:zRLXxLCgL1WyKsPVrgbSdMN4c0FMkDAskSTQP+0hdUY=
|
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||||
go.opentelemetry.io/otel v1.34.0/go.mod h1:OWFPOQ+h4G8xpyjgqo4SxJYdDQ/qmRH+wivy7zzx9oI=
|
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||||
|
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.49.0 h1:4Pp6oUg3+e/6M4C0A/3kJ2VYa++dsWVTtGgLVj5xtHg=
|
||||||
|
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.49.0/go.mod h1:Mjt1i1INqiaoZOMGR1RIUJN+i3ChKoFRqzrRQhlkbs0=
|
||||||
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk=
|
||||||
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw=
|
||||||
|
go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
|
||||||
|
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.34.0 h1:OeNbIYk/2C15ckl7glBlOBp5+WlYsOElzTNmiPW/x60=
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.34.0 h1:OeNbIYk/2C15ckl7glBlOBp5+WlYsOElzTNmiPW/x60=
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.34.0/go.mod h1:7Bept48yIeqxP2OZ9/AqIpYS94h2or0aB4FypJTc8ZM=
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.34.0/go.mod h1:7Bept48yIeqxP2OZ9/AqIpYS94h2or0aB4FypJTc8ZM=
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.34.0 h1:BEj3SPM81McUZHYjRS5pEgNgnmzGJ5tRpU5krWnV8Bs=
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.34.0 h1:BEj3SPM81McUZHYjRS5pEgNgnmzGJ5tRpU5krWnV8Bs=
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.34.0/go.mod h1:9cKLGBDzI/F3NoHLQGm4ZrYdIHsvGt6ej6hUowxY0J4=
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.34.0/go.mod h1:9cKLGBDzI/F3NoHLQGm4ZrYdIHsvGt6ej6hUowxY0J4=
|
||||||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.34.0 h1:jBpDk4HAUsrnVO1FsfCfCOTEc/MkInJmvfCHYLFiT80=
|
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.34.0 h1:jBpDk4HAUsrnVO1FsfCfCOTEc/MkInJmvfCHYLFiT80=
|
||||||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.34.0/go.mod h1:H9LUIM1daaeZaz91vZcfeM0fejXPmgCYE8ZhzqfJuiU=
|
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.34.0/go.mod h1:H9LUIM1daaeZaz91vZcfeM0fejXPmgCYE8ZhzqfJuiU=
|
||||||
go.opentelemetry.io/otel/metric v1.34.0 h1:+eTR3U0MyfWjRDhmFMxe2SsW64QrZ84AOhvqS7Y+PoQ=
|
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
|
||||||
go.opentelemetry.io/otel/metric v1.34.0/go.mod h1:CEDrp0fy2D0MvkXE+dPV7cMi8tWZwX3dmaIhwPOaqHE=
|
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
|
||||||
go.opentelemetry.io/otel/sdk v1.34.0 h1:95zS4k/2GOy069d321O8jWgYsW3MzVV+KuSPKp7Wr1A=
|
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
|
||||||
go.opentelemetry.io/otel/sdk v1.34.0/go.mod h1:0e/pNiaMAqaykJGKbi+tSjWfNNHMTxoC9qANsCzbyxU=
|
go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
|
||||||
go.opentelemetry.io/otel/sdk/metric v1.31.0 h1:i9hxxLJF/9kkvfHppyLL55aW7iIJz4JjxTeYusH7zMc=
|
go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8=
|
||||||
go.opentelemetry.io/otel/sdk/metric v1.31.0/go.mod h1:CRInTMVvNhUKgSAMbKyTMxqOBC0zgyxzW55lZzX43Y8=
|
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
|
||||||
go.opentelemetry.io/otel/trace v1.34.0 h1:+ouXS2V8Rd4hp4580a8q23bg0azF2nI8cqLYnC8mh/k=
|
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
|
||||||
go.opentelemetry.io/otel/trace v1.34.0/go.mod h1:Svm7lSjQD7kG7KJ/MUHPVXSDGz2OX4h0M2jHBhmSfRE=
|
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
|
||||||
go.opentelemetry.io/proto/otlp v1.5.0 h1:xJvq7gMzB31/d406fB8U5CBdyQGw4P399D1aQWU/3i4=
|
go.opentelemetry.io/proto/otlp v1.5.0 h1:xJvq7gMzB31/d406fB8U5CBdyQGw4P399D1aQWU/3i4=
|
||||||
go.opentelemetry.io/proto/otlp v1.5.0/go.mod h1:keN8WnHxOy8PG0rQZjJJ5A2ebUoafqWp0eVQ4yIXvJ4=
|
go.opentelemetry.io/proto/otlp v1.5.0/go.mod h1:keN8WnHxOy8PG0rQZjJJ5A2ebUoafqWp0eVQ4yIXvJ4=
|
||||||
go.uber.org/goleak v1.2.0 h1:xqgm/S+aQvhWFTtR0XK3Jvg7z8kGV8P4X14IzwN3Eqk=
|
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||||
go.uber.org/goleak v1.2.0/go.mod h1:XJYK+MuIchqpmGmUSAzotztawfKvYLUIgg7guXrwVUo=
|
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||||
go.uber.org/mock v0.4.0 h1:VcM4ZOtdbR4f6VXfiOpwpVJDL6lCReaZ6mw31wqh7KU=
|
go.uber.org/mock v0.4.0 h1:VcM4ZOtdbR4f6VXfiOpwpVJDL6lCReaZ6mw31wqh7KU=
|
||||||
go.uber.org/mock v0.4.0/go.mod h1:a6FSlNadKUHUa9IP5Vyt1zh4fC7uAwxMutEAscFbkZc=
|
go.uber.org/mock v0.4.0/go.mod h1:a6FSlNadKUHUa9IP5Vyt1zh4fC7uAwxMutEAscFbkZc=
|
||||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||||
go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo=
|
go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo=
|
||||||
go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so=
|
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 h1:QtOrQd0bTUnhNVNndMpLHNWrDmYzZ2KDqSrEymqInZw=
|
||||||
golang.org/x/arch v0.15.0/go.mod h1:JmwW7aLIoRUKgaTzhkiEFxvcEiQGyOg9BMonBJUS7EE=
|
golang.org/x/arch v0.15.0/go.mod h1:JmwW7aLIoRUKgaTzhkiEFxvcEiQGyOg9BMonBJUS7EE=
|
||||||
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||||
@@ -297,22 +439,33 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh
|
|||||||
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
|
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
|
||||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
|
golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
|
||||||
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
|
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
||||||
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
|
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 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/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 h1:hVwzHzIUGRjiF7EcUjqNxk3NCfkPxbDKRdnNE1Rpg0U=
|
||||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
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.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
golang.org/x/mod v0.3.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.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.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-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-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-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-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-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-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-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-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
|
||||||
golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
|
golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
|
||||||
@@ -320,19 +473,22 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug
|
|||||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||||
golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U=
|
golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U=
|
||||||
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
|
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
|
||||||
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
|
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.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
|
||||||
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
|
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
|
||||||
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
|
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-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-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-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
|
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||||
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
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-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-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
@@ -354,15 +510,15 @@ golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||||
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||||
golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0=
|
golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0=
|
||||||
golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
|
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
|
||||||
golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=
|
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
@@ -370,39 +526,60 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
|||||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
|
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||||
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
|
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 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
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-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-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-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||||
golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||||
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||||
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
|
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
|
||||||
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
|
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
|
||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20250115164207-1a7da9e5054f h1:gap6+3Gk41EItBuyi4XX/bp4oqJ3UwuIMl25yGinuAA=
|
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20250115164207-1a7da9e5054f/go.mod h1:Ic02D47M+zbarjYYUlK57y316f2MoN0gjAwI3f2S95o=
|
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250115164207-1a7da9e5054f h1:OxYkA3wjPsZyBylwymxSHa7ViiW1Sml4ToBrncvFehI=
|
google.golang.org/api v0.189.0 h1:equMo30LypAkdkLMBqfeIqtyAnlyig1JSZArl4XPwdI=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250115164207-1a7da9e5054f/go.mod h1:+2Yz8+CLJbIfL9z73EW45avw8Lmge3xVElCP9zEKi50=
|
google.golang.org/api v0.189.0/go.mod h1:FLWGJKb0hb+pU2j+rJqwbnsF+ym+fQs73rbJ+KAUgy8=
|
||||||
google.golang.org/grpc v1.69.4 h1:MF5TftSMkd8GLw/m0KM6V8CMOCY6NZ1NQDPGFgbTt4A=
|
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
||||||
google.golang.org/grpc v1.69.4/go.mod h1:vyjdE6jLBI76dgpDojsFGNaHlxdjXN9ghpnd2o7JGZ4=
|
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||||
|
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
|
||||||
|
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
|
||||||
|
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
|
||||||
|
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls=
|
||||||
|
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto=
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww=
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
|
||||||
|
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
||||||
|
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
|
||||||
|
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
|
||||||
|
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
|
||||||
|
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
|
||||||
|
google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE=
|
||||||
|
google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
||||||
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
||||||
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
||||||
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
google.golang.org/protobuf 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.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.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.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-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.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
||||||
google.golang.org/protobuf v1.36.3 h1:82DV7MYdb8anAVi3qge1wSnMDrnKK7ebr+I0hHRN1BU=
|
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||||
google.golang.org/protobuf v1.36.3/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
|
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
@@ -414,7 +591,10 @@ gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
|||||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
gopkg.in/yaml.v2 v2.2.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.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.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.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.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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
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=
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 88 KiB |
+27
-22
@@ -514,6 +514,14 @@ type ToolExecutionResult struct {
|
|||||||
IsError bool
|
IsError bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func buildToolFailureMessage(toolName, detail string, err error) string {
|
||||||
|
var b strings.Builder
|
||||||
|
fmt.Fprintf(&b, "工具调用失败\n\n")
|
||||||
|
fmt.Fprintf(&b, "工具名称: %s\n", toolName)
|
||||||
|
fmt.Fprintf(&b, "错误详情: %s", detail)
|
||||||
|
return strings.TrimRight(b.String(), "\n")
|
||||||
|
}
|
||||||
|
|
||||||
// executeToolViaMCP 通过MCP执行工具
|
// executeToolViaMCP 通过MCP执行工具
|
||||||
// 即使工具执行失败,也返回结果而不是错误,让AI能够处理错误情况
|
// 即使工具执行失败,也返回结果而不是错误,让AI能够处理错误情况
|
||||||
func (a *Agent) executeToolViaMCP(ctx context.Context, toolName string, args map[string]interface{}) (*ToolExecutionResult, error) {
|
func (a *Agent) executeToolViaMCP(ctx context.Context, toolName string, args map[string]interface{}) (*ToolExecutionResult, error) {
|
||||||
@@ -573,32 +581,16 @@ func (a *Agent) executeToolViaMCP(ctx context.Context, toolName string, args map
|
|||||||
// 如果调用失败(如工具不存在、超时),返回友好的错误信息而不是抛出异常
|
// 如果调用失败(如工具不存在、超时),返回友好的错误信息而不是抛出异常
|
||||||
if err != nil {
|
if err != nil {
|
||||||
detail := err.Error()
|
detail := err.Error()
|
||||||
|
timeoutMinutes := 10
|
||||||
|
if a.agentConfig != nil && a.agentConfig.ToolTimeoutMinutes > 0 {
|
||||||
|
timeoutMinutes = a.agentConfig.ToolTimeoutMinutes
|
||||||
|
}
|
||||||
if errors.Is(err, context.Canceled) {
|
if errors.Is(err, context.Canceled) {
|
||||||
detail = "工具调用已被手动终止(MCP 监控页)。智能体将携带此结果继续后续步骤,整条任务不会因此被停止。"
|
detail = "工具调用已被手动终止(MCP 监控页)。智能体将携带此结果继续后续步骤,整条任务不会因此被停止。"
|
||||||
} else if errors.Is(err, context.DeadlineExceeded) {
|
} else if errors.Is(err, context.DeadlineExceeded) {
|
||||||
min := 10
|
detail = fmt.Sprintf("工具执行超过 %d 分钟被自动终止(可在 config.yaml 的 agent.tool_timeout_minutes 中调整)", timeoutMinutes)
|
||||||
if a.agentConfig != nil && a.agentConfig.ToolTimeoutMinutes > 0 {
|
|
||||||
min = a.agentConfig.ToolTimeoutMinutes
|
|
||||||
}
|
|
||||||
detail = fmt.Sprintf("工具执行超过 %d 分钟被自动终止(可在 config.yaml 的 agent.tool_timeout_minutes 中调整)", min)
|
|
||||||
}
|
}
|
||||||
errorMsg := fmt.Sprintf(`工具调用失败
|
errorMsg := buildToolFailureMessage(toolName, detail, err)
|
||||||
|
|
||||||
工具名称: %s
|
|
||||||
错误类型: 系统错误
|
|
||||||
错误详情: %s
|
|
||||||
|
|
||||||
可能的原因:
|
|
||||||
- 工具 "%s" 不存在或未启用
|
|
||||||
- 单次执行超时(agent.tool_timeout_minutes)
|
|
||||||
- 系统配置问题
|
|
||||||
- 网络或权限问题
|
|
||||||
|
|
||||||
建议:
|
|
||||||
- 检查工具名称是否正确
|
|
||||||
- 若需更长执行时间,可适当增大 agent.tool_timeout_minutes
|
|
||||||
- 尝试使用其他替代工具
|
|
||||||
- 如果这是必需的工具,请向用户说明情况`, toolName, detail, toolName)
|
|
||||||
|
|
||||||
return &ToolExecutionResult{
|
return &ToolExecutionResult{
|
||||||
Result: errorMsg,
|
Result: errorMsg,
|
||||||
@@ -828,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 非空时合并进返回给模型的文本。
|
// CancelMCPToolExecutionWithNote 取消一次进行中的 MCP 工具(先内部后外部),与监控页「终止工具」一致;note 非空时合并进返回给模型的文本。
|
||||||
func (a *Agent) CancelMCPToolExecutionWithNote(executionID, note string) bool {
|
func (a *Agent) CancelMCPToolExecutionWithNote(executionID, note string) bool {
|
||||||
executionID = strings.TrimSpace(executionID)
|
executionID = strings.TrimSpace(executionID)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package agent
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -71,6 +72,80 @@ func TestAgent_NewAgent_CustomConfig(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBuildToolFailureMessageAuthorizationDenied(t *testing.T) {
|
||||||
|
msg := buildToolFailureMessage(
|
||||||
|
"list_project_facts",
|
||||||
|
"tool authorization denied: no access to project",
|
||||||
|
errors.New("tool authorization denied: no access to project"),
|
||||||
|
)
|
||||||
|
for _, want := range []string{
|
||||||
|
"工具名称: list_project_facts",
|
||||||
|
"错误详情: tool authorization denied: no access to project",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(msg, want) {
|
||||||
|
t.Fatalf("message missing %q:\n%s", want, msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, notWant := range []string{
|
||||||
|
"可能的原因",
|
||||||
|
"建议",
|
||||||
|
"错误类型",
|
||||||
|
"工具 \"list_project_facts\" 不存在或未启用",
|
||||||
|
"单次执行超时",
|
||||||
|
} {
|
||||||
|
if strings.Contains(msg, notWant) {
|
||||||
|
t.Fatalf("message should not include generic hint %q:\n%s", notWant, msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildToolFailureMessageCanceled(t *testing.T) {
|
||||||
|
msg := buildToolFailureMessage(
|
||||||
|
"long_running_tool",
|
||||||
|
"工具调用已被手动终止(MCP 监控页)。智能体将携带此结果继续后续步骤,整条任务不会因此被停止。",
|
||||||
|
context.Canceled,
|
||||||
|
)
|
||||||
|
|
||||||
|
for _, want := range []string{
|
||||||
|
"工具名称: long_running_tool",
|
||||||
|
"错误详情: 工具调用已被手动终止",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(msg, want) {
|
||||||
|
t.Fatalf("message missing %q:\n%s", want, msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildToolFailureMessageDeadlineExceeded(t *testing.T) {
|
||||||
|
msg := buildToolFailureMessage(
|
||||||
|
"nmap",
|
||||||
|
"工具执行超过 15 分钟被自动终止(可在 config.yaml 的 agent.tool_timeout_minutes 中调整)",
|
||||||
|
context.DeadlineExceeded,
|
||||||
|
)
|
||||||
|
|
||||||
|
for _, want := range []string{
|
||||||
|
"工具名称: nmap",
|
||||||
|
"错误详情: 工具执行超过 15 分钟被自动终止",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(msg, want) {
|
||||||
|
t.Fatalf("message missing %q:\n%s", want, msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildToolFailureMessageUnknownKeepsGenericFallback(t *testing.T) {
|
||||||
|
msg := buildToolFailureMessage("custom_tool", "dial tcp: connection reset by peer", errors.New("dial tcp: connection reset by peer"))
|
||||||
|
|
||||||
|
for _, want := range []string{
|
||||||
|
"工具名称: custom_tool",
|
||||||
|
"错误详情: dial tcp: connection reset by peer",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(msg, want) {
|
||||||
|
t.Fatalf("message missing %q:\n%s", want, msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestAgentCancelRunningMCPToolsForConversation(t *testing.T) {
|
func TestAgentCancelRunningMCPToolsForConversation(t *testing.T) {
|
||||||
ag := setupTestAgent(t)
|
ag := setupTestAgent(t)
|
||||||
ag.mcpServer.ConfigureToolWaitTimeoutSeconds(1)
|
ag.mcpServer.ConfigureToolWaitTimeoutSeconds(1)
|
||||||
|
|||||||
@@ -0,0 +1,266 @@
|
|||||||
|
package agentfinalizer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"cyberstrike-ai/internal/database"
|
||||||
|
"cyberstrike-ai/internal/mcp"
|
||||||
|
"cyberstrike-ai/internal/multiagent"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
StatusCompleted = "completed"
|
||||||
|
StatusInProgress = "in_progress"
|
||||||
|
StatusBlocked = "blocked"
|
||||||
|
StatusFailed = "failed"
|
||||||
|
StatusCancelled = "cancelled"
|
||||||
|
StatusAwaitingHITL = "awaiting_hitl"
|
||||||
|
|
||||||
|
ReasonVerified = "verified"
|
||||||
|
ReasonPendingTools = "pending_tool_executions"
|
||||||
|
ReasonEmptyResponse = "empty_response"
|
||||||
|
ReasonAwaitingHITL = "awaiting_hitl"
|
||||||
|
ReasonFailed = "failed"
|
||||||
|
ReasonCancelled = "cancelled"
|
||||||
|
ReasonMissingEvidence = "missing_execution_evidence"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Decision is the single contract that may promote an agent run to a final
|
||||||
|
// user-facing answer. Natural-language assistant text is only a candidate until
|
||||||
|
// this object says Finalizable.
|
||||||
|
type Decision struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
Finalizable bool `json:"finalizable"`
|
||||||
|
Finalized bool `json:"finalized"`
|
||||||
|
CompletionReason string `json:"completionReason"`
|
||||||
|
FinalText string `json:"finalText,omitempty"`
|
||||||
|
EvidenceVerified bool `json:"evidenceVerified"`
|
||||||
|
EvidenceRefs []string `json:"evidenceRefs,omitempty"`
|
||||||
|
PendingExecutionIDs []string `json:"pendingExecutionIds,omitempty"`
|
||||||
|
PendingToolRuns []string `json:"pendingToolRuns,omitempty"`
|
||||||
|
MissingChecks []string `json:"missingChecks,omitempty"`
|
||||||
|
AgentMode string `json:"agentMode,omitempty"`
|
||||||
|
ConversationID string `json:"conversationId,omitempty"`
|
||||||
|
AssistantMessageID string `json:"messageId,omitempty"`
|
||||||
|
CandidateResponseLen int `json:"candidateResponseLen,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Input struct {
|
||||||
|
Response string
|
||||||
|
MCPExecutionIDs []string
|
||||||
|
ConversationID string
|
||||||
|
AssistantMessageID string
|
||||||
|
AgentMode string
|
||||||
|
Status string
|
||||||
|
CompletionReason string
|
||||||
|
AwaitingHITL bool
|
||||||
|
RequireExecutionEvidence bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func FromRunResult(db *database.DB, result *multiagent.RunResult, in Input) Decision {
|
||||||
|
if result != nil {
|
||||||
|
if strings.TrimSpace(in.Response) == "" {
|
||||||
|
in.Response = result.Response
|
||||||
|
}
|
||||||
|
if len(in.MCPExecutionIDs) == 0 {
|
||||||
|
in.MCPExecutionIDs = result.MCPExecutionIDs
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(in.Status) == "" {
|
||||||
|
in.Status = result.Status
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(in.CompletionReason) == "" {
|
||||||
|
in.CompletionReason = result.CompletionReason
|
||||||
|
}
|
||||||
|
}
|
||||||
|
d := Decide(db, in)
|
||||||
|
if result != nil {
|
||||||
|
result.Finalized = d.Finalized
|
||||||
|
result.Status = d.Status
|
||||||
|
result.CompletionReason = d.CompletionReason
|
||||||
|
result.EvidenceVerified = d.EvidenceVerified
|
||||||
|
result.EvidenceRefs = append([]string(nil), d.EvidenceRefs...)
|
||||||
|
result.PendingExecutionIDs = append([]string(nil), d.PendingExecutionIDs...)
|
||||||
|
result.MissingChecks = append([]string(nil), d.MissingChecks...)
|
||||||
|
}
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
func Decide(db *database.DB, in Input) Decision {
|
||||||
|
text := strings.TrimSpace(in.Response)
|
||||||
|
status := strings.TrimSpace(in.Status)
|
||||||
|
if status == "" {
|
||||||
|
status = StatusCompleted
|
||||||
|
}
|
||||||
|
reason := strings.TrimSpace(in.CompletionReason)
|
||||||
|
if reason == "" {
|
||||||
|
reason = ReasonVerified
|
||||||
|
}
|
||||||
|
d := Decision{
|
||||||
|
Status: status,
|
||||||
|
CompletionReason: reason,
|
||||||
|
FinalText: text,
|
||||||
|
EvidenceVerified: true,
|
||||||
|
EvidenceRefs: evidenceRefs(in.MCPExecutionIDs),
|
||||||
|
AgentMode: strings.TrimSpace(in.AgentMode),
|
||||||
|
ConversationID: strings.TrimSpace(in.ConversationID),
|
||||||
|
AssistantMessageID: strings.TrimSpace(in.AssistantMessageID),
|
||||||
|
CandidateResponseLen: len([]rune(text)),
|
||||||
|
}
|
||||||
|
|
||||||
|
if in.AwaitingHITL {
|
||||||
|
d.Status = StatusAwaitingHITL
|
||||||
|
d.CompletionReason = ReasonAwaitingHITL
|
||||||
|
d.EvidenceVerified = false
|
||||||
|
d.MissingChecks = append(d.MissingChecks, "workflow is awaiting HITL approval")
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
if isEmptyCandidate(text) {
|
||||||
|
d.Status = StatusBlocked
|
||||||
|
d.CompletionReason = ReasonEmptyResponse
|
||||||
|
d.EvidenceVerified = false
|
||||||
|
d.MissingChecks = append(d.MissingChecks, "assistant final text is empty or only an empty-response placeholder")
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
switch status {
|
||||||
|
case StatusInProgress, StatusBlocked, StatusFailed, StatusCancelled, StatusAwaitingHITL:
|
||||||
|
d.Status = status
|
||||||
|
d.EvidenceVerified = false
|
||||||
|
if d.CompletionReason == ReasonVerified {
|
||||||
|
d.CompletionReason = status
|
||||||
|
}
|
||||||
|
d.MissingChecks = append(d.MissingChecks, "agent run status is "+status)
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
pending := pendingExecutions(db, in.MCPExecutionIDs)
|
||||||
|
if len(pending) > 0 {
|
||||||
|
d.Status = StatusInProgress
|
||||||
|
d.CompletionReason = ReasonPendingTools
|
||||||
|
d.EvidenceVerified = false
|
||||||
|
d.PendingExecutionIDs = pending
|
||||||
|
d.PendingToolRuns = append([]string(nil), pending...)
|
||||||
|
d.MissingChecks = append(d.MissingChecks, "tool execution still queued or running")
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
if in.RequireExecutionEvidence && !hasCompletedEvidence(db, in.MCPExecutionIDs) {
|
||||||
|
d.Status = StatusBlocked
|
||||||
|
d.CompletionReason = ReasonMissingEvidence
|
||||||
|
d.EvidenceVerified = false
|
||||||
|
d.MissingChecks = append(d.MissingChecks, "execution evidence is required but no completed tool execution was recorded")
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
d.Finalizable = true
|
||||||
|
d.Finalized = true
|
||||||
|
d.Status = StatusCompleted
|
||||||
|
if d.CompletionReason == "" {
|
||||||
|
d.CompletionReason = ReasonVerified
|
||||||
|
}
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
func ResponsePayload(d Decision, extra map[string]interface{}) map[string]interface{} {
|
||||||
|
out := map[string]interface{}{
|
||||||
|
"finalized": d.Finalized,
|
||||||
|
"finalizable": d.Finalizable,
|
||||||
|
"status": d.Status,
|
||||||
|
"completionReason": d.CompletionReason,
|
||||||
|
"evidenceVerified": d.EvidenceVerified,
|
||||||
|
"evidenceRefs": d.EvidenceRefs,
|
||||||
|
"pendingExecutionIds": d.PendingExecutionIDs,
|
||||||
|
"pendingToolRuns": d.PendingToolRuns,
|
||||||
|
"missingChecks": d.MissingChecks,
|
||||||
|
}
|
||||||
|
if d.ConversationID != "" {
|
||||||
|
out["conversationId"] = d.ConversationID
|
||||||
|
}
|
||||||
|
if d.AssistantMessageID != "" {
|
||||||
|
out["messageId"] = d.AssistantMessageID
|
||||||
|
}
|
||||||
|
if d.AgentMode != "" {
|
||||||
|
out["agentMode"] = d.AgentMode
|
||||||
|
}
|
||||||
|
for k, v := range extra {
|
||||||
|
out[k] = v
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func isEmptyCandidate(s string) bool {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if s == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return strings.Contains(s, "no assistant text was captured") ||
|
||||||
|
strings.Contains(s, "未捕获到助手文本输出")
|
||||||
|
}
|
||||||
|
|
||||||
|
func evidenceRefs(ids []string) []string {
|
||||||
|
out := make([]string, 0, len(ids))
|
||||||
|
seen := make(map[string]struct{}, len(ids))
|
||||||
|
for _, id := range ids {
|
||||||
|
id = strings.TrimSpace(id)
|
||||||
|
if id == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := seen[id]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[id] = struct{}{}
|
||||||
|
out = append(out, "mcp_execution:"+id)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func pendingExecutions(db *database.DB, ids []string) []string {
|
||||||
|
if db == nil || len(ids) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]string, 0)
|
||||||
|
seen := make(map[string]struct{}, len(ids))
|
||||||
|
for _, id := range ids {
|
||||||
|
id = strings.TrimSpace(id)
|
||||||
|
if id == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := seen[id]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[id] = struct{}{}
|
||||||
|
exec, err := db.GetToolExecution(id)
|
||||||
|
if err != nil || exec == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch strings.TrimSpace(exec.Status) {
|
||||||
|
case mcp.ToolExecutionStatusQueued, mcp.ToolExecutionStatusRunning:
|
||||||
|
out = append(out, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasCompletedEvidence(db *database.DB, ids []string) bool {
|
||||||
|
if db == nil || len(ids) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
seen := make(map[string]struct{}, len(ids))
|
||||||
|
for _, id := range ids {
|
||||||
|
id = strings.TrimSpace(id)
|
||||||
|
if id == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := seen[id]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[id] = struct{}{}
|
||||||
|
exec, err := db.GetToolExecution(id)
|
||||||
|
if err != nil || exec == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(exec.Status) == mcp.ToolExecutionStatusCompleted {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
package agentfinalizer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"cyberstrike-ai/internal/database"
|
||||||
|
"cyberstrike-ai/internal/mcp"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newDecisionTestDB(t *testing.T) *database.DB {
|
||||||
|
t.Helper()
|
||||||
|
db, err := database.NewDB(filepath.Join(t.TempDir(), "finalizer.db"), zap.NewNop())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewDB: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = db.Close() })
|
||||||
|
return db
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveDecisionTestExecution(t *testing.T, db *database.DB, id, status string) {
|
||||||
|
t.Helper()
|
||||||
|
if err := db.SaveToolExecution(&mcp.ToolExecution{
|
||||||
|
ID: id,
|
||||||
|
ToolName: "test::tool",
|
||||||
|
Arguments: map[string]interface{}{"input": id},
|
||||||
|
Status: status,
|
||||||
|
StartTime: time.Now(),
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("SaveToolExecution(%s): %v", id, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecideBlocksPendingToolExecutions(t *testing.T) {
|
||||||
|
db := newDecisionTestDB(t)
|
||||||
|
saveDecisionTestExecution(t, db, "run-queued", mcp.ToolExecutionStatusQueued)
|
||||||
|
saveDecisionTestExecution(t, db, "run-running", mcp.ToolExecutionStatusRunning)
|
||||||
|
saveDecisionTestExecution(t, db, "run-completed", mcp.ToolExecutionStatusCompleted)
|
||||||
|
|
||||||
|
d := Decide(db, Input{
|
||||||
|
Response: "工具还没全部结束时,这只是一段候选输出。",
|
||||||
|
MCPExecutionIDs: []string{"run-queued", "run-running", "run-completed"},
|
||||||
|
})
|
||||||
|
|
||||||
|
if d.Finalizable || d.Finalized {
|
||||||
|
t.Fatalf("pending tools should not be finalizable: %+v", d)
|
||||||
|
}
|
||||||
|
if d.Status != StatusInProgress || d.CompletionReason != ReasonPendingTools {
|
||||||
|
t.Fatalf("status/reason = %s/%s, want %s/%s", d.Status, d.CompletionReason, StatusInProgress, ReasonPendingTools)
|
||||||
|
}
|
||||||
|
if got, want := len(d.PendingExecutionIDs), 2; got != want {
|
||||||
|
t.Fatalf("pending execution count = %d, want %d (%v)", got, want, d.PendingExecutionIDs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecideBlocksAwaitingHITLAndEmptyCandidate(t *testing.T) {
|
||||||
|
hitl := Decide(nil, Input{Response: "等待人工审批", AwaitingHITL: true})
|
||||||
|
if hitl.Finalizable || hitl.Status != StatusAwaitingHITL || hitl.CompletionReason != ReasonAwaitingHITL {
|
||||||
|
t.Fatalf("HITL decision mismatch: %+v", hitl)
|
||||||
|
}
|
||||||
|
|
||||||
|
empty := Decide(nil, Input{Response: "⚠️ Eino 执行完成,但未捕获到助手文本输出。"})
|
||||||
|
if empty.Finalizable || empty.Status != StatusBlocked || empty.CompletionReason != ReasonEmptyResponse {
|
||||||
|
t.Fatalf("empty candidate decision mismatch: %+v", empty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecideBlocksWhenExecutionEvidenceIsRequiredButMissing(t *testing.T) {
|
||||||
|
d := Decide(nil, Input{
|
||||||
|
Response: "任务已处理完成。",
|
||||||
|
RequireExecutionEvidence: true,
|
||||||
|
})
|
||||||
|
if d.Finalizable || d.Finalized {
|
||||||
|
t.Fatalf("missing required execution evidence should not finalize: %+v", d)
|
||||||
|
}
|
||||||
|
if d.Status != StatusBlocked || d.CompletionReason != ReasonMissingEvidence {
|
||||||
|
t.Fatalf("status/reason = %s/%s, want %s/%s", d.Status, d.CompletionReason, StatusBlocked, ReasonMissingEvidence)
|
||||||
|
}
|
||||||
|
if d.EvidenceVerified {
|
||||||
|
t.Fatalf("missing required execution evidence should be marked unverified: %+v", d)
|
||||||
|
}
|
||||||
|
if len(d.MissingChecks) == 0 {
|
||||||
|
t.Fatalf("missing checks should explain the evidence gap: %+v", d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecideBlocksWhenOnlyFailedEvidenceIsRecorded(t *testing.T) {
|
||||||
|
db := newDecisionTestDB(t)
|
||||||
|
saveDecisionTestExecution(t, db, "run-failed", mcp.ToolExecutionStatusFailed)
|
||||||
|
saveDecisionTestExecution(t, db, "run-cancelled", mcp.ToolExecutionStatusCancelled)
|
||||||
|
|
||||||
|
d := Decide(db, Input{
|
||||||
|
Response: "任务已处理完成。",
|
||||||
|
MCPExecutionIDs: []string{"run-failed", "run-cancelled"},
|
||||||
|
RequireExecutionEvidence: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
if d.Finalizable || d.Finalized {
|
||||||
|
t.Fatalf("failed evidence should not satisfy required execution evidence: %+v", d)
|
||||||
|
}
|
||||||
|
if d.Status != StatusBlocked || d.CompletionReason != ReasonMissingEvidence {
|
||||||
|
t.Fatalf("status/reason = %s/%s, want %s/%s", d.Status, d.CompletionReason, StatusBlocked, ReasonMissingEvidence)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecideFinalizesCompletedEvidence(t *testing.T) {
|
||||||
|
db := newDecisionTestDB(t)
|
||||||
|
saveDecisionTestExecution(t, db, "run-ok", mcp.ToolExecutionStatusCompleted)
|
||||||
|
|
||||||
|
d := Decide(db, Input{
|
||||||
|
Response: "任务已处理完成,见工具执行记录。",
|
||||||
|
MCPExecutionIDs: []string{"run-ok"},
|
||||||
|
RequireExecutionEvidence: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
if !d.Finalizable || !d.Finalized || d.Status != StatusCompleted {
|
||||||
|
t.Fatalf("completed execution should finalize: %+v", d)
|
||||||
|
}
|
||||||
|
if !d.EvidenceVerified || len(d.EvidenceRefs) != 1 {
|
||||||
|
t.Fatalf("evidence refs mismatch: %+v", d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecideAllowsInformationalAnswerWhenExecutionEvidenceIsNotRequired(t *testing.T) {
|
||||||
|
d := Decide(nil, Input{Response: "这是一个概念解释,不需要执行工具。"})
|
||||||
|
if !d.Finalizable || !d.Finalized || d.Status != StatusCompleted {
|
||||||
|
t.Fatalf("informational response should finalize when execution evidence is not required: %+v", d)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -443,6 +443,7 @@ func New(cfg *config.Config, log *logger.Logger, configPath string) (*App, error
|
|||||||
conversationHandler := handler.NewConversationHandler(db, log.Logger)
|
conversationHandler := handler.NewConversationHandler(db, log.Logger)
|
||||||
conversationHandler.SetAudit(auditSvc)
|
conversationHandler.SetAudit(auditSvc)
|
||||||
conversationHandler.SetTaskStopper(agentHandler)
|
conversationHandler.SetTaskStopper(agentHandler)
|
||||||
|
conversationHandler.SetTaskStateProvider(agentHandler)
|
||||||
auditHandler := handler.NewAuditHandler(db, auditSvc, log.Logger)
|
auditHandler := handler.NewAuditHandler(db, auditSvc, log.Logger)
|
||||||
robotHandler := handler.NewRobotHandler(cfg, db, agentHandler, log.Logger)
|
robotHandler := handler.NewRobotHandler(cfg, db, agentHandler, log.Logger)
|
||||||
robotHandler.SetAudit(auditSvc)
|
robotHandler.SetAudit(auditSvc)
|
||||||
@@ -1029,6 +1030,7 @@ func setupRoutes(
|
|||||||
protected.POST("/conversations", conversationHandler.CreateConversation)
|
protected.POST("/conversations", conversationHandler.CreateConversation)
|
||||||
protected.GET("/conversations", conversationHandler.ListConversations)
|
protected.GET("/conversations", conversationHandler.ListConversations)
|
||||||
protected.GET("/conversations/:id", conversationHandler.GetConversation)
|
protected.GET("/conversations/:id", conversationHandler.GetConversation)
|
||||||
|
protected.GET("/conversations/:id/plan-tasks", conversationHandler.GetConversationPlanTasks)
|
||||||
protected.GET("/messages/:id/process-details", conversationHandler.GetMessageProcessDetails)
|
protected.GET("/messages/:id/process-details", conversationHandler.GetMessageProcessDetails)
|
||||||
protected.GET("/process-details/:id", conversationHandler.GetProcessDetail)
|
protected.GET("/process-details/:id", conversationHandler.GetProcessDetail)
|
||||||
protected.PUT("/conversations/:id", conversationHandler.UpdateConversation)
|
protected.PUT("/conversations/:id", conversationHandler.UpdateConversation)
|
||||||
@@ -1340,6 +1342,7 @@ func setupRoutes(
|
|||||||
protected.GET("/chat-uploads", chatUploadsHandler.List)
|
protected.GET("/chat-uploads", chatUploadsHandler.List)
|
||||||
protected.GET("/chat-uploads/export", chatUploadsHandler.Export)
|
protected.GET("/chat-uploads/export", chatUploadsHandler.Export)
|
||||||
protected.GET("/chat-uploads/download", chatUploadsHandler.Download)
|
protected.GET("/chat-uploads/download", chatUploadsHandler.Download)
|
||||||
|
protected.GET("/chat-uploads/path", chatUploadsHandler.ResolvePath)
|
||||||
protected.GET("/chat-uploads/content", chatUploadsHandler.GetContent)
|
protected.GET("/chat-uploads/content", chatUploadsHandler.GetContent)
|
||||||
protected.POST("/chat-uploads", chatUploadsHandler.Upload)
|
protected.POST("/chat-uploads", chatUploadsHandler.Upload)
|
||||||
protected.POST("/chat-uploads/mkdir", chatUploadsHandler.Mkdir)
|
protected.POST("/chat-uploads/mkdir", chatUploadsHandler.Mkdir)
|
||||||
@@ -1361,6 +1364,7 @@ func setupRoutes(
|
|||||||
protected.POST("/workflows/runs/:runId/resume", workflowHandler.ResumeRun)
|
protected.POST("/workflows/runs/:runId/resume", workflowHandler.ResumeRun)
|
||||||
protected.POST("/workflows/validate", workflowHandler.Validate)
|
protected.POST("/workflows/validate", workflowHandler.Validate)
|
||||||
protected.POST("/workflows/dry-run", workflowHandler.DryRun)
|
protected.POST("/workflows/dry-run", workflowHandler.DryRun)
|
||||||
|
protected.POST("/workflows/generate-draft", workflowHandler.GenerateDraft)
|
||||||
protected.GET("/workflows/:id/package", workflowHandler.ExportPackage)
|
protected.GET("/workflows/:id/package", workflowHandler.ExportPackage)
|
||||||
protected.POST("/workflow-package-inspections", workflowHandler.CreatePackageInspection)
|
protected.POST("/workflow-package-inspections", workflowHandler.CreatePackageInspection)
|
||||||
protected.GET("/workflow-package-inspections/:inspectionId", workflowHandler.GetPackageInspection)
|
protected.GET("/workflow-package-inspections/:inspectionId", workflowHandler.GetPackageInspection)
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ tcp_reverse 默认仅接受 CSB1 加密 Beacon(AES-GCM + ImplantToken)才登
|
|||||||
"bind_host": map[string]interface{}{"type": "string", "description": "绑定地址,默认 127.0.0.1;外网监听常用 0.0.0.0"},
|
"bind_host": map[string]interface{}{"type": "string", "description": "绑定地址,默认 127.0.0.1;外网监听常用 0.0.0.0"},
|
||||||
"callback_host": map[string]interface{}{"type": "string", "description": "可选:植入端/Payload 回连主机名(公网 IP 或域名)。写入 config_json;生成 oneliner/beacon 时优先于 bind_host。update 时传入空字符串可清除"},
|
"callback_host": map[string]interface{}{"type": "string", "description": "可选:植入端/Payload 回连主机名(公网 IP 或域名)。写入 config_json;生成 oneliner/beacon 时优先于 bind_host。update 时传入空字符串可清除"},
|
||||||
"bind_port": map[string]interface{}{"type": "integer", "description": fmt.Sprintf("绑定端口(create 必填)。须 ≠ %d(当前本服务 Web/API 端口,配置 server.port)", webListenPort), "minimum": 1, "maximum": 65535},
|
"bind_port": map[string]interface{}{"type": "integer", "description": fmt.Sprintf("绑定端口(create 必填)。须 ≠ %d(当前本服务 Web/API 端口,配置 server.port)", webListenPort), "minimum": 1, "maximum": 65535},
|
||||||
|
"project_id": map[string]interface{}{"type": "string", "description": "所属项目 ID。create 省略时默认使用当前对话绑定项目;未绑定项目的对话则创建未绑定监听器"},
|
||||||
"profile_id": map[string]interface{}{"type": "string", "description": "Malleable Profile ID"},
|
"profile_id": map[string]interface{}{"type": "string", "description": "Malleable Profile ID"},
|
||||||
"remark": map[string]interface{}{"type": "string", "description": "备注"},
|
"remark": map[string]interface{}{"type": "string", "description": "备注"},
|
||||||
"config": map[string]interface{}{"type": "object", "description": "高级配置(beacon 路径/TLS/OPSEC 等),create/update 可用。tcp_reverse 可选 allow_legacy_shell:true 允许未加密经典 shell(默认 false)"},
|
"config": map[string]interface{}{"type": "object", "description": "高级配置(beacon 路径/TLS/OPSEC 等),create/update 可用。tcp_reverse 可选 allow_legacy_shell:true 允许未加密经典 shell(默认 false)"},
|
||||||
@@ -116,6 +117,13 @@ tcp_reverse 默认仅接受 CSB1 加密 Beacon(AES-GCM + ImplantToken)才登
|
|||||||
cfg = &c2.ListenerConfig{}
|
cfg = &c2.ListenerConfig{}
|
||||||
_ = json.Unmarshal(cfgBytes, cfg)
|
_ = json.Unmarshal(cfgBytes, cfg)
|
||||||
}
|
}
|
||||||
|
projectID := strings.TrimSpace(getString(params, "project_id"))
|
||||||
|
if projectID == "" {
|
||||||
|
projectID = mcpEffectiveProjectFilter(ctx, m.DB())
|
||||||
|
if projectID == database.ProjectFilterUnbound {
|
||||||
|
projectID = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
input := c2.CreateListenerInput{
|
input := c2.CreateListenerInput{
|
||||||
Name: getString(params, "name"),
|
Name: getString(params, "name"),
|
||||||
Type: getString(params, "type"),
|
Type: getString(params, "type"),
|
||||||
@@ -123,7 +131,7 @@ tcp_reverse 默认仅接受 CSB1 加密 Beacon(AES-GCM + ImplantToken)才登
|
|||||||
BindPort: int(getFloat64(params, "bind_port")),
|
BindPort: int(getFloat64(params, "bind_port")),
|
||||||
ProfileID: getString(params, "profile_id"),
|
ProfileID: getString(params, "profile_id"),
|
||||||
Remark: getString(params, "remark"),
|
Remark: getString(params, "remark"),
|
||||||
ProjectID: strings.TrimSpace(mcp.MCPProjectIDFromContext(ctx)),
|
ProjectID: projectID,
|
||||||
Config: cfg,
|
Config: cfg,
|
||||||
CallbackHost: getString(params, "callback_host"),
|
CallbackHost: getString(params, "callback_host"),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"cyberstrike-ai/internal/authctx"
|
||||||
|
"cyberstrike-ai/internal/c2"
|
||||||
|
"cyberstrike-ai/internal/database"
|
||||||
|
"cyberstrike-ai/internal/mcp"
|
||||||
|
"cyberstrike-ai/internal/mcp/builtin"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestC2ListenerCreateInheritsConversationProject(t *testing.T) {
|
||||||
|
db, err := database.NewDB(filepath.Join(t.TempDir(), "c2-tools.db"), zap.NewNop())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
user, err := db.CreateRBACUser("c2-agent", "C2 Agent", "hash", true, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
project, err := db.CreateProject(&database.Project{Name: "engagement"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := db.AssignResourceToUser(user.ID, "project", project.ID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
conversation, err := db.CreateConversation("project chat", database.ConversationCreateMeta{ProjectID: project.ID})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
principal := authctx.NewPrincipal(user.ID, user.Username, database.RBACScopeAssigned, map[string]bool{
|
||||||
|
"c2:read": true, "c2:write": true,
|
||||||
|
})
|
||||||
|
ctx := authctx.WithPrincipal(mcp.WithMCPConversationID(context.Background(), conversation.ID), principal)
|
||||||
|
server := mcp.NewServer(zap.NewNop())
|
||||||
|
server.SetToolAuthorizer(mcpToolAuthorizer(db))
|
||||||
|
registerC2Tools(server, c2.NewManager(db, zap.NewNop(), t.TempDir()), zap.NewNop(), 8080)
|
||||||
|
|
||||||
|
result, _, err := server.CallTool(ctx, builtin.ToolC2Listener, map[string]interface{}{
|
||||||
|
"action": "create",
|
||||||
|
"name": "tcp-reverse-2222",
|
||||||
|
"type": "tcp_reverse",
|
||||||
|
"bind_host": "0.0.0.0",
|
||||||
|
"bind_port": 2222,
|
||||||
|
})
|
||||||
|
if err != nil || result == nil || result.IsError {
|
||||||
|
t.Fatalf("create listener result=%#v err=%v text=%q", result, err, toolResultText(result))
|
||||||
|
}
|
||||||
|
|
||||||
|
listeners, err := db.ListC2Listeners()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(listeners) != 1 {
|
||||||
|
t.Fatalf("listener count=%d, want 1", len(listeners))
|
||||||
|
}
|
||||||
|
if listeners[0].ProjectID != project.ID {
|
||||||
|
t.Fatalf("listener project_id=%q, want %q", listeners[0].ProjectID, project.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -244,7 +244,20 @@ func authorizeC2Action(ctx context.Context, principal authctx.Principal, db *dat
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if id == "" {
|
if id == "" {
|
||||||
if action == "create" || action == "list" {
|
if action == "create" {
|
||||||
|
projectID := mcpAuthorizationString(args, "project_id")
|
||||||
|
if projectID == "" {
|
||||||
|
projectID = mcpEffectiveProjectFilter(ctx, db)
|
||||||
|
if projectID == database.ProjectFilterUnbound {
|
||||||
|
projectID = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if projectID != "" && (db == nil || !db.UserCanAccessResource(principal.UserID, principal.ScopeFor(permission), "project", projectID)) {
|
||||||
|
return fmt.Errorf("no access to project %s", projectID)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if action == "list" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return fmt.Errorf("missing resource identifier %s", argument)
|
return fmt.Errorf("missing resource identifier %s", argument)
|
||||||
@@ -369,7 +382,13 @@ func authorizeProjectTool(ctx context.Context, principal authctx.Principal, db *
|
|||||||
return fmt.Errorf("no access to conversation %s", conversationID)
|
return fmt.Errorf("no access to conversation %s", conversationID)
|
||||||
}
|
}
|
||||||
projectID, err := db.GetConversationProjectID(conversationID)
|
projectID, err := db.GetConversationProjectID(conversationID)
|
||||||
if err != nil || strings.TrimSpace(projectID) == "" || !db.UserCanAccessResource(principal.UserID, principal.ScopeFor(permission), "project", projectID) {
|
if err != nil {
|
||||||
|
return fmt.Errorf("no access to project: %w", err)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(projectID) == "" {
|
||||||
|
return fmt.Errorf("当前对话未绑定项目,无法使用项目黑板工具,请先在对话中选择项目或创建带项目的对话")
|
||||||
|
}
|
||||||
|
if !db.UserCanAccessResource(principal.UserID, principal.ScopeFor(permission), "project", projectID) {
|
||||||
return fmt.Errorf("no access to project %s", projectID)
|
return fmt.Errorf("no access to project %s", projectID)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -291,16 +291,13 @@ func (l *HTTPBeaconListener) handleResult(w http.ResponseWriter, r *http.Request
|
|||||||
}
|
}
|
||||||
var report TaskResultReport
|
var report TaskResultReport
|
||||||
plaintext, decErr := DecryptAESGCM(l.rec.EncryptionKey, string(body))
|
plaintext, decErr := DecryptAESGCM(l.rec.EncryptionKey, string(body))
|
||||||
if decErr == nil {
|
if decErr != nil {
|
||||||
if err := json.Unmarshal(plaintext, &report); err != nil {
|
l.disguisedReject(w)
|
||||||
l.disguisedReject(w)
|
return
|
||||||
return
|
}
|
||||||
}
|
if err := json.Unmarshal(plaintext, &report); err != nil {
|
||||||
} else {
|
l.disguisedReject(w)
|
||||||
if err := json.Unmarshal(body, &report); err != nil {
|
return
|
||||||
l.disguisedReject(w)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if err := l.manager.IngestTaskResult(report); err != nil {
|
if err := l.manager.IngestTaskResult(report); err != nil {
|
||||||
http.Error(w, "ingest result failed", http.StatusInternalServerError)
|
http.Error(w, "ingest result failed", http.StatusInternalServerError)
|
||||||
@@ -341,12 +338,15 @@ func (l *HTTPBeaconListener) handleUpload(w http.ResponseWriter, r *http.Request
|
|||||||
l.disguisedReject(w)
|
l.disguisedReject(w)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
dir := filepath.Join(l.manager.StorageDir(), "uploads")
|
dir, dst, err := uploadPathForTask(l.manager.StorageDir(), taskID)
|
||||||
|
if err != nil {
|
||||||
|
l.disguisedReject(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
http.Error(w, "mkdir failed", http.StatusInternalServerError)
|
http.Error(w, "mkdir failed", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
dst := filepath.Join(dir, taskID+".bin")
|
|
||||||
if err := os.WriteFile(dst, plaintext, 0o644); err != nil {
|
if err := os.WriteFile(dst, plaintext, 0o644); err != nil {
|
||||||
http.Error(w, "save failed", http.StatusInternalServerError)
|
http.Error(w, "save failed", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -227,3 +228,87 @@ func TestHTTPBeaconListener_HandleFileServe(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHTTPBeaconListener_HandleUploadConfinesTaskID(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
store := filepath.Join(tmp, "c2store")
|
||||||
|
keyB64, err := GenerateAESKey()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
token := "test-implant-token-upload"
|
||||||
|
l := &HTTPBeaconListener{
|
||||||
|
rec: &database.C2Listener{
|
||||||
|
EncryptionKey: keyB64,
|
||||||
|
ImplantToken: token,
|
||||||
|
},
|
||||||
|
manager: NewManager(nil, zap.NewNop(), store),
|
||||||
|
logger: zap.NewNop(),
|
||||||
|
}
|
||||||
|
|
||||||
|
encrypted, err := EncryptAESGCM(keyB64, []byte("safe upload"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/upload?task_id=t_safe123", strings.NewReader(encrypted))
|
||||||
|
req.Header.Set("X-Implant-Token", token)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
|
||||||
|
l.handleUpload(rr, req)
|
||||||
|
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status=%d body=%q", rr.Code, rr.Body.String())
|
||||||
|
}
|
||||||
|
got, err := os.ReadFile(filepath.Join(store, "uploads", "t_safe123.bin"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if string(got) != "safe upload" {
|
||||||
|
t.Fatalf("content=%q", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
evilBody, err := EncryptAESGCM(keyB64, []byte("owned"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
evilReq := httptest.NewRequest(http.MethodPost, "/upload?task_id=..%2Fowned", strings.NewReader(evilBody))
|
||||||
|
evilReq.Header.Set("X-Implant-Token", token)
|
||||||
|
evilRR := httptest.NewRecorder()
|
||||||
|
|
||||||
|
l.handleUpload(evilRR, evilReq)
|
||||||
|
|
||||||
|
if evilRR.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("status=%d body=%q", evilRR.Code, evilRR.Body.String())
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(store, "owned.bin")); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("outside file exists or stat failed unexpectedly: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHTTPBeaconListener_HandleResultRejectsPlaintextJSON(t *testing.T) {
|
||||||
|
keyB64, err := GenerateAESKey()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
l := &HTTPBeaconListener{
|
||||||
|
rec: &database.C2Listener{
|
||||||
|
EncryptionKey: keyB64,
|
||||||
|
ImplantToken: "test-implant-token-result",
|
||||||
|
},
|
||||||
|
logger: zap.NewNop(),
|
||||||
|
}
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/result", strings.NewReader(`{"task_id":"t_test","success":true}`))
|
||||||
|
req.Header.Set("X-Implant-Token", "test-implant-token-result")
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
|
||||||
|
l.handleResult(rr, req)
|
||||||
|
|
||||||
|
if rr.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("status=%d body=%q", rr.Code, rr.Body.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(rr.Body.String(), "404 Not Found") {
|
||||||
|
t.Fatalf("expected disguised 404 body, got %q", rr.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+62
-5
@@ -42,6 +42,11 @@ type Manager struct {
|
|||||||
// MCPToolC2Task 与 MCP builtin、c2_task 工具名一致,供 HITL 白名单与 Agent 侧对齐。
|
// MCPToolC2Task 与 MCP builtin、c2_task 工具名一致,供 HITL 白名单与 Agent 侧对齐。
|
||||||
const MCPToolC2Task = "c2_task"
|
const MCPToolC2Task = "c2_task"
|
||||||
|
|
||||||
|
var (
|
||||||
|
resultBlobSuffixPattern = regexp.MustCompile(`^\.[A-Za-z0-9][A-Za-z0-9_-]{0,31}$`)
|
||||||
|
uploadTaskIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$`)
|
||||||
|
)
|
||||||
|
|
||||||
// HITLBridge 把"危险任务"桥到现有 internal/handler/hitl 审批流的接口。
|
// HITLBridge 把"危险任务"桥到现有 internal/handler/hitl 审批流的接口。
|
||||||
// internal/app 实例化时传入;空实现表示禁用 HITL 拦截(开发期方便)。
|
// internal/app 实例化时传入;空实现表示禁用 HITL 拦截(开发期方便)。
|
||||||
type HITLBridge interface {
|
type HITLBridge interface {
|
||||||
@@ -736,18 +741,24 @@ func (m *Manager) IngestTaskResult(report TaskResultReport) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) saveResultBlob(taskID, b64Content, suffix string) (string, error) {
|
func (m *Manager) saveResultBlob(taskID, b64Content, suffix string) (string, error) {
|
||||||
suffix = strings.TrimSpace(suffix)
|
taskID = strings.TrimSpace(taskID)
|
||||||
if suffix == "" {
|
if taskID == "" || taskID == "." || taskID == ".." ||
|
||||||
suffix = ".bin"
|
strings.ContainsAny(taskID, `/\`) {
|
||||||
|
return "", fmt.Errorf("invalid task_id")
|
||||||
}
|
}
|
||||||
if !strings.HasPrefix(suffix, ".") {
|
|
||||||
suffix = "." + suffix
|
suffix, err := normalizeResultBlobSuffix(suffix)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
}
|
}
|
||||||
dir := filepath.Join(m.storageDir, "results")
|
dir := filepath.Join(m.storageDir, "results")
|
||||||
if err := osMkdirAll(dir, 0o755); err != nil {
|
if err := osMkdirAll(dir, 0o755); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
path := filepath.Join(dir, taskID+suffix)
|
path := filepath.Join(dir, taskID+suffix)
|
||||||
|
if err := ensurePathInDir(dir, path); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
data, err := base64Decode(b64Content)
|
data, err := base64Decode(b64Content)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
@@ -758,6 +769,52 @@ func (m *Manager) saveResultBlob(taskID, b64Content, suffix string) (string, err
|
|||||||
return path, nil
|
return path, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func uploadPathForTask(storageDir, taskID string) (dir, path string, err error) {
|
||||||
|
taskID = strings.TrimSpace(taskID)
|
||||||
|
if !uploadTaskIDPattern.MatchString(taskID) {
|
||||||
|
return "", "", fmt.Errorf("invalid task_id")
|
||||||
|
}
|
||||||
|
dir = filepath.Join(storageDir, "uploads")
|
||||||
|
path = filepath.Join(dir, taskID+".bin")
|
||||||
|
if err := ensurePathInDir(dir, path); err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
return dir, path, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeResultBlobSuffix(suffix string) (string, error) {
|
||||||
|
suffix = strings.TrimSpace(suffix)
|
||||||
|
if suffix == "" {
|
||||||
|
return ".bin", nil
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(suffix, ".") {
|
||||||
|
suffix = "." + suffix
|
||||||
|
}
|
||||||
|
if !resultBlobSuffixPattern.MatchString(suffix) {
|
||||||
|
return "", fmt.Errorf("invalid blob suffix")
|
||||||
|
}
|
||||||
|
return suffix, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensurePathInDir(dir, path string) error {
|
||||||
|
absDir, err := filepath.Abs(dir)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
absPath, err := filepath.Abs(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rel, err := filepath.Rel(absDir, absPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if rel == "." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || rel == ".." || filepath.IsAbs(rel) {
|
||||||
|
return fmt.Errorf("path escapes result directory")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
// 事件总线辅助
|
// 事件总线辅助
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package c2
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestManagerSaveResultBlobConfinesPath(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
mgr := NewManager(nil, zap.NewNop(), filepath.Join(tmp, "c2store"))
|
||||||
|
content := base64.StdEncoding.EncodeToString([]byte("result bytes"))
|
||||||
|
|
||||||
|
got, err := mgr.saveResultBlob("t_safe123", content, "txt")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := filepath.Join(tmp, "c2store", "results", "t_safe123.txt")
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("path=%q want %q", got, want)
|
||||||
|
}
|
||||||
|
raw, err := os.ReadFile(want)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if string(raw) != "result bytes" {
|
||||||
|
t.Fatalf("content=%q", raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
outside := filepath.Join(tmp, "owned")
|
||||||
|
if _, err := mgr.saveResultBlob("t_safe123", content, "./../../owned"); err == nil {
|
||||||
|
t.Fatal("expected traversal suffix to be rejected")
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(outside); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("outside file exists or stat failed unexpectedly: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUploadPathForTaskConfinesPath(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
store := filepath.Join(tmp, "c2store")
|
||||||
|
|
||||||
|
dir, got, err := uploadPathForTask(store, "t_safe123")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if want := filepath.Join(store, "uploads"); dir != want {
|
||||||
|
t.Fatalf("dir=%q want %q", dir, want)
|
||||||
|
}
|
||||||
|
if want := filepath.Join(store, "uploads", "t_safe123.bin"); got != want {
|
||||||
|
t.Fatalf("path=%q want %q", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, taskID := range []string{"", ".", "..", "../owned", `..\owned`, "sub/owned", "sub\\owned", "task.with.dot", "-leading"} {
|
||||||
|
if _, _, err := uploadPathForTask(store, taskID); err == nil {
|
||||||
|
t.Fatalf("task_id %q unexpectedly accepted", taskID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeResultBlobSuffix(t *testing.T) {
|
||||||
|
for _, suffix := range []string{"", "png", ".jpg", ".7z", ".safe_name-1"} {
|
||||||
|
if _, err := normalizeResultBlobSuffix(suffix); err != nil {
|
||||||
|
t.Fatalf("suffix %q rejected: %v", suffix, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, suffix := range []string{".", "..", "../x", "./../../x", "/tmp/x", `..\x`, ".name.with.dot", ".toolong012345678901234567890123456789"} {
|
||||||
|
if _, err := normalizeResultBlobSuffix(suffix); err == nil {
|
||||||
|
t.Fatalf("suffix %q unexpectedly accepted", suffix)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -209,11 +209,13 @@ func (l *TCPReverseListener) handleTCPBeaconSession(conn net.Conn, br *bufio.Rea
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
dir := filepath.Join(l.manager.StorageDir(), "uploads")
|
dir, dst, err := uploadPathForTask(l.manager.StorageDir(), up.TaskID)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
dst := filepath.Join(dir, up.TaskID+".bin")
|
|
||||||
if err := os.WriteFile(dst, plainFile, 0o644); err != nil {
|
if err := os.WriteFile(dst, plainFile, 0o644); err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
+82
-71
@@ -54,9 +54,6 @@ type EnsureLocalConfigResult struct {
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
DefaultMaxCompletionTokens = 16384
|
DefaultMaxCompletionTokens = 16384
|
||||||
DefaultMaxToolArgumentsBytes = 65536
|
|
||||||
DefaultMaxShellCommandBytes = 65536
|
|
||||||
DefaultModelOutputRepairMaxAttempts = 1
|
|
||||||
DefaultSummarizationUserIntentLedgerMaxRunes = 96000
|
DefaultSummarizationUserIntentLedgerMaxRunes = 96000
|
||||||
DefaultSummarizationUserIntentLedgerEntryMaxRunes = 16000
|
DefaultSummarizationUserIntentLedgerEntryMaxRunes = 16000
|
||||||
DefaultLatestUserMessageMaxRunes = 48000
|
DefaultLatestUserMessageMaxRunes = 48000
|
||||||
@@ -256,12 +253,6 @@ func (c MultiAgentEinoCallbacksConfig) EinoCallbacksMaxOutputSummaryRunes() int
|
|||||||
|
|
||||||
// MultiAgentEinoMiddlewareConfig optional Eino ADK middleware and Deep / supervisor tuning.
|
// MultiAgentEinoMiddlewareConfig optional Eino ADK middleware and Deep / supervisor tuning.
|
||||||
type MultiAgentEinoMiddlewareConfig struct {
|
type MultiAgentEinoMiddlewareConfig struct {
|
||||||
// MaxToolArgumentsBytes hard-rejects oversized model-generated tool arguments before execution.
|
|
||||||
MaxToolArgumentsBytes int `yaml:"max_tool_arguments_bytes,omitempty" json:"max_tool_arguments_bytes,omitempty"`
|
|
||||||
// MaxShellCommandBytes applies a stricter limit to exec/execute command strings.
|
|
||||||
MaxShellCommandBytes int `yaml:"max_shell_command_bytes,omitempty" json:"max_shell_command_bytes,omitempty"`
|
|
||||||
// ModelOutputRepairMaxAttempts limits consecutive model-output repair attempts.
|
|
||||||
ModelOutputRepairMaxAttempts int `yaml:"model_output_repair_max_attempts,omitempty" json:"model_output_repair_max_attempts,omitempty"`
|
|
||||||
// PatchToolCalls inserts placeholder tool results for dangling assistant tool_calls (nil = enabled).
|
// PatchToolCalls inserts placeholder tool results for dangling assistant tool_calls (nil = enabled).
|
||||||
PatchToolCalls *bool `yaml:"patch_tool_calls,omitempty" json:"patch_tool_calls,omitempty"`
|
PatchToolCalls *bool `yaml:"patch_tool_calls,omitempty" json:"patch_tool_calls,omitempty"`
|
||||||
// ToolSearch enables dynamictool/toolsearch: hide tail tools until model calls tool_search (reduces prompt tools).
|
// ToolSearch enables dynamictool/toolsearch: hide tail tools until model calls tool_search (reduces prompt tools).
|
||||||
@@ -297,7 +288,7 @@ type MultiAgentEinoMiddlewareConfig struct {
|
|||||||
LatestUserMessageHeadRunes int `yaml:"latest_user_message_head_runes,omitempty" json:"latest_user_message_head_runes,omitempty"`
|
LatestUserMessageHeadRunes int `yaml:"latest_user_message_head_runes,omitempty" json:"latest_user_message_head_runes,omitempty"`
|
||||||
// LatestUserMessageTailRunes keeps the tail preview for an oversized current user turn.
|
// LatestUserMessageTailRunes keeps the tail preview for an oversized current user turn.
|
||||||
LatestUserMessageTailRunes int `yaml:"latest_user_message_tail_runes,omitempty" json:"latest_user_message_tail_runes,omitempty"`
|
LatestUserMessageTailRunes int `yaml:"latest_user_message_tail_runes,omitempty" json:"latest_user_message_tail_runes,omitempty"`
|
||||||
// SummarizationRetryMaxAttempts 已废弃:summarization 与 run loop 共用 run_retry_max_attempts 及 isEinoTransientRunError。
|
// SummarizationRetryMaxAttempts 已废弃:summarization 与 Eino 原生 ModelRetry 共用 model_retry_max_retries 及 isEinoTransientRunError。
|
||||||
SummarizationRetryMaxAttempts int `yaml:"summarization_retry_max_attempts,omitempty" json:"summarization_retry_max_attempts,omitempty"`
|
SummarizationRetryMaxAttempts int `yaml:"summarization_retry_max_attempts,omitempty" json:"summarization_retry_max_attempts,omitempty"`
|
||||||
// PlanExecuteUserInputBudgetRatio caps planner/replanner/executor userInput prompt budget ratio (default 0.35).
|
// PlanExecuteUserInputBudgetRatio caps planner/replanner/executor userInput prompt budget ratio (default 0.35).
|
||||||
PlanExecuteUserInputBudgetRatio float64 `yaml:"plan_execute_user_input_budget_ratio,omitempty" json:"plan_execute_user_input_budget_ratio,omitempty"`
|
PlanExecuteUserInputBudgetRatio float64 `yaml:"plan_execute_user_input_budget_ratio,omitempty" json:"plan_execute_user_input_budget_ratio,omitempty"`
|
||||||
@@ -311,11 +302,19 @@ type MultiAgentEinoMiddlewareConfig struct {
|
|||||||
CheckpointDir string `yaml:"checkpoint_dir,omitempty" json:"checkpoint_dir,omitempty"`
|
CheckpointDir string `yaml:"checkpoint_dir,omitempty" json:"checkpoint_dir,omitempty"`
|
||||||
// DeepOutputKey passed to deep.Config OutputKey (session final text); empty = off.
|
// DeepOutputKey passed to deep.Config OutputKey (session final text); empty = off.
|
||||||
DeepOutputKey string `yaml:"deep_output_key,omitempty" json:"deep_output_key,omitempty"`
|
DeepOutputKey string `yaml:"deep_output_key,omitempty" json:"deep_output_key,omitempty"`
|
||||||
// DeepModelRetryMaxRetries 已废弃:临时错误统一由 run loop 内 isEinoTransientRunError + run_retry_max_attempts 处理。
|
// DeepModelRetryMaxRetries 已废弃:请用 model_retry_max_retries;保留字段仅为兼容旧配置。
|
||||||
DeepModelRetryMaxRetries int `yaml:"deep_model_retry_max_retries,omitempty" json:"deep_model_retry_max_retries,omitempty"`
|
DeepModelRetryMaxRetries int `yaml:"deep_model_retry_max_retries,omitempty" json:"deep_model_retry_max_retries,omitempty"`
|
||||||
// RunRetryMaxAttempts > 0:408/409/425/429/5xx/网络抖动时可退避重试次数(run loop 与 summarization 共用);0=默认 4。
|
// ModelRetryMaxRetries configures Eino ADK native ChatModel retry attempts; 0=default 4.
|
||||||
|
ModelRetryMaxRetries int `yaml:"model_retry_max_retries,omitempty" json:"model_retry_max_retries,omitempty"`
|
||||||
|
// ModelRetryMaxBackoffSec caps native model retry backoff seconds; 0=default 30.
|
||||||
|
ModelRetryMaxBackoffSec int `yaml:"model_retry_max_backoff_sec,omitempty" json:"model_retry_max_backoff_sec,omitempty"`
|
||||||
|
// ModelFailoverChannels lists ai.channels IDs to try after native model retry is exhausted.
|
||||||
|
ModelFailoverChannels []string `yaml:"model_failover_channels,omitempty" json:"model_failover_channels,omitempty"`
|
||||||
|
// ModelFailoverMaxRetries caps distinct failover channel attempts; 0=all configured failover channels.
|
||||||
|
ModelFailoverMaxRetries int `yaml:"model_failover_max_retries,omitempty" json:"model_failover_max_retries,omitempty"`
|
||||||
|
// RunRetryMaxAttempts 已废弃:模型临时错误由 Eino 原生 ModelRetry 处理;仅保留给非模型层 run loop 兜底与 summarization 旧字段。
|
||||||
RunRetryMaxAttempts int `yaml:"run_retry_max_attempts,omitempty" json:"run_retry_max_attempts,omitempty"`
|
RunRetryMaxAttempts int `yaml:"run_retry_max_attempts,omitempty" json:"run_retry_max_attempts,omitempty"`
|
||||||
// RunRetryMaxBackoffSec 单次退避上限秒数;0=默认 30。
|
// RunRetryMaxBackoffSec 已废弃:请用 model_retry_max_backoff_sec;仅保留给非模型层 run loop 兜底与 summarization 旧字段。
|
||||||
RunRetryMaxBackoffSec int `yaml:"run_retry_max_backoff_sec,omitempty" json:"run_retry_max_backoff_sec,omitempty"`
|
RunRetryMaxBackoffSec int `yaml:"run_retry_max_backoff_sec,omitempty" json:"run_retry_max_backoff_sec,omitempty"`
|
||||||
// EmptyResponseContinueMaxAttempts Run 成功但未捕获助手正文时 Handler 层退避续跑次数;0=默认 5。
|
// EmptyResponseContinueMaxAttempts Run 成功但未捕获助手正文时 Handler 层退避续跑次数;0=默认 5。
|
||||||
EmptyResponseContinueMaxAttempts int `yaml:"empty_response_continue_max_attempts,omitempty" json:"empty_response_continue_max_attempts,omitempty"`
|
EmptyResponseContinueMaxAttempts int `yaml:"empty_response_continue_max_attempts,omitempty" json:"empty_response_continue_max_attempts,omitempty"`
|
||||||
@@ -323,27 +322,6 @@ type MultiAgentEinoMiddlewareConfig struct {
|
|||||||
TaskToolDescriptionPrefix string `yaml:"task_tool_description_prefix,omitempty" json:"task_tool_description_prefix,omitempty"`
|
TaskToolDescriptionPrefix string `yaml:"task_tool_description_prefix,omitempty" json:"task_tool_description_prefix,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c MultiAgentEinoMiddlewareConfig) MaxToolArgumentsBytesEffective() int {
|
|
||||||
if c.MaxToolArgumentsBytes > 0 {
|
|
||||||
return c.MaxToolArgumentsBytes
|
|
||||||
}
|
|
||||||
return DefaultMaxToolArgumentsBytes
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c MultiAgentEinoMiddlewareConfig) MaxShellCommandBytesEffective() int {
|
|
||||||
if c.MaxShellCommandBytes > 0 {
|
|
||||||
return c.MaxShellCommandBytes
|
|
||||||
}
|
|
||||||
return DefaultMaxShellCommandBytes
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c MultiAgentEinoMiddlewareConfig) ModelOutputRepairMaxAttemptsEffective() int {
|
|
||||||
if c.ModelOutputRepairMaxAttempts > 0 {
|
|
||||||
return c.ModelOutputRepairMaxAttempts
|
|
||||||
}
|
|
||||||
return DefaultModelOutputRepairMaxAttempts
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c MultiAgentEinoMiddlewareConfig) SummarizationTriggerRatioEffective() float64 {
|
func (c MultiAgentEinoMiddlewareConfig) SummarizationTriggerRatioEffective() float64 {
|
||||||
v := c.SummarizationTriggerRatio
|
v := c.SummarizationTriggerRatio
|
||||||
if v <= 0 {
|
if v <= 0 {
|
||||||
@@ -514,6 +492,10 @@ type MultiAgentPublic struct {
|
|||||||
LatestUserMessageMaxRunes int `json:"latest_user_message_max_runes"`
|
LatestUserMessageMaxRunes int `json:"latest_user_message_max_runes"`
|
||||||
LatestUserMessageHeadRunes int `json:"latest_user_message_head_runes"`
|
LatestUserMessageHeadRunes int `json:"latest_user_message_head_runes"`
|
||||||
LatestUserMessageTailRunes int `json:"latest_user_message_tail_runes"`
|
LatestUserMessageTailRunes int `json:"latest_user_message_tail_runes"`
|
||||||
|
ModelRetryMaxRetries int `json:"model_retry_max_retries"`
|
||||||
|
ModelRetryMaxBackoffSec int `json:"model_retry_max_backoff_sec"`
|
||||||
|
ModelFailoverChannels []string `json:"model_failover_channels,omitempty"`
|
||||||
|
ModelFailoverMaxRetries int `json:"model_failover_max_retries"`
|
||||||
ToolSearchAlwaysVisibleTools []string `json:"tool_search_always_visible_tools,omitempty"`
|
ToolSearchAlwaysVisibleTools []string `json:"tool_search_always_visible_tools,omitempty"`
|
||||||
ToolSearchAlwaysVisibleEffectiveTools []string `json:"tool_search_always_visible_effective_tools,omitempty"`
|
ToolSearchAlwaysVisibleEffectiveTools []string `json:"tool_search_always_visible_effective_tools,omitempty"`
|
||||||
}
|
}
|
||||||
@@ -555,15 +537,19 @@ func NormalizeMultiAgentOrchestration(s string) string {
|
|||||||
|
|
||||||
// MultiAgentAPIUpdate 设置页/API 仅更新多代理标量字段;写入 YAML 时不覆盖 sub_agents 等块。
|
// MultiAgentAPIUpdate 设置页/API 仅更新多代理标量字段;写入 YAML 时不覆盖 sub_agents 等块。
|
||||||
type MultiAgentAPIUpdate struct {
|
type MultiAgentAPIUpdate struct {
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
RobotDefaultAgentMode string `json:"robot_default_agent_mode,omitempty"`
|
RobotDefaultAgentMode string `json:"robot_default_agent_mode,omitempty"`
|
||||||
BatchUseMultiAgent bool `json:"batch_use_multi_agent"`
|
BatchUseMultiAgent bool `json:"batch_use_multi_agent"`
|
||||||
PlanExecuteLoopMaxIterations *int `json:"plan_execute_loop_max_iterations,omitempty"`
|
PlanExecuteLoopMaxIterations *int `json:"plan_execute_loop_max_iterations,omitempty"`
|
||||||
SummarizationUserIntentLedgerMaxRunes *int `json:"summarization_user_intent_ledger_max_runes,omitempty"`
|
SummarizationUserIntentLedgerMaxRunes *int `json:"summarization_user_intent_ledger_max_runes,omitempty"`
|
||||||
SummarizationUserIntentLedgerEntryMaxRunes *int `json:"summarization_user_intent_ledger_entry_max_runes,omitempty"`
|
SummarizationUserIntentLedgerEntryMaxRunes *int `json:"summarization_user_intent_ledger_entry_max_runes,omitempty"`
|
||||||
LatestUserMessageMaxRunes *int `json:"latest_user_message_max_runes,omitempty"`
|
LatestUserMessageMaxRunes *int `json:"latest_user_message_max_runes,omitempty"`
|
||||||
LatestUserMessageHeadRunes *int `json:"latest_user_message_head_runes,omitempty"`
|
LatestUserMessageHeadRunes *int `json:"latest_user_message_head_runes,omitempty"`
|
||||||
LatestUserMessageTailRunes *int `json:"latest_user_message_tail_runes,omitempty"`
|
LatestUserMessageTailRunes *int `json:"latest_user_message_tail_runes,omitempty"`
|
||||||
|
ModelRetryMaxRetries *int `json:"model_retry_max_retries,omitempty"`
|
||||||
|
ModelRetryMaxBackoffSec *int `json:"model_retry_max_backoff_sec,omitempty"`
|
||||||
|
ModelFailoverChannels *[]string `json:"model_failover_channels,omitempty"`
|
||||||
|
ModelFailoverMaxRetries *int `json:"model_failover_max_retries,omitempty"`
|
||||||
// 指针区分「JSON 未传该字段」与「传空数组要清空」;省略时不应覆盖 YAML 中的常驻工具白名单。
|
// 指针区分「JSON 未传该字段」与「传空数组要清空」;省略时不应覆盖 YAML 中的常驻工具白名单。
|
||||||
ToolSearchAlwaysVisibleTools *[]string `json:"tool_search_always_visible_tools,omitempty"`
|
ToolSearchAlwaysVisibleTools *[]string `json:"tool_search_always_visible_tools,omitempty"`
|
||||||
}
|
}
|
||||||
@@ -831,7 +817,7 @@ type MCPConfig struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type OpenAIConfig 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"`
|
APIKey string `yaml:"api_key" json:"api_key"`
|
||||||
BaseURL string `yaml:"base_url" json:"base_url"`
|
BaseURL string `yaml:"base_url" json:"base_url"`
|
||||||
Model string `yaml:"model" json:"model"`
|
Model string `yaml:"model" json:"model"`
|
||||||
@@ -972,6 +958,16 @@ func (c OpenAIConfig) MaxCompletionTokensEffective() int {
|
|||||||
return DefaultMaxCompletionTokens
|
return DefaultMaxCompletionTokens
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IsDeepSeekEndpointOrModel reports whether the channel targets DeepSeek's
|
||||||
|
// official-compatible API or a DeepSeek model family. This is separate from the
|
||||||
|
// reasoning profile: profile controls field mapping, while DeepSeek has provider
|
||||||
|
// constraints such as default thinking mode and no tool_choice in thinking mode.
|
||||||
|
func (c OpenAIConfig) IsDeepSeekEndpointOrModel() bool {
|
||||||
|
baseURL := strings.ToLower(strings.TrimSpace(c.BaseURL))
|
||||||
|
model := strings.ToLower(strings.TrimSpace(c.Model))
|
||||||
|
return strings.Contains(baseURL, "deepseek") || strings.Contains(model, "deepseek")
|
||||||
|
}
|
||||||
|
|
||||||
// OpenAIReasoningConfig 全局默认与网关 profile(对话页可通过 ChatRequest.reasoning 覆盖,受 AllowClientReasoning 约束)。
|
// OpenAIReasoningConfig 全局默认与网关 profile(对话页可通过 ChatRequest.reasoning 覆盖,受 AllowClientReasoning 约束)。
|
||||||
type OpenAIReasoningConfig struct {
|
type OpenAIReasoningConfig struct {
|
||||||
// Mode: auto(默认)| on | off | default(与 auto 相同)。
|
// Mode: auto(默认)| on | off | default(与 auto 相同)。
|
||||||
@@ -1120,29 +1116,56 @@ const hitlAuditAgentPromptBase = `你是 CyberStrikeAI 人机协同审计 Agent
|
|||||||
|
|
||||||
你会收到 JSON,包含 hitlMode、toolName、arguments/argumentsObj、userMessage、thinking、reasoningChain、planning 等字段。
|
你会收到 JSON,包含 hitlMode、toolName、arguments/argumentsObj、userMessage、thinking、reasoningChain、planning 等字段。
|
||||||
|
|
||||||
|
规则优先级:
|
||||||
|
- 必须 reject 的规则
|
||||||
|
- 必须 approve 的规则
|
||||||
|
- 默认放行规则
|
||||||
|
|
||||||
|
规则匹配要求:
|
||||||
|
- 如果同时命中 reject 和 approve,必须 reject。
|
||||||
|
- 每次裁决必须给出一个规则编号,不得输出“命中规则:无”。
|
||||||
|
- 未命中明确 reject 或 approve 规则,但未发现明确破坏行为时,命中 D1:默认放行。
|
||||||
|
- 不得仅凭“本地执行”“设置超时”“文件名包含 test”判断脚本无害;必须检查实际 command、代码或请求参数。
|
||||||
|
- 如果脚本具体内容不可见,应表述为“当前参数未显示明确高危操作”,不能断言“无持久化影响”。
|
||||||
|
|
||||||
裁决基调(默认放行):
|
裁决基调(默认放行):
|
||||||
- 常规、低风险的渗透测试操作 → approve(如信息收集、端口/服务扫描、目录枚举、只读查询、无害探测命令)
|
- 绝大多数渗透测试操作(包括各类攻击 payload)都应放行
|
||||||
- 与用户授权、当前任务目标一致,且未见明确高危迹象 → approve
|
- 在裁决之前需要判断出:1、此请求的是干什么(理解请求内容)?2、此请求造成什么后果?
|
||||||
- 仅在「可能对系统造成实质影响」时 → reject
|
- 仅在「明确会直接破坏目标业务系统正常运行,或造成真实用户数据丢失/不可逆篡改」时 → reject
|
||||||
|
- 信息不足、参数略宽泛、仅为探测意图时 → approve
|
||||||
|
- 拿不准时 → approve(宁可放过可恢复的测试,不要误拦合法攻击)
|
||||||
|
|
||||||
必须 reject 的高危情形(示例,非穷举):
|
必须 reject 的高危情形(示例,非穷举):
|
||||||
- 删库、清表、批量删除数据、格式化磁盘、不可逆破坏
|
- 修改/重置任意用户或管理员密码、强制下线真实用户
|
||||||
- 修改/重置密码、创建或篡改管理员账号、持久化后门、开机自启
|
- 修改服务器或服务配置(系统配置、Web/数据库参数、防火墙、启动项、计划任务)
|
||||||
- 向生产环境写入恶意载荷、勒索加密、停止关键服务、修改系统核心配置
|
- 修改/创建/删除用户、角色、权限(账户体系篡改)
|
||||||
- 明显越权:与任务/授权目标无关的破坏性操作
|
- 删除或大规模破坏数据/文件(DROP/TRUNCATE/DELETE 全表、rm 重要文件、清空数据库)
|
||||||
|
- 停止、禁用、重启业务服务(systemctl stop、kill 关键进程、shutdown、reboot)
|
||||||
|
- 大规模压测或高并发造成 DoS
|
||||||
|
|
||||||
不应单独作为 reject 理由的情形:
|
以下情形必须 approve(即使看起来“攻击性强”或包含写操作):
|
||||||
- 常规 nmap/curl/grep/读文件/枚举类命令本身
|
- 各类漏洞探测与利用 payload(SQL 注入含读写型、命令注入、反序列化、文件包含、路径遍历、SSRF、SSTI、XXE 等)
|
||||||
- 参数略显宽泛但无明确破坏意图(审查编辑模式可收窄参数后 approve)
|
- 文件上传测试,包括上传 webshell、写入 webshell 文件
|
||||||
- 仅因「信息不足」——若无上述高危迹象,应 approve 并可在 comment 中提示注意点`
|
- 命令执行探测、反弹 Shell、dnslog / 数据外带探测
|
||||||
|
- 数据库命令执行、写文件、写计划任务、内存马注入等 getshell 利用
|
||||||
|
- 常规速率的口令爆破(非 DoS 级)
|
||||||
|
- 信息收集、端口/目录扫描、枚举、抓取
|
||||||
|
- 读取、查询、探测类操作(含敏感文件)
|
||||||
|
- 攻陷后无害侦察命令(whoami/id/uname/ls/cat 等)
|
||||||
|
- 参数宽泛但无明确破坏意图的请求
|
||||||
|
|
||||||
|
判定原则:
|
||||||
|
- “写”本身不是拦截理由——渗透测试大量依赖写操作(注入、上传、爆破、getshell)
|
||||||
|
- 关键看是否破坏「业务可用性 / 关键数据完整性」
|
||||||
|
- 只有明确命中上方 reject 清单才 reject`
|
||||||
|
|
||||||
const hitlAuditAgentPromptApprovalOutput = `
|
const hitlAuditAgentPromptApprovalOutput = `
|
||||||
仅输出一行 JSON,不要 markdown 代码块:
|
仅输出一行 JSON,不要 markdown 代码块:
|
||||||
{"decision":"approve"|"reject","comment":"简要理由"}`
|
{"decision":"approve"|"reject","comment":"实际操作:...;成功后的后果:...;命中规则:..."}`
|
||||||
|
|
||||||
const hitlAuditAgentPromptReviewEditOutput = `
|
const hitlAuditAgentPromptReviewEditOutput = `
|
||||||
仅输出一行 JSON,不要 markdown 代码块:
|
仅输出一行 JSON,不要 markdown 代码块:
|
||||||
{"decision":"approve"|"reject","comment":"简要理由","editedArguments":{...}}
|
{"decision":"approve"|"reject","comment":"实际操作:...;成功后的后果:...;命中规则:...","editedArguments":{...}}
|
||||||
|
|
||||||
editedArguments 规则(仅 approve 且需要改参时填写,否则省略该字段):
|
editedArguments 规则(仅 approve 且需要改参时填写,否则省略该字段):
|
||||||
- 提供完整替换后的工具参数对象,键名与 argumentsObj 一致
|
- 提供完整替换后的工具参数对象,键名与 argumentsObj 一致
|
||||||
@@ -1350,7 +1373,7 @@ func Load(path string) (*Config, error) {
|
|||||||
cfg.Audit.MaxDetailBytes = 8192
|
cfg.Audit.MaxDetailBytes = 8192
|
||||||
}
|
}
|
||||||
cfg.ApplyDefaultAIChannel()
|
cfg.ApplyDefaultAIChannel()
|
||||||
if err := validateModelOutputLimits(cfg.OpenAI, cfg.MultiAgent.EinoMiddleware); err != nil {
|
if err := validateOpenAIOutputLimits(cfg.OpenAI); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
// 如果配置了工具目录,从目录加载工具配置
|
// 如果配置了工具目录,从目录加载工具配置
|
||||||
@@ -1415,22 +1438,10 @@ func Load(path string) (*Config, error) {
|
|||||||
return &cfg, nil
|
return &cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateModelOutputLimits(openAI OpenAIConfig, mw MultiAgentEinoMiddlewareConfig) error {
|
func validateOpenAIOutputLimits(openAI OpenAIConfig) error {
|
||||||
if openAI.MaxCompletionTokens < 0 {
|
if openAI.MaxCompletionTokens < 0 {
|
||||||
return fmt.Errorf("openai.max_completion_tokens 必须为正数")
|
return fmt.Errorf("openai.max_completion_tokens 必须为正数")
|
||||||
}
|
}
|
||||||
if mw.MaxToolArgumentsBytes < 0 {
|
|
||||||
return fmt.Errorf("multi_agent.eino_middleware.max_tool_arguments_bytes 必须为正数")
|
|
||||||
}
|
|
||||||
if mw.MaxShellCommandBytes < 0 {
|
|
||||||
return fmt.Errorf("multi_agent.eino_middleware.max_shell_command_bytes 必须为正数")
|
|
||||||
}
|
|
||||||
if mw.ModelOutputRepairMaxAttempts < 0 {
|
|
||||||
return fmt.Errorf("multi_agent.eino_middleware.model_output_repair_max_attempts 必须为正数")
|
|
||||||
}
|
|
||||||
if mw.MaxShellCommandBytesEffective() > mw.MaxToolArgumentsBytesEffective() {
|
|
||||||
return fmt.Errorf("multi_agent.eino_middleware.max_shell_command_bytes 不能大于 max_tool_arguments_bytes")
|
|
||||||
}
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -169,18 +169,11 @@ func TestSummarizationOutputReserveTokensEffective(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestModelOutputLimitDefaultsAndValidation(t *testing.T) {
|
func TestOpenAIOutputLimitValidation(t *testing.T) {
|
||||||
if got := (OpenAIConfig{}).MaxCompletionTokensEffective(); got != DefaultMaxCompletionTokens {
|
if got := (OpenAIConfig{}).MaxCompletionTokensEffective(); got != DefaultMaxCompletionTokens {
|
||||||
t.Fatalf("max completion default=%d", got)
|
t.Fatalf("max completion default=%d", got)
|
||||||
}
|
}
|
||||||
mw := MultiAgentEinoMiddlewareConfig{}
|
if err := validateOpenAIOutputLimits(OpenAIConfig{MaxCompletionTokens: -1}); err == nil {
|
||||||
if mw.MaxToolArgumentsBytesEffective() != 65536 || mw.MaxShellCommandBytesEffective() != 65536 || mw.ModelOutputRepairMaxAttemptsEffective() != 1 {
|
|
||||||
t.Fatalf("unexpected guard defaults: %+v", mw)
|
|
||||||
}
|
|
||||||
if err := validateModelOutputLimits(OpenAIConfig{}, MultiAgentEinoMiddlewareConfig{MaxShellCommandBytes: 100, MaxToolArgumentsBytes: 99}); err == nil {
|
|
||||||
t.Fatal("shell limit greater than generic limit must fail")
|
|
||||||
}
|
|
||||||
if err := validateModelOutputLimits(OpenAIConfig{MaxCompletionTokens: -1}, MultiAgentEinoMiddlewareConfig{}); err == nil {
|
|
||||||
t.Fatal("negative completion limit must fail")
|
t.Fatal("negative completion limit must fail")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDefaultHitlAuditAgentPromptIncludesPrioritizedRules(t *testing.T) {
|
||||||
|
prompt := DefaultHitlAuditAgentPrompt()
|
||||||
|
for _, want := range []string{
|
||||||
|
"如果同时命中 reject 和 approve,必须 reject",
|
||||||
|
"修改/重置任意用户或管理员密码",
|
||||||
|
"修改/创建/删除用户、角色、权限",
|
||||||
|
"停止、禁用、重启业务服务",
|
||||||
|
"命中规则:...",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(prompt, want) {
|
||||||
|
t.Fatalf("default approval prompt missing %q", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDefaultHitlAuditAgentPromptReviewEditKeepsEditedArguments(t *testing.T) {
|
||||||
|
prompt := DefaultHitlAuditAgentPromptReviewEdit()
|
||||||
|
if !strings.Contains(prompt, `"editedArguments":{...}`) {
|
||||||
|
t.Fatal("review-edit prompt must preserve editedArguments output")
|
||||||
|
}
|
||||||
|
if !strings.Contains(prompt, "命中规则:...") {
|
||||||
|
t.Fatal("review-edit prompt must require a matched rule")
|
||||||
|
}
|
||||||
|
}
|
||||||
+150
-8
@@ -13,6 +13,7 @@ import (
|
|||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
"go.uber.org/zap"
|
||||||
"golang.org/x/net/idna"
|
"golang.org/x/net/idna"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -50,6 +51,7 @@ type Asset struct {
|
|||||||
LastScanTaskID string `json:"last_scan_task_id,omitempty"`
|
LastScanTaskID string `json:"last_scan_task_id,omitempty"`
|
||||||
VulnerabilityCount int `json:"vulnerability_count"`
|
VulnerabilityCount int `json:"vulnerability_count"`
|
||||||
RiskLevel string `json:"risk_level"`
|
RiskLevel string `json:"risk_level"`
|
||||||
|
RiskScore int `json:"-"`
|
||||||
OwnerUserID string `json:"-"`
|
OwnerUserID string `json:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -443,15 +445,15 @@ func assetWhere(filter AssetListFilter, access RBACListAccess) (string, []interf
|
|||||||
args = append(args, *filter.Port)
|
args = append(args, *filter.Port)
|
||||||
}
|
}
|
||||||
if filter.RiskLevel != "" {
|
if filter.RiskLevel != "" {
|
||||||
query += " AND " + assetRiskLevelExpr + " = ?"
|
query += " AND " + assetRiskLevelCachedExpr + " = ?"
|
||||||
args = append(args, strings.ToLower(strings.TrimSpace(filter.RiskLevel)))
|
args = append(args, strings.ToLower(strings.TrimSpace(filter.RiskLevel)))
|
||||||
}
|
}
|
||||||
if filter.MinVulnerabilities != nil {
|
if filter.MinVulnerabilities != nil {
|
||||||
query += " AND " + assetVulnerabilityCountExpr + " >= ?"
|
query += " AND " + assetVulnerabilityCountCachedExpr + " >= ?"
|
||||||
args = append(args, *filter.MinVulnerabilities)
|
args = append(args, *filter.MinVulnerabilities)
|
||||||
}
|
}
|
||||||
if filter.MaxVulnerabilities != nil {
|
if filter.MaxVulnerabilities != nil {
|
||||||
query += " AND " + assetVulnerabilityCountExpr + " <= ?"
|
query += " AND " + assetVulnerabilityCountCachedExpr + " <= ?"
|
||||||
args = append(args, *filter.MaxVulnerabilities)
|
args = append(args, *filter.MaxVulnerabilities)
|
||||||
}
|
}
|
||||||
for _, item := range []struct {
|
for _, item := range []struct {
|
||||||
@@ -573,20 +575,24 @@ const assetVulnerabilityMatchExpr = `(
|
|||||||
|
|
||||||
const assetVulnerabilityCountExpr = `(SELECT COUNT(DISTINCT v.id) FROM vulnerabilities v WHERE ` + assetVulnerabilityMatchExpr + `)`
|
const assetVulnerabilityCountExpr = `(SELECT COUNT(DISTINCT v.id) FROM vulnerabilities v WHERE ` + assetVulnerabilityMatchExpr + `)`
|
||||||
|
|
||||||
const assetRiskScoreExpr = `COALESCE((
|
const assetRiskScoreQueryExpr = `COALESCE((
|
||||||
SELECT MAX(CASE LOWER(COALESCE(v.severity,'')) WHEN 'critical' THEN 5 WHEN 'high' THEN 4 WHEN 'medium' THEN 3 WHEN 'low' THEN 2 WHEN 'info' THEN 1 ELSE 0 END)
|
SELECT MAX(CASE LOWER(COALESCE(v.severity,'')) WHEN 'critical' THEN 5 WHEN 'high' THEN 4 WHEN 'medium' THEN 3 WHEN 'low' THEN 2 WHEN 'info' THEN 1 ELSE 0 END)
|
||||||
FROM vulnerabilities v
|
FROM vulnerabilities v
|
||||||
WHERE LOWER(COALESCE(v.status,'open')) NOT IN ('fixed','false_positive','ignored') AND ` + assetVulnerabilityMatchExpr + `
|
WHERE LOWER(COALESCE(v.status,'open')) NOT IN ('fixed','false_positive','ignored') AND ` + assetVulnerabilityMatchExpr + `
|
||||||
),0)`
|
),0)`
|
||||||
|
|
||||||
const assetRiskLevelExpr = `(CASE WHEN ` + assetEffectiveLastScanExpr + ` IS NULL THEN 'unassessed' ELSE CASE ` + assetRiskScoreExpr + `
|
const assetRiskLevelQueryExpr = `(CASE WHEN ` + assetEffectiveLastScanExpr + ` IS NULL THEN 'unassessed' ELSE CASE ` + assetRiskScoreQueryExpr + `
|
||||||
WHEN 5 THEN 'critical' WHEN 4 THEN 'high' WHEN 3 THEN 'medium' WHEN 2 THEN 'low' WHEN 1 THEN 'info' ELSE 'normal' END END)`
|
WHEN 5 THEN 'critical' WHEN 4 THEN 'high' WHEN 3 THEN 'medium' WHEN 2 THEN 'low' WHEN 1 THEN 'info' ELSE 'normal' END END)`
|
||||||
|
|
||||||
|
const assetVulnerabilityCountCachedExpr = `COALESCE(assets.vulnerability_count,0)`
|
||||||
|
const assetRiskScoreCachedExpr = `COALESCE(assets.risk_score,0)`
|
||||||
|
const assetRiskLevelCachedExpr = `COALESCE(NULLIF(assets.risk_level,''),'unassessed')`
|
||||||
|
|
||||||
const assetSelectColumns = `assets.id,COALESCE(assets.project_id,''),COALESCE(p.name,''),assets.host,assets.ip,assets.port,assets.domain,assets.protocol,assets.title,assets.server,assets.country,
|
const assetSelectColumns = `assets.id,COALESCE(assets.project_id,''),COALESCE(p.name,''),assets.host,assets.ip,assets.port,assets.domain,assets.protocol,assets.title,assets.server,assets.country,
|
||||||
assets.province,assets.city,assets.responsible_person,assets.department,assets.business_system,assets.environment,assets.criticality,
|
assets.province,assets.city,assets.responsible_person,assets.department,assets.business_system,assets.environment,assets.criticality,
|
||||||
assets.source,assets.source_query,assets.status,assets.tags_json,assets.first_seen_at,assets.last_seen_at,assets.created_at,assets.updated_at,
|
assets.source,assets.source_query,assets.status,assets.tags_json,assets.first_seen_at,assets.last_seen_at,assets.created_at,assets.updated_at,
|
||||||
` + assetEffectiveLastScanExpr + `,COALESCE(assets.last_scan_conversation_id,''),COALESCE(assets.last_scan_queue_id,''),COALESCE(assets.last_scan_task_id,''),
|
` + assetEffectiveLastScanExpr + `,COALESCE(assets.last_scan_conversation_id,''),COALESCE(assets.last_scan_queue_id,''),COALESCE(assets.last_scan_task_id,''),
|
||||||
` + assetVulnerabilityCountExpr + `,` + assetRiskLevelExpr
|
` + assetVulnerabilityCountCachedExpr + `,` + assetRiskLevelCachedExpr
|
||||||
|
|
||||||
// MarkAssetScanned links an asset to the conversation or batch subtask created from it.
|
// MarkAssetScanned links an asset to the conversation or batch subtask created from it.
|
||||||
// The link lets the asset list show the latest scan time and vulnerabilities produced by that scan.
|
// The link lets the asset list show the latest scan time and vulnerabilities produced by that scan.
|
||||||
@@ -601,6 +607,9 @@ func (db *DB) MarkAssetScanned(id, conversationID, queueID, taskID string, acces
|
|||||||
if n == 0 {
|
if n == 0 {
|
||||||
return sql.ErrNoRows
|
return sql.ErrNoRows
|
||||||
}
|
}
|
||||||
|
if err := db.RefreshAssetRiskCache(id); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -629,6 +638,9 @@ func (db *DB) CompleteAssetScan(id, conversationID string, access RBACListAccess
|
|||||||
if n == 0 {
|
if n == 0 {
|
||||||
return sql.ErrNoRows
|
return sql.ErrNoRows
|
||||||
}
|
}
|
||||||
|
if err := db.RefreshAssetRiskCache(id); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -638,6 +650,136 @@ func (db *DB) BatchTaskBelongsToQueue(taskID, queueID string) bool {
|
|||||||
return err == nil && count > 0
|
return err == nil && count > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func assetRiskLevelFromScore(score int, scanned bool) string {
|
||||||
|
if !scanned {
|
||||||
|
return "unassessed"
|
||||||
|
}
|
||||||
|
switch score {
|
||||||
|
case 5:
|
||||||
|
return "critical"
|
||||||
|
case 4:
|
||||||
|
return "high"
|
||||||
|
case 3:
|
||||||
|
return "medium"
|
||||||
|
case 2:
|
||||||
|
return "low"
|
||||||
|
case 1:
|
||||||
|
return "info"
|
||||||
|
default:
|
||||||
|
return "normal"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefreshAssetRiskCache recalculates the denormalized fields used by the asset
|
||||||
|
// list. Keeping this in the database layer makes Web API and MCP writes share
|
||||||
|
// one consistency path.
|
||||||
|
func (db *DB) RefreshAssetRiskCache(assetID string) error {
|
||||||
|
assetID = strings.TrimSpace(assetID)
|
||||||
|
if assetID == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var count int
|
||||||
|
if err := db.QueryRow("SELECT "+assetVulnerabilityCountExpr+" FROM assets WHERE assets.id=?", assetID).Scan(&count); err != nil {
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("刷新资产漏洞数量失败: %w", err)
|
||||||
|
}
|
||||||
|
var score int
|
||||||
|
if err := db.QueryRow("SELECT "+assetRiskScoreQueryExpr+" FROM assets WHERE assets.id=?", assetID).Scan(&score); err != nil {
|
||||||
|
return fmt.Errorf("刷新资产风险分数失败: %w", err)
|
||||||
|
}
|
||||||
|
var lastScan interface{}
|
||||||
|
if err := db.QueryRow("SELECT "+assetEffectiveLastScanExpr+" FROM assets WHERE assets.id=?", assetID).Scan(&lastScan); err != nil {
|
||||||
|
return fmt.Errorf("刷新资产扫描状态失败: %w", err)
|
||||||
|
}
|
||||||
|
level := assetRiskLevelFromScore(score, lastScan != nil)
|
||||||
|
if _, err := db.Exec(`UPDATE assets SET vulnerability_count=?, risk_score=?, risk_level=? WHERE id=?`, count, score, level, assetID); err != nil {
|
||||||
|
return fmt.Errorf("更新资产风险缓存失败: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) RefreshAllAssetRiskCache() error {
|
||||||
|
rows, err := db.Query(`SELECT id FROM assets`)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("查询资产列表失败: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
var id string
|
||||||
|
if err := rows.Scan(&id); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := db.RefreshAssetRiskCache(id); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) AssetIDsForVulnerabilityConversations(conversationIDs []string) ([]string, error) {
|
||||||
|
seen := map[string]struct{}{}
|
||||||
|
cleaned := make([]string, 0, len(conversationIDs))
|
||||||
|
for _, id := range conversationIDs {
|
||||||
|
id = strings.TrimSpace(id)
|
||||||
|
if id == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := seen[id]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[id] = struct{}{}
|
||||||
|
cleaned = append(cleaned, id)
|
||||||
|
}
|
||||||
|
if len(cleaned) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
placeholders := strings.TrimRight(strings.Repeat("?,", len(cleaned)), ",")
|
||||||
|
args := make([]interface{}, 0, len(cleaned)*2)
|
||||||
|
for _, id := range cleaned {
|
||||||
|
args = append(args, id)
|
||||||
|
}
|
||||||
|
for _, id := range cleaned {
|
||||||
|
args = append(args, id)
|
||||||
|
}
|
||||||
|
rows, err := db.Query(`SELECT DISTINCT assets.id FROM assets
|
||||||
|
WHERE assets.last_scan_conversation_id IN (`+placeholders+`)
|
||||||
|
OR assets.last_scan_task_id IN (SELECT bt.id FROM batch_tasks bt WHERE bt.conversation_id IN (`+placeholders+`))`, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("查询受影响资产失败: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
assetIDs := []string{}
|
||||||
|
for rows.Next() {
|
||||||
|
var id string
|
||||||
|
if err := rows.Scan(&id); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
assetIDs = append(assetIDs, id)
|
||||||
|
}
|
||||||
|
return assetIDs, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) RefreshAssetRiskCacheForConversations(conversationIDs ...string) error {
|
||||||
|
assetIDs, err := db.AssetIDsForVulnerabilityConversations(conversationIDs)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, id := range assetIDs {
|
||||||
|
if err := db.RefreshAssetRiskCache(id); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) refreshAssetRiskCacheForConversationsBestEffort(conversationIDs ...string) {
|
||||||
|
if err := db.RefreshAssetRiskCacheForConversations(conversationIDs...); err != nil && db.logger != nil {
|
||||||
|
db.logger.Warn("刷新资产风险缓存失败", zap.Error(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (db *DB) ListAssets(limit, offset int, filter AssetListFilter, access RBACListAccess) ([]*Asset, int, error) {
|
func (db *DB) ListAssets(limit, offset int, filter AssetListFilter, access RBACListAccess) ([]*Asset, int, error) {
|
||||||
if limit < 1 {
|
if limit < 1 {
|
||||||
limit = 20
|
limit = 20
|
||||||
@@ -726,9 +868,9 @@ func assetOrderBy(sortBy, sortOrder string) string {
|
|||||||
case "port":
|
case "port":
|
||||||
expression = "assets.port"
|
expression = "assets.port"
|
||||||
case "vulnerability_count":
|
case "vulnerability_count":
|
||||||
expression = assetVulnerabilityCountExpr
|
expression = assetVulnerabilityCountCachedExpr
|
||||||
case "risk_level":
|
case "risk_level":
|
||||||
expression = assetRiskScoreExpr
|
expression = assetRiskScoreCachedExpr
|
||||||
default:
|
default:
|
||||||
expression = "assets.last_seen_at"
|
expression = "assets.last_seen_at"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -383,7 +383,12 @@ func TestAssetScanLinkReturnsTimeAndRelatedVulnerabilities(t *testing.T) {
|
|||||||
if linked.LastScanAt == nil || linked.LastScanConversationID != conv.ID || linked.VulnerabilityCount != 1 || linked.RiskLevel != "high" {
|
if linked.LastScanAt == nil || linked.LastScanConversationID != conv.ID || linked.VulnerabilityCount != 1 || linked.RiskLevel != "high" {
|
||||||
t.Fatalf("unexpected scan metadata: %#v", linked)
|
t.Fatalf("unexpected scan metadata: %#v", linked)
|
||||||
}
|
}
|
||||||
if _, err := db.Exec(`UPDATE vulnerabilities SET status='fixed' WHERE conversation_id=?`, conv.ID); err != nil {
|
vulns, err := db.ListVulnerabilities(10, 0, VulnerabilityListFilter{ConversationID: conv.ID})
|
||||||
|
if err != nil || len(vulns) != 1 {
|
||||||
|
t.Fatalf("list linked vulnerabilities: len=%d err=%v", len(vulns), err)
|
||||||
|
}
|
||||||
|
vulns[0].Status = "fixed"
|
||||||
|
if err := db.UpdateVulnerability(vulns[0].ID, vulns[0]); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
resolved, err := db.GetAsset(assets[0].ID, RBACListAccess{Scope: RBACScopeAll})
|
resolved, err := db.GetAsset(assets[0].ID, RBACListAccess{Scope: RBACScopeAll})
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ type Conversation struct {
|
|||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
ProjectID string `json:"projectId,omitempty"`
|
ProjectID string `json:"projectId,omitempty"`
|
||||||
RoleName string `json:"roleName,omitempty"`
|
RoleName string `json:"roleName,omitempty"`
|
||||||
|
AgentMode string `json:"agentMode,omitempty"`
|
||||||
Pinned bool `json:"pinned"`
|
Pinned bool `json:"pinned"`
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
UpdatedAt time.Time `json:"updatedAt"`
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
@@ -59,29 +60,30 @@ func (db *DB) CreateConversationWithWebshell(webshellConnectionID, title string,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
roleName := normalizeConversationRoleName(meta.RoleName)
|
roleName := normalizeConversationRoleName(meta.RoleName)
|
||||||
|
agentMode := normalizeConversationAgentMode(meta.AgentMode)
|
||||||
|
|
||||||
var err error
|
var err error
|
||||||
wsID := strings.TrimSpace(webshellConnectionID)
|
wsID := strings.TrimSpace(webshellConnectionID)
|
||||||
switch {
|
switch {
|
||||||
case wsID != "" && projectID != "":
|
case wsID != "" && projectID != "":
|
||||||
_, err = db.Exec(
|
_, err = db.Exec(
|
||||||
"INSERT INTO conversations (id, title, created_at, updated_at, webshell_connection_id, project_id, role_name) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
"INSERT INTO conversations (id, title, created_at, updated_at, webshell_connection_id, project_id, role_name, agent_mode) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
id, title, now, now, wsID, projectID, roleName,
|
id, title, now, now, wsID, projectID, roleName, agentMode,
|
||||||
)
|
)
|
||||||
case wsID != "":
|
case wsID != "":
|
||||||
_, err = db.Exec(
|
_, err = db.Exec(
|
||||||
"INSERT INTO conversations (id, title, created_at, updated_at, webshell_connection_id, role_name) VALUES (?, ?, ?, ?, ?, ?)",
|
"INSERT INTO conversations (id, title, created_at, updated_at, webshell_connection_id, role_name, agent_mode) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||||
id, title, now, now, wsID, roleName,
|
id, title, now, now, wsID, roleName, agentMode,
|
||||||
)
|
)
|
||||||
case projectID != "":
|
case projectID != "":
|
||||||
_, err = db.Exec(
|
_, err = db.Exec(
|
||||||
"INSERT INTO conversations (id, title, created_at, updated_at, project_id, role_name) VALUES (?, ?, ?, ?, ?, ?)",
|
"INSERT INTO conversations (id, title, created_at, updated_at, project_id, role_name, agent_mode) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||||
id, title, now, now, projectID, roleName,
|
id, title, now, now, projectID, roleName, agentMode,
|
||||||
)
|
)
|
||||||
default:
|
default:
|
||||||
_, err = db.Exec(
|
_, err = db.Exec(
|
||||||
"INSERT INTO conversations (id, title, created_at, updated_at, role_name) VALUES (?, ?, ?, ?, ?)",
|
"INSERT INTO conversations (id, title, created_at, updated_at, role_name, agent_mode) VALUES (?, ?, ?, ?, ?, ?)",
|
||||||
id, title, now, now, roleName,
|
id, title, now, now, roleName, agentMode,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -93,6 +95,7 @@ func (db *DB) CreateConversationWithWebshell(webshellConnectionID, title string,
|
|||||||
Title: title,
|
Title: title,
|
||||||
ProjectID: projectID,
|
ProjectID: projectID,
|
||||||
RoleName: roleName,
|
RoleName: roleName,
|
||||||
|
AgentMode: agentMode,
|
||||||
CreatedAt: now,
|
CreatedAt: now,
|
||||||
UpdatedAt: now,
|
UpdatedAt: now,
|
||||||
}
|
}
|
||||||
@@ -240,10 +243,11 @@ func (db *DB) GetConversation(id string) (*Conversation, error) {
|
|||||||
|
|
||||||
var projectID sql.NullString
|
var projectID sql.NullString
|
||||||
var roleName sql.NullString
|
var roleName sql.NullString
|
||||||
|
var agentMode sql.NullString
|
||||||
err := db.QueryRow(
|
err := db.QueryRow(
|
||||||
"SELECT id, title, pinned, created_at, updated_at, project_id, role_name FROM conversations WHERE id = ?",
|
"SELECT id, title, pinned, created_at, updated_at, project_id, role_name, agent_mode FROM conversations WHERE id = ?",
|
||||||
id,
|
id,
|
||||||
).Scan(&conv.ID, &conv.Title, &pinned, &createdAt, &updatedAt, &projectID, &roleName)
|
).Scan(&conv.ID, &conv.Title, &pinned, &createdAt, &updatedAt, &projectID, &roleName, &agentMode)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
return nil, fmt.Errorf("对话不存在")
|
return nil, fmt.Errorf("对话不存在")
|
||||||
@@ -256,6 +260,9 @@ func (db *DB) GetConversation(id string) (*Conversation, error) {
|
|||||||
if roleName.Valid {
|
if roleName.Valid {
|
||||||
conv.RoleName = normalizeConversationRoleName(roleName.String)
|
conv.RoleName = normalizeConversationRoleName(roleName.String)
|
||||||
}
|
}
|
||||||
|
if agentMode.Valid {
|
||||||
|
conv.AgentMode = normalizeConversationAgentMode(agentMode.String)
|
||||||
|
}
|
||||||
|
|
||||||
// 尝试多种时间格式解析
|
// 尝试多种时间格式解析
|
||||||
var err1, err2 error
|
var err1, err2 error
|
||||||
@@ -330,10 +337,11 @@ func (db *DB) GetConversationLite(id string) (*Conversation, error) {
|
|||||||
|
|
||||||
var projectID sql.NullString
|
var projectID sql.NullString
|
||||||
var roleName sql.NullString
|
var roleName sql.NullString
|
||||||
|
var agentMode sql.NullString
|
||||||
err := db.QueryRow(
|
err := db.QueryRow(
|
||||||
"SELECT id, title, pinned, created_at, updated_at, project_id, role_name FROM conversations WHERE id = ?",
|
"SELECT id, title, pinned, created_at, updated_at, project_id, role_name, agent_mode FROM conversations WHERE id = ?",
|
||||||
id,
|
id,
|
||||||
).Scan(&conv.ID, &conv.Title, &pinned, &createdAt, &updatedAt, &projectID, &roleName)
|
).Scan(&conv.ID, &conv.Title, &pinned, &createdAt, &updatedAt, &projectID, &roleName, &agentMode)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
return nil, fmt.Errorf("对话不存在")
|
return nil, fmt.Errorf("对话不存在")
|
||||||
@@ -346,6 +354,9 @@ func (db *DB) GetConversationLite(id string) (*Conversation, error) {
|
|||||||
if roleName.Valid {
|
if roleName.Valid {
|
||||||
conv.RoleName = normalizeConversationRoleName(roleName.String)
|
conv.RoleName = normalizeConversationRoleName(roleName.String)
|
||||||
}
|
}
|
||||||
|
if agentMode.Valid {
|
||||||
|
conv.AgentMode = normalizeConversationAgentMode(agentMode.String)
|
||||||
|
}
|
||||||
|
|
||||||
// 尝试多种时间格式解析
|
// 尝试多种时间格式解析
|
||||||
var err1, err2 error
|
var err1, err2 error
|
||||||
@@ -384,6 +395,17 @@ func normalizeConversationRoleName(roleName string) string {
|
|||||||
return roleName
|
return roleName
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func normalizeConversationAgentMode(agentMode string) string {
|
||||||
|
agentMode = strings.ToLower(strings.TrimSpace(agentMode))
|
||||||
|
agentMode = strings.ReplaceAll(agentMode, "-", "_")
|
||||||
|
switch agentMode {
|
||||||
|
case "deep", "plan_execute", "supervisor":
|
||||||
|
return agentMode
|
||||||
|
default:
|
||||||
|
return "eino_single"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (db *DB) SetConversationRoleName(id, roleName string) error {
|
func (db *DB) SetConversationRoleName(id, roleName string) error {
|
||||||
roleName = normalizeConversationRoleName(roleName)
|
roleName = normalizeConversationRoleName(roleName)
|
||||||
_, err := db.Exec(
|
_, err := db.Exec(
|
||||||
@@ -396,6 +418,18 @@ func (db *DB) SetConversationRoleName(id, roleName string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (db *DB) SetConversationAgentMode(id, agentMode string) error {
|
||||||
|
agentMode = normalizeConversationAgentMode(agentMode)
|
||||||
|
_, err := db.Exec(
|
||||||
|
"UPDATE conversations SET agent_mode = ? WHERE id = ?",
|
||||||
|
agentMode, id,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("更新对话模式失败: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func conversationProjectIDColumn(alias string) string {
|
func conversationProjectIDColumn(alias string) string {
|
||||||
if alias != "" {
|
if alias != "" {
|
||||||
return alias + ".project_id"
|
return alias + ".project_id"
|
||||||
@@ -520,7 +554,7 @@ func (db *DB) ListConversations(limit, offset int, search, sortBy, projectID str
|
|||||||
where, args = appendConversationProjectFilter(where, args, projectID, "c")
|
where, args = appendConversationProjectFilter(where, args, projectID, "c")
|
||||||
args = append(args, limit, offset)
|
args = append(args, limit, offset)
|
||||||
rows, err = db.Query(
|
rows, err = db.Query(
|
||||||
`SELECT c.id, c.title, COALESCE(c.pinned, 0), c.created_at, c.updated_at, c.project_id, c.role_name
|
`SELECT c.id, c.title, COALESCE(c.pinned, 0), c.created_at, c.updated_at, c.project_id, c.role_name, c.agent_mode
|
||||||
FROM conversations c`+where+`
|
FROM conversations c`+where+`
|
||||||
`+orderClause+`
|
`+orderClause+`
|
||||||
LIMIT ? OFFSET ?`,
|
LIMIT ? OFFSET ?`,
|
||||||
@@ -536,7 +570,7 @@ func (db *DB) ListConversations(limit, offset int, search, sortBy, projectID str
|
|||||||
}
|
}
|
||||||
args = append(args, limit, offset)
|
args = append(args, limit, offset)
|
||||||
rows, err = db.Query(
|
rows, err = db.Query(
|
||||||
"SELECT id, title, COALESCE(pinned, 0), created_at, updated_at, project_id, role_name FROM conversations"+where+" "+orderClause+" LIMIT ? OFFSET ?",
|
"SELECT id, title, COALESCE(pinned, 0), created_at, updated_at, project_id, role_name, agent_mode FROM conversations"+where+" "+orderClause+" LIMIT ? OFFSET ?",
|
||||||
args...,
|
args...,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -564,7 +598,7 @@ func (db *DB) ListConversationsForAccess(limit, offset int, search, sortBy, proj
|
|||||||
where, args = appendConversationAccessFilter(where, args, userID, scope, "c")
|
where, args = appendConversationAccessFilter(where, args, userID, scope, "c")
|
||||||
args = append(args, limit, offset)
|
args = append(args, limit, offset)
|
||||||
rows, err = db.Query(
|
rows, err = db.Query(
|
||||||
`SELECT c.id, c.title, COALESCE(c.pinned, 0), c.created_at, c.updated_at, c.project_id, c.role_name
|
`SELECT c.id, c.title, COALESCE(c.pinned, 0), c.created_at, c.updated_at, c.project_id, c.role_name, c.agent_mode
|
||||||
FROM conversations c`+where+`
|
FROM conversations c`+where+`
|
||||||
`+orderClause+`
|
`+orderClause+`
|
||||||
LIMIT ? OFFSET ?`, args...)
|
LIMIT ? OFFSET ?`, args...)
|
||||||
@@ -579,7 +613,7 @@ func (db *DB) ListConversationsForAccess(limit, offset int, search, sortBy, proj
|
|||||||
}
|
}
|
||||||
args = append(args, limit, offset)
|
args = append(args, limit, offset)
|
||||||
rows, err = db.Query(
|
rows, err = db.Query(
|
||||||
"SELECT id, title, COALESCE(pinned, 0), created_at, updated_at, project_id, role_name FROM conversations"+where+" "+orderClause+" LIMIT ? OFFSET ?",
|
"SELECT id, title, COALESCE(pinned, 0), created_at, updated_at, project_id, role_name, agent_mode FROM conversations"+where+" "+orderClause+" LIMIT ? OFFSET ?",
|
||||||
args...)
|
args...)
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -597,7 +631,8 @@ func scanConversationRows(rows *sql.Rows) ([]*Conversation, error) {
|
|||||||
var pinned int
|
var pinned int
|
||||||
var projectID sql.NullString
|
var projectID sql.NullString
|
||||||
var roleName sql.NullString
|
var roleName sql.NullString
|
||||||
if err := rows.Scan(&conv.ID, &conv.Title, &pinned, &createdAt, &updatedAt, &projectID, &roleName); err != nil {
|
var agentMode sql.NullString
|
||||||
|
if err := rows.Scan(&conv.ID, &conv.Title, &pinned, &createdAt, &updatedAt, &projectID, &roleName, &agentMode); err != nil {
|
||||||
return nil, fmt.Errorf("扫描对话失败: %w", err)
|
return nil, fmt.Errorf("扫描对话失败: %w", err)
|
||||||
}
|
}
|
||||||
if projectID.Valid {
|
if projectID.Valid {
|
||||||
@@ -606,6 +641,9 @@ func scanConversationRows(rows *sql.Rows) ([]*Conversation, error) {
|
|||||||
if roleName.Valid {
|
if roleName.Valid {
|
||||||
conv.RoleName = normalizeConversationRoleName(roleName.String)
|
conv.RoleName = normalizeConversationRoleName(roleName.String)
|
||||||
}
|
}
|
||||||
|
if agentMode.Valid {
|
||||||
|
conv.AgentMode = normalizeConversationAgentMode(agentMode.String)
|
||||||
|
}
|
||||||
var err1, err2 error
|
var err1, err2 error
|
||||||
conv.CreatedAt, err1 = time.Parse("2006-01-02 15:04:05.999999999-07:00", createdAt)
|
conv.CreatedAt, err1 = time.Parse("2006-01-02 15:04:05.999999999-07:00", createdAt)
|
||||||
if err1 != nil {
|
if err1 != nil {
|
||||||
@@ -665,7 +703,7 @@ func (db *DB) ListUngroupedConversations(limit, offset int, sortBy, projectID st
|
|||||||
where, args = appendConversationProjectFilter(where, args, projectID, "c")
|
where, args = appendConversationProjectFilter(where, args, projectID, "c")
|
||||||
args = append(args, limit, offset)
|
args = append(args, limit, offset)
|
||||||
rows, err := db.Query(
|
rows, err := db.Query(
|
||||||
`SELECT c.id, c.title, COALESCE(c.pinned, 0), c.created_at, c.updated_at, c.project_id, c.role_name `+
|
`SELECT c.id, c.title, COALESCE(c.pinned, 0), c.created_at, c.updated_at, c.project_id, c.role_name, c.agent_mode `+
|
||||||
where+`
|
where+`
|
||||||
`+orderClause+`
|
`+orderClause+`
|
||||||
LIMIT ? OFFSET ?`,
|
LIMIT ? OFFSET ?`,
|
||||||
@@ -689,7 +727,7 @@ func (db *DB) ListUngroupedConversationsForAccess(limit, offset int, sortBy, pro
|
|||||||
where, args = appendConversationAccessFilter(where, args, userID, scope, "c")
|
where, args = appendConversationAccessFilter(where, args, userID, scope, "c")
|
||||||
args = append(args, limit, offset)
|
args = append(args, limit, offset)
|
||||||
rows, err := db.Query(
|
rows, err := db.Query(
|
||||||
`SELECT c.id, c.title, COALESCE(c.pinned, 0), c.created_at, c.updated_at, c.project_id, c.role_name `+
|
`SELECT c.id, c.title, COALESCE(c.pinned, 0), c.created_at, c.updated_at, c.project_id, c.role_name, c.agent_mode `+
|
||||||
where+`
|
where+`
|
||||||
`+orderClause+`
|
`+orderClause+`
|
||||||
LIMIT ? OFFSET ?`,
|
LIMIT ? OFFSET ?`,
|
||||||
@@ -835,6 +873,11 @@ func (db *DB) ConversationArtifactsBaseDir() string {
|
|||||||
return strings.TrimSpace(db.conversationArtifactsDir)
|
return strings.TrimSpace(db.conversationArtifactsDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// EinoWorkspaceBaseDir returns the configured agent workspace root.
|
||||||
|
func (db *DB) EinoWorkspaceBaseDir() string {
|
||||||
|
return db.einoWorkspaceBaseDir()
|
||||||
|
}
|
||||||
|
|
||||||
func (db *DB) einoWorkspaceBaseDir() string {
|
func (db *DB) einoWorkspaceBaseDir() string {
|
||||||
if db == nil {
|
if db == nil {
|
||||||
return ""
|
return ""
|
||||||
@@ -1310,6 +1353,39 @@ func (db *DB) AddProcessDetailWithID(messageID, conversationID, eventType, messa
|
|||||||
return id, nil
|
return id, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpdateProcessDetailContent 更新流式聚合详情的正文与元数据。使用固定记录 ID,
|
||||||
|
// 避免每个 token 新增一行,同时让页面刷新能读取到尚未结束的规划输出。
|
||||||
|
func (db *DB) UpdateProcessDetailContent(id, message string, data interface{}) error {
|
||||||
|
var dataJSON string
|
||||||
|
if data != nil {
|
||||||
|
jsonData, err := json.Marshal(data)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("序列化过程详情数据失败: %w", err)
|
||||||
|
}
|
||||||
|
dataJSON = string(jsonData)
|
||||||
|
}
|
||||||
|
result, err := db.Exec(
|
||||||
|
"UPDATE process_details SET message = ?, data = ? WHERE id = ?",
|
||||||
|
message, dataJSON, strings.TrimSpace(id),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("更新过程详情失败: %w", err)
|
||||||
|
}
|
||||||
|
if affected, affectedErr := result.RowsAffected(); affectedErr == nil && affected == 0 {
|
||||||
|
return fmt.Errorf("过程详情不存在: %s", id)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteProcessDetail 删除被判定为工具结果回显的临时规划记录。
|
||||||
|
func (db *DB) DeleteProcessDetail(id string) error {
|
||||||
|
_, err := db.Exec("DELETE FROM process_details WHERE id = ?", strings.TrimSpace(id))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("删除过程详情失败: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// GetProcessDetails 获取消息的过程详情
|
// GetProcessDetails 获取消息的过程详情
|
||||||
func (db *DB) GetProcessDetails(messageID string) ([]ProcessDetail, error) {
|
func (db *DB) GetProcessDetails(messageID string) ([]ProcessDetail, error) {
|
||||||
rows, err := db.Query(
|
rows, err := db.Query(
|
||||||
@@ -1377,10 +1453,15 @@ type ProcessDetailsSummary struct {
|
|||||||
ToolCount int `json:"toolCount"`
|
ToolCount int `json:"toolCount"`
|
||||||
ToolExecutions []ProcessDetailsToolExecution `json:"toolExecutions,omitempty"`
|
ToolExecutions []ProcessDetailsToolExecution `json:"toolExecutions,omitempty"`
|
||||||
MCPExecutionIDs []string `json:"mcpExecutionIds,omitempty"`
|
MCPExecutionIDs []string `json:"mcpExecutionIds,omitempty"`
|
||||||
|
StartedAt *time.Time `json:"startedAt,omitempty"`
|
||||||
|
CompletedAt *time.Time `json:"completedAt,omitempty"`
|
||||||
|
DurationMs int64 `json:"durationMs"`
|
||||||
|
Status string `json:"status,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ProcessDetailsToolExecution struct {
|
type ProcessDetailsToolExecution struct {
|
||||||
ProcessDetailID string `json:"processDetailId,omitempty"`
|
ProcessDetailID string `json:"processDetailId,omitempty"`
|
||||||
|
ResultDetailID string `json:"resultDetailId,omitempty"`
|
||||||
ToolName string `json:"toolName,omitempty"`
|
ToolName string `json:"toolName,omitempty"`
|
||||||
ToolCallID string `json:"toolCallId,omitempty"`
|
ToolCallID string `json:"toolCallId,omitempty"`
|
||||||
ExecutionID string `json:"executionId,omitempty"`
|
ExecutionID string `json:"executionId,omitempty"`
|
||||||
@@ -1398,6 +1479,54 @@ func (db *DB) GetProcessDetailsSummary(messageID string) (*ProcessDetailsSummary
|
|||||||
}
|
}
|
||||||
|
|
||||||
summary := &ProcessDetailsSummary{Total: total}
|
summary := &ProcessDetailsSummary{Total: total}
|
||||||
|
var messageCreatedAt, messageUpdatedAt sql.NullString
|
||||||
|
var messageContent string
|
||||||
|
if err := db.QueryRow(
|
||||||
|
"SELECT created_at, updated_at, content FROM messages WHERE id = ?",
|
||||||
|
messageID,
|
||||||
|
).Scan(&messageCreatedAt, &messageUpdatedAt, &messageContent); err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return nil, fmt.Errorf("查询过程详情耗时失败: %w", err)
|
||||||
|
}
|
||||||
|
if messageCreatedAt.Valid {
|
||||||
|
if startedAt := parseDBTime(messageCreatedAt.String); !startedAt.IsZero() {
|
||||||
|
summary.StartedAt = &startedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var terminalEvent, terminalCreatedAt string
|
||||||
|
terminalErr := db.QueryRow(`
|
||||||
|
SELECT event_type, created_at
|
||||||
|
FROM process_details
|
||||||
|
WHERE message_id = ? AND event_type IN ('cancelled', 'timeout', 'error')
|
||||||
|
ORDER BY created_at DESC, rowid DESC
|
||||||
|
LIMIT 1`, messageID).Scan(&terminalEvent, &terminalCreatedAt)
|
||||||
|
if terminalErr != nil && !errors.Is(terminalErr, sql.ErrNoRows) {
|
||||||
|
return nil, fmt.Errorf("查询过程详情终态失败: %w", terminalErr)
|
||||||
|
}
|
||||||
|
if terminalEvent != "" {
|
||||||
|
switch terminalEvent {
|
||||||
|
case "cancelled":
|
||||||
|
summary.Status = "cancelled"
|
||||||
|
case "timeout":
|
||||||
|
summary.Status = "timeout"
|
||||||
|
default:
|
||||||
|
summary.Status = "failed"
|
||||||
|
}
|
||||||
|
if completedAt := parseDBTime(terminalCreatedAt); !completedAt.IsZero() {
|
||||||
|
summary.CompletedAt = &completedAt
|
||||||
|
}
|
||||||
|
} else if strings.TrimSpace(messageContent) == "处理中..." || strings.TrimSpace(messageContent) == "Processing..." {
|
||||||
|
summary.Status = "running"
|
||||||
|
} else {
|
||||||
|
summary.Status = "completed"
|
||||||
|
if messageUpdatedAt.Valid {
|
||||||
|
if completedAt := parseDBTime(messageUpdatedAt.String); !completedAt.IsZero() {
|
||||||
|
summary.CompletedAt = &completedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if summary.StartedAt != nil && summary.CompletedAt != nil && !summary.CompletedAt.Before(*summary.StartedAt) {
|
||||||
|
summary.DurationMs = summary.CompletedAt.Sub(*summary.StartedAt).Milliseconds()
|
||||||
|
}
|
||||||
if total == 0 {
|
if total == 0 {
|
||||||
return summary, nil
|
return summary, nil
|
||||||
}
|
}
|
||||||
@@ -1419,11 +1548,12 @@ func (db *DB) GetProcessDetailsSummary(messageID string) (*ProcessDetailsSummary
|
|||||||
seenExecIDs := make(map[string]bool)
|
seenExecIDs := make(map[string]bool)
|
||||||
// A provider may reuse a fallback toolCallId across streaming rounds. Keep a
|
// 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
|
// FIFO per ID instead of a single index so every persisted call gets at most
|
||||||
// one result. Results without an ID fall back to the oldest unmatched call.
|
// 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)
|
toolIndexesByCallID := make(map[string][]int)
|
||||||
lastMatchedToolIndexByCallID := make(map[string]int)
|
lastMatchedToolIndexByCallID := make(map[string]int)
|
||||||
matchedToolIndexes := make([]bool, 0)
|
matchedToolIndexes := make([]bool, 0)
|
||||||
nextUnmatchedToolIdx := 0
|
|
||||||
for execRows.Next() {
|
for execRows.Next() {
|
||||||
var detailID string
|
var detailID string
|
||||||
var eventType string
|
var eventType string
|
||||||
@@ -1439,24 +1569,10 @@ func (db *DB) GetProcessDetailsSummary(messageID string) (*ProcessDetailsSummary
|
|||||||
if err := json.Unmarshal([]byte(dataJSON), &payload); err != nil {
|
if err := json.Unmarshal([]byte(dataJSON), &payload); err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
toolName, _ := payload["toolName"].(string)
|
toolName := processDetailString(payload, "toolName")
|
||||||
toolName = strings.TrimSpace(toolName)
|
toolCallID := processDetailString(payload, "toolCallId")
|
||||||
toolCallID, _ := payload["toolCallId"].(string)
|
execID := processDetailString(payload, "executionId")
|
||||||
toolCallID = strings.TrimSpace(toolCallID)
|
status := toolResultStatusFromPayload(payload, eventType)
|
||||||
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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if eventType == "tool_call" {
|
if eventType == "tool_call" {
|
||||||
summary.ToolExecutions = append(summary.ToolExecutions, ProcessDetailsToolExecution{
|
summary.ToolExecutions = append(summary.ToolExecutions, ProcessDetailsToolExecution{
|
||||||
ProcessDetailID: strings.TrimSpace(detailID),
|
ProcessDetailID: strings.TrimSpace(detailID),
|
||||||
@@ -1473,41 +1589,20 @@ func (db *DB) GetProcessDetailsSummary(messageID string) (*ProcessDetailsSummary
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if eventType == "tool_result" {
|
if eventType == "tool_result" {
|
||||||
idx := -1
|
idx := matchToolExecutionIndex(
|
||||||
if toolCallID != "" {
|
summary.ToolExecutions,
|
||||||
queue := toolIndexesByCallID[toolCallID]
|
matchedToolIndexes,
|
||||||
for len(queue) > 0 {
|
toolCallID,
|
||||||
candidate := queue[0]
|
toolName,
|
||||||
queue = queue[1:]
|
toolIndexesByCallID,
|
||||||
if candidate >= 0 && candidate < len(matchedToolIndexes) && !matchedToolIndexes[candidate] {
|
lastMatchedToolIndexByCallID,
|
||||||
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 {
|
|
||||||
for nextUnmatchedToolIdx < len(matchedToolIndexes) && matchedToolIndexes[nextUnmatchedToolIdx] {
|
|
||||||
nextUnmatchedToolIdx++
|
|
||||||
}
|
|
||||||
if nextUnmatchedToolIdx < len(matchedToolIndexes) {
|
|
||||||
idx = nextUnmatchedToolIdx
|
|
||||||
nextUnmatchedToolIdx++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if idx >= 0 && idx < len(summary.ToolExecutions) {
|
if idx >= 0 && idx < len(summary.ToolExecutions) {
|
||||||
matchedToolIndexes[idx] = true
|
matchedToolIndexes[idx] = true
|
||||||
if toolCallID != "" {
|
if toolCallID != "" {
|
||||||
lastMatchedToolIndexByCallID[toolCallID] = idx
|
lastMatchedToolIndexByCallID[toolCallID] = idx
|
||||||
}
|
}
|
||||||
|
summary.ToolExecutions[idx].ResultDetailID = strings.TrimSpace(detailID)
|
||||||
if summary.ToolExecutions[idx].ToolName == "" {
|
if summary.ToolExecutions[idx].ToolName == "" {
|
||||||
summary.ToolExecutions[idx].ToolName = toolName
|
summary.ToolExecutions[idx].ToolName = toolName
|
||||||
}
|
}
|
||||||
@@ -1517,6 +1612,8 @@ func (db *DB) GetProcessDetailsSummary(messageID string) (*ProcessDetailsSummary
|
|||||||
summary.ToolExecutions[idx].ExecutionID = execID
|
summary.ToolExecutions[idx].ExecutionID = execID
|
||||||
if status != "" {
|
if status != "" {
|
||||||
summary.ToolExecutions[idx].Status = status
|
summary.ToolExecutions[idx].Status = status
|
||||||
|
} else {
|
||||||
|
summary.ToolExecutions[idx].Status = "completed"
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
summary.ToolExecutions = append(summary.ToolExecutions, ProcessDetailsToolExecution{
|
summary.ToolExecutions = append(summary.ToolExecutions, ProcessDetailsToolExecution{
|
||||||
@@ -1573,6 +1670,82 @@ func (db *DB) GetProcessDetailsSummary(messageID string) (*ProcessDetailsSummary
|
|||||||
return summary, nil
|
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 分页获取消息的过程详情(按时间升序)。
|
// GetProcessDetailsPage 分页获取消息的过程详情(按时间升序)。
|
||||||
func (db *DB) GetProcessDetailsPage(messageID string, limit, offset int) ([]ProcessDetail, int, error) {
|
func (db *DB) GetProcessDetailsPage(messageID string, limit, offset int) ([]ProcessDetail, int, error) {
|
||||||
var total int
|
var total int
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ type ConversationCreateMeta struct {
|
|||||||
WebShellConnectionID string
|
WebShellConnectionID string
|
||||||
ProjectID string
|
ProjectID string
|
||||||
RoleName string
|
RoleName string
|
||||||
|
AgentMode string
|
||||||
ClientIP string
|
ClientIP string
|
||||||
SessionHint string
|
SessionHint string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -184,6 +184,7 @@ func (db *DB) initTables() error {
|
|||||||
created_at DATETIME NOT NULL,
|
created_at DATETIME NOT NULL,
|
||||||
updated_at DATETIME NOT NULL,
|
updated_at DATETIME NOT NULL,
|
||||||
role_name TEXT NOT NULL DEFAULT '默认',
|
role_name TEXT NOT NULL DEFAULT '默认',
|
||||||
|
agent_mode TEXT NOT NULL DEFAULT 'eino_single',
|
||||||
last_react_input TEXT,
|
last_react_input TEXT,
|
||||||
last_react_output TEXT
|
last_react_output TEXT
|
||||||
);`
|
);`
|
||||||
@@ -420,6 +421,7 @@ func (db *DB) initTables() error {
|
|||||||
responsible_person TEXT NOT NULL DEFAULT '', department TEXT NOT NULL DEFAULT '', business_system TEXT NOT NULL DEFAULT '',
|
responsible_person TEXT NOT NULL DEFAULT '', department TEXT NOT NULL DEFAULT '', business_system TEXT NOT NULL DEFAULT '',
|
||||||
environment TEXT NOT NULL DEFAULT '', criticality TEXT NOT NULL DEFAULT '',
|
environment TEXT NOT NULL DEFAULT '', criticality TEXT NOT NULL DEFAULT '',
|
||||||
source TEXT NOT NULL DEFAULT 'manual', source_query TEXT NOT NULL DEFAULT '', status TEXT NOT NULL DEFAULT 'active',
|
source TEXT NOT NULL DEFAULT 'manual', source_query TEXT NOT NULL DEFAULT '', status TEXT NOT NULL DEFAULT 'active',
|
||||||
|
vulnerability_count INTEGER NOT NULL DEFAULT 0, risk_score INTEGER NOT NULL DEFAULT 0, risk_level TEXT NOT NULL DEFAULT 'unassessed',
|
||||||
tags_json TEXT NOT NULL DEFAULT '[]', first_seen_at DATETIME NOT NULL, last_seen_at DATETIME NOT NULL,
|
tags_json TEXT NOT NULL DEFAULT '[]', first_seen_at DATETIME NOT NULL, last_seen_at DATETIME NOT NULL,
|
||||||
created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, owner_user_id TEXT,
|
created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, owner_user_id TEXT,
|
||||||
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE SET NULL
|
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE SET NULL
|
||||||
@@ -744,6 +746,9 @@ func (db *DB) initTables() error {
|
|||||||
CREATE INDEX IF NOT EXISTS idx_assets_status ON assets(status);
|
CREATE INDEX IF NOT EXISTS idx_assets_status ON assets(status);
|
||||||
CREATE INDEX IF NOT EXISTS idx_assets_owner ON assets(owner_user_id);
|
CREATE INDEX IF NOT EXISTS idx_assets_owner ON assets(owner_user_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_assets_project ON assets(project_id);
|
CREATE INDEX IF NOT EXISTS idx_assets_project ON assets(project_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_assets_vulnerability_count ON assets(vulnerability_count);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_assets_risk_score ON assets(risk_score);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_assets_risk_level ON assets(risk_level);
|
||||||
CREATE INDEX IF NOT EXISTS idx_projects_status ON projects(status);
|
CREATE INDEX IF NOT EXISTS idx_projects_status ON projects(status);
|
||||||
CREATE INDEX IF NOT EXISTS idx_projects_updated_at ON projects(updated_at);
|
CREATE INDEX IF NOT EXISTS idx_projects_updated_at ON projects(updated_at);
|
||||||
CREATE INDEX IF NOT EXISTS idx_project_facts_project_id ON project_facts(project_id);
|
CREATE INDEX IF NOT EXISTS idx_project_facts_project_id ON project_facts(project_id);
|
||||||
@@ -976,7 +981,6 @@ func (db *DB) initTables() error {
|
|||||||
if _, err := db.Exec(createIndexes); err != nil {
|
if _, err := db.Exec(createIndexes); err != nil {
|
||||||
return fmt.Errorf("创建索引失败: %w", err)
|
return fmt.Errorf("创建索引失败: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
db.logger.Debug("数据库表初始化完成")
|
db.logger.Debug("数据库表初始化完成")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -1026,6 +1030,9 @@ func (db *DB) migrateAssetsTable() error {
|
|||||||
{"business_system", "ALTER TABLE assets ADD COLUMN business_system TEXT NOT NULL DEFAULT ''"},
|
{"business_system", "ALTER TABLE assets ADD COLUMN business_system TEXT NOT NULL DEFAULT ''"},
|
||||||
{"environment", "ALTER TABLE assets ADD COLUMN environment TEXT NOT NULL DEFAULT ''"},
|
{"environment", "ALTER TABLE assets ADD COLUMN environment TEXT NOT NULL DEFAULT ''"},
|
||||||
{"criticality", "ALTER TABLE assets ADD COLUMN criticality TEXT NOT NULL DEFAULT ''"},
|
{"criticality", "ALTER TABLE assets ADD COLUMN criticality TEXT NOT NULL DEFAULT ''"},
|
||||||
|
{"vulnerability_count", "ALTER TABLE assets ADD COLUMN vulnerability_count INTEGER NOT NULL DEFAULT 0"},
|
||||||
|
{"risk_score", "ALTER TABLE assets ADD COLUMN risk_score INTEGER NOT NULL DEFAULT 0"},
|
||||||
|
{"risk_level", "ALTER TABLE assets ADD COLUMN risk_level TEXT NOT NULL DEFAULT 'unassessed'"},
|
||||||
}
|
}
|
||||||
for _, column := range columns {
|
for _, column := range columns {
|
||||||
var count int
|
var count int
|
||||||
@@ -1174,6 +1181,21 @@ func (db *DB) migrateConversationsTable() error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 检查 agent_mode 字段是否存在(对话绑定的执行模式,用于历史任务切换时恢复对话模式)
|
||||||
|
err = db.QueryRow("SELECT COUNT(*) FROM pragma_table_info('conversations') WHERE name='agent_mode'").Scan(&count)
|
||||||
|
if err != nil {
|
||||||
|
if _, addErr := db.Exec("ALTER TABLE conversations ADD COLUMN agent_mode TEXT NOT NULL DEFAULT 'eino_single'"); addErr != nil {
|
||||||
|
errMsg := strings.ToLower(addErr.Error())
|
||||||
|
if !strings.Contains(errMsg, "duplicate column") && !strings.Contains(errMsg, "already exists") {
|
||||||
|
db.logger.Warn("添加agent_mode字段失败", zap.Error(addErr))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if count == 0 {
|
||||||
|
if _, err := db.Exec("ALTER TABLE conversations ADD COLUMN agent_mode TEXT NOT NULL DEFAULT 'eino_single'"); err != nil {
|
||||||
|
db.logger.Warn("添加agent_mode字段失败", zap.Error(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ConversationPlanTask mirrors the public fields persisted by Eino plantask.
|
||||||
|
// Keeping the transport model here avoids coupling the HTTP layer to Eino's
|
||||||
|
// private task type.
|
||||||
|
type ConversationPlanTask struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Subject string `json:"subject"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Blocks []string `json:"blocks,omitempty"`
|
||||||
|
BlockedBy []string `json:"blockedBy,omitempty"`
|
||||||
|
ActiveForm string `json:"activeForm,omitempty"`
|
||||||
|
Owner string `json:"owner,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListConversationPlanTasks returns the live Eino task board for one
|
||||||
|
// conversation. A missing task directory is the normal state for short or
|
||||||
|
// legacy conversations and therefore returns an empty list.
|
||||||
|
func (db *DB) ListConversationPlanTasks(conversationID string) ([]ConversationPlanTask, error) {
|
||||||
|
return db.ListConversationPlanTasksSince(conversationID, time.Time{})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListConversationPlanTasksSince limits the board to files written during the
|
||||||
|
// current agent run. The Eino backend intentionally keeps older task files for
|
||||||
|
// model continuity, but the conversation UI must not surface those files before
|
||||||
|
// the new run has called TaskCreate.
|
||||||
|
func (db *DB) ListConversationPlanTasksSince(conversationID string, since time.Time) ([]ConversationPlanTask, error) {
|
||||||
|
if db == nil {
|
||||||
|
return []ConversationPlanTask{}, nil
|
||||||
|
}
|
||||||
|
conversationID = strings.TrimSpace(conversationID)
|
||||||
|
if conversationID == "" {
|
||||||
|
return nil, fmt.Errorf("conversation id is required")
|
||||||
|
}
|
||||||
|
base := strings.TrimSpace(db.einoPlantaskBaseDir)
|
||||||
|
if base == "" {
|
||||||
|
return []ConversationPlanTask{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
dir := filepath.Join(base, sanitizeConversationPathSegment(conversationID))
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return []ConversationPlanTask{}, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read conversation plan tasks: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
type numberedTask struct {
|
||||||
|
number int
|
||||||
|
task ConversationPlanTask
|
||||||
|
}
|
||||||
|
numbered := make([]numberedTask, 0, len(entries))
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
idText := strings.TrimSuffix(entry.Name(), ".json")
|
||||||
|
number, parseErr := strconv.Atoi(idText)
|
||||||
|
if parseErr != nil || number < 1 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !since.IsZero() {
|
||||||
|
info, infoErr := entry.Info()
|
||||||
|
if infoErr != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if info.ModTime().Before(since) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
content, readErr := os.ReadFile(filepath.Join(dir, entry.Name()))
|
||||||
|
if readErr != nil {
|
||||||
|
if db.logger != nil {
|
||||||
|
db.logger.Debug("读取 Eino 任务文件失败",
|
||||||
|
zap.String("conversationId", conversationID),
|
||||||
|
zap.String("file", entry.Name()),
|
||||||
|
zap.Error(readErr))
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var task ConversationPlanTask
|
||||||
|
if decodeErr := json.Unmarshal(content, &task); decodeErr != nil {
|
||||||
|
// TaskUpdate writes files concurrently with this read. A partial read
|
||||||
|
// is transient, so skip it and let the next poll recover.
|
||||||
|
if db.logger != nil {
|
||||||
|
db.logger.Debug("解析 Eino 任务文件失败",
|
||||||
|
zap.String("conversationId", conversationID),
|
||||||
|
zap.String("file", entry.Name()),
|
||||||
|
zap.Error(decodeErr))
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(task.ID) == "" {
|
||||||
|
task.ID = idText
|
||||||
|
}
|
||||||
|
if strings.EqualFold(strings.TrimSpace(task.Status), "deleted") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
numbered = append(numbered, numberedTask{number: number, task: task})
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.SliceStable(numbered, func(i, j int) bool {
|
||||||
|
return numbered[i].number < numbered[j].number
|
||||||
|
})
|
||||||
|
tasks := make([]ConversationPlanTask, 0, len(numbered))
|
||||||
|
for _, item := range numbered {
|
||||||
|
tasks = append(tasks, item.task)
|
||||||
|
}
|
||||||
|
return tasks, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestListConversationPlanTasksSortedAndToleratesMissingDirectory(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
db, err := NewDB(filepath.Join(tmp, "plantask.db"), zap.NewNop())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewDB: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = db.Close() })
|
||||||
|
|
||||||
|
base := filepath.Join(tmp, "skills", ".eino", "plantask")
|
||||||
|
db.SetEinoConversationDirs(base, "", "", "")
|
||||||
|
missing, err := db.ListConversationPlanTasks("missing")
|
||||||
|
if err != nil || len(missing) != 0 {
|
||||||
|
t.Fatalf("missing task board = %#v, err=%v", missing, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
dir := filepath.Join(base, "conversation-1")
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
t.Fatalf("MkdirAll: %v", err)
|
||||||
|
}
|
||||||
|
files := map[string]string{
|
||||||
|
"10.json": `{"id":"10","subject":"最后检查","status":"pending"}`,
|
||||||
|
"2.json": `{"id":"2","subject":"实现接口","status":"in_progress","activeForm":"正在实现接口"}`,
|
||||||
|
"1.json": `{"id":"1","subject":"梳理需求","status":"completed"}`,
|
||||||
|
"bad.json": `{`,
|
||||||
|
}
|
||||||
|
for name, content := range files {
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644); err != nil {
|
||||||
|
t.Fatalf("WriteFile(%s): %v", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, ".highwatermark"), []byte("10"), 0o644); err != nil {
|
||||||
|
t.Fatalf("WriteFile(highwatermark): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks, err := db.ListConversationPlanTasks("conversation-1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListConversationPlanTasks: %v", err)
|
||||||
|
}
|
||||||
|
if len(tasks) != 3 {
|
||||||
|
t.Fatalf("tasks = %#v, want 3", tasks)
|
||||||
|
}
|
||||||
|
if tasks[0].ID != "1" || tasks[1].ID != "2" || tasks[2].ID != "10" {
|
||||||
|
t.Fatalf("task order = %q, %q, %q", tasks[0].ID, tasks[1].ID, tasks[2].ID)
|
||||||
|
}
|
||||||
|
if tasks[1].ActiveForm != "正在实现接口" {
|
||||||
|
t.Fatalf("activeForm = %q", tasks[1].ActiveForm)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListConversationPlanTasksSinceHidesPreviousRunUntilTaskCreate(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
db, err := NewDB(filepath.Join(tmp, "plantask-current-run.db"), zap.NewNop())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewDB: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = db.Close() })
|
||||||
|
|
||||||
|
base := filepath.Join(tmp, "plantask")
|
||||||
|
db.SetEinoConversationDirs(base, "", "", "")
|
||||||
|
dir := filepath.Join(base, "conversation-current-run")
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
t.Fatalf("MkdirAll: %v", err)
|
||||||
|
}
|
||||||
|
oldPath := filepath.Join(dir, "1.json")
|
||||||
|
if err := os.WriteFile(oldPath, []byte(`{"id":"1","subject":"上一轮任务","status":"in_progress"}`), 0o644); err != nil {
|
||||||
|
t.Fatalf("WriteFile(old): %v", err)
|
||||||
|
}
|
||||||
|
runStartedAt := time.Now().Add(-time.Second)
|
||||||
|
oldTime := runStartedAt.Add(-time.Minute)
|
||||||
|
if err := os.Chtimes(oldPath, oldTime, oldTime); err != nil {
|
||||||
|
t.Fatalf("Chtimes(old): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks, err := db.ListConversationPlanTasksSince("conversation-current-run", runStartedAt)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListConversationPlanTasksSince(before TaskCreate): %v", err)
|
||||||
|
}
|
||||||
|
if len(tasks) != 0 {
|
||||||
|
t.Fatalf("stale tasks shown before current TaskCreate: %#v", tasks)
|
||||||
|
}
|
||||||
|
|
||||||
|
newPath := filepath.Join(dir, "2.json")
|
||||||
|
if err := os.WriteFile(newPath, []byte(`{"id":"2","subject":"本轮任务","status":"pending"}`), 0o644); err != nil {
|
||||||
|
t.Fatalf("WriteFile(new): %v", err)
|
||||||
|
}
|
||||||
|
tasks, err = db.ListConversationPlanTasksSince("conversation-current-run", runStartedAt)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListConversationPlanTasksSince(after TaskCreate): %v", err)
|
||||||
|
}
|
||||||
|
if len(tasks) != 1 || tasks[0].ID != "2" {
|
||||||
|
t.Fatalf("current tasks = %#v, want task 2 only", tasks)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,11 +3,12 @@ package database
|
|||||||
import (
|
import (
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestProcessDetailsSummaryPairsMixedIdentifiedAndIDLessResults(t *testing.T) {
|
func TestProcessDetailsSummaryDoesNotGuessIDLessResultsOntoDifferentTool(t *testing.T) {
|
||||||
db, conversationID, messageID := setupProcessDetailsSummaryTest(t)
|
db, conversationID, messageID := setupProcessDetailsSummaryTest(t)
|
||||||
for _, id := range []string{"call-1", "call-2", "call-3", "call-4"} {
|
for _, id := range []string{"call-1", "call-2", "call-3", "call-4"} {
|
||||||
if err := db.AddProcessDetail(messageID, conversationID, "tool_call", "call", map[string]interface{}{
|
if err := db.AddProcessDetail(messageID, conversationID, "tool_call", "call", map[string]interface{}{
|
||||||
@@ -19,26 +20,101 @@ func TestProcessDetailsSummaryPairsMixedIdentifiedAndIDLessResults(t *testing.T)
|
|||||||
results := []map[string]interface{}{
|
results := []map[string]interface{}{
|
||||||
{"toolName": "http-framework-test", "toolCallId": "call-1", "success": true},
|
{"toolName": "http-framework-test", "toolCallId": "call-1", "success": true},
|
||||||
{"toolName": "http-framework-test", "toolCallId": "call-2", "success": true},
|
{"toolName": "http-framework-test", "toolCallId": "call-2", "success": true},
|
||||||
{"toolName": "http-framework-test", "success": true},
|
{"toolName": "other-tool", "success": true},
|
||||||
{"toolName": "http-framework-test", "success": true},
|
{"toolName": "other-tool", "success": true},
|
||||||
}
|
}
|
||||||
|
var resultIDs []string
|
||||||
for _, result := range results {
|
for _, result := range results {
|
||||||
if err := db.AddProcessDetail(messageID, conversationID, "tool_result", "result", result); err != nil {
|
resultID, err := db.AddProcessDetailWithID(messageID, conversationID, "tool_result", "result", result)
|
||||||
|
if err != nil {
|
||||||
t.Fatalf("AddProcessDetail(tool_result): %v", err)
|
t.Fatalf("AddProcessDetail(tool_result): %v", err)
|
||||||
}
|
}
|
||||||
|
resultIDs = append(resultIDs, resultID)
|
||||||
}
|
}
|
||||||
|
|
||||||
summary, err := db.GetProcessDetailsSummary(messageID)
|
summary, err := db.GetProcessDetailsSummary(messageID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("GetProcessDetailsSummary: %v", err)
|
t.Fatalf("GetProcessDetailsSummary: %v", err)
|
||||||
}
|
}
|
||||||
if len(summary.ToolExecutions) != 4 {
|
if len(summary.ToolExecutions) != 6 {
|
||||||
t.Fatalf("tool executions = %d, want 4", len(summary.ToolExecutions))
|
t.Fatalf("tool executions = %d, want 6", len(summary.ToolExecutions))
|
||||||
|
}
|
||||||
|
for i, execution := range summary.ToolExecutions[:2] {
|
||||||
|
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])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i, execution := range summary.ToolExecutions[2:4] {
|
||||||
|
if execution.Status != "result_missing" {
|
||||||
|
t.Fatalf("unmatched call %d status = %q, want result_missing", i, execution.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i, execution := range summary.ToolExecutions[4:] {
|
||||||
|
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 {
|
for i, execution := range summary.ToolExecutions {
|
||||||
if execution.Status != "completed" {
|
if execution.Status != "completed" {
|
||||||
t.Fatalf("execution %d status = %q, want completed", i, execution.Status)
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,6 +165,63 @@ func TestProcessDetailsSummaryDoesNotReportPersistedOrphanAsRunning(t *testing.T
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestProcessDetailsSummaryIncludesPersistedTurnTiming(t *testing.T) {
|
||||||
|
db, _, messageID := setupProcessDetailsSummaryTest(t)
|
||||||
|
startedAt := "2026-08-10T08:00:00Z"
|
||||||
|
completedAt := "2026-08-10T08:12:59Z"
|
||||||
|
if _, err := db.Exec(
|
||||||
|
"UPDATE messages SET content = ?, created_at = ?, updated_at = ? WHERE id = ?",
|
||||||
|
"done", startedAt, completedAt, messageID,
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("update message timing: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
summary, err := db.GetProcessDetailsSummary(messageID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetProcessDetailsSummary: %v", err)
|
||||||
|
}
|
||||||
|
if summary.Status != "completed" {
|
||||||
|
t.Fatalf("status = %q, want completed", summary.Status)
|
||||||
|
}
|
||||||
|
if summary.StartedAt == nil || summary.CompletedAt == nil {
|
||||||
|
t.Fatalf("timing missing: %#v", summary)
|
||||||
|
}
|
||||||
|
if want := int64((12*time.Minute + 59*time.Second) / time.Millisecond); summary.DurationMs != want {
|
||||||
|
t.Fatalf("durationMs = %d, want %d", summary.DurationMs, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProcessDetailsSummaryTreatsCancelledPlaceholderAsTerminal(t *testing.T) {
|
||||||
|
db, conversationID, messageID := setupProcessDetailsSummaryTest(t)
|
||||||
|
startedAt := "2026-08-10T08:00:00Z"
|
||||||
|
if _, err := db.Exec(
|
||||||
|
"UPDATE messages SET content = ?, created_at = ?, updated_at = ? WHERE id = ?",
|
||||||
|
"处理中...", startedAt, startedAt, messageID,
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("update running placeholder: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := db.Exec(`
|
||||||
|
INSERT INTO process_details (id, message_id, conversation_id, event_type, message, data, created_at)
|
||||||
|
VALUES ('cancelled-detail', ?, ?, 'cancelled', 'interrupted', '{}', '2026-08-10T08:02:05Z')`,
|
||||||
|
messageID, conversationID); err != nil {
|
||||||
|
t.Fatalf("insert cancelled detail: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
summary, err := db.GetProcessDetailsSummary(messageID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetProcessDetailsSummary: %v", err)
|
||||||
|
}
|
||||||
|
if summary.Status != "cancelled" {
|
||||||
|
t.Fatalf("status = %q, want cancelled", summary.Status)
|
||||||
|
}
|
||||||
|
if summary.CompletedAt == nil {
|
||||||
|
t.Fatal("cancelled summary should expose a fixed completion time")
|
||||||
|
}
|
||||||
|
if want := int64((2*time.Minute + 5*time.Second) / time.Millisecond); summary.DurationMs != want {
|
||||||
|
t.Fatalf("durationMs = %d, want %d", summary.DurationMs, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func setupProcessDetailsSummaryTest(t *testing.T) (*DB, string, string) {
|
func setupProcessDetailsSummaryTest(t *testing.T) (*DB, string, string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
db, err := NewDB(filepath.Join(t.TempDir(), "process-details.db"), zap.NewNop())
|
db, err := NewDB(filepath.Join(t.TempDir(), "process-details.db"), zap.NewNop())
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import (
|
|||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
var factKeyPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9._/-]*$`)
|
var factKeyPattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._/-]*$`)
|
||||||
|
|
||||||
// ValidateFactKey 校验事实 key(项目内唯一标识)。
|
// ValidateFactKey 校验事实 key(项目内唯一标识)。
|
||||||
func ValidateFactKey(key string) error {
|
func ValidateFactKey(key string) error {
|
||||||
@@ -22,7 +22,7 @@ func ValidateFactKey(key string) error {
|
|||||||
return fmt.Errorf("fact_key 过长(最多 128 字符)")
|
return fmt.Errorf("fact_key 过长(最多 128 字符)")
|
||||||
}
|
}
|
||||||
if !factKeyPattern.MatchString(key) {
|
if !factKeyPattern.MatchString(key) {
|
||||||
return fmt.Errorf("fact_key 格式无效,仅允许小写字母、数字及 . _ / -,且须以小写字母或数字开头")
|
return fmt.Errorf("fact_key 格式无效,仅允许字母、数字及 . _ / -,且须以字母或数字开头(支持驼峰命名)")
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -111,6 +111,19 @@ func (db *DB) GetProject(id string) (*Project, error) {
|
|||||||
return &p, nil
|
return &p, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetProjectName returns a project display name without loading the full record.
|
||||||
|
func (db *DB) GetProjectName(id string) (string, error) {
|
||||||
|
var name string
|
||||||
|
err := db.QueryRow(`SELECT name FROM projects WHERE id = ?`, id).Scan(&name)
|
||||||
|
if err != nil {
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return "", fmt.Errorf("项目不存在")
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("获取项目名称失败: %w", err)
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(name), nil
|
||||||
|
}
|
||||||
|
|
||||||
func projectListSearchPattern(q string) string {
|
func projectListSearchPattern(q string) string {
|
||||||
q = strings.TrimSpace(q)
|
q = strings.TrimSpace(q)
|
||||||
if q == "" {
|
if q == "" {
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FindNearestToolExecutionArguments returns the arguments for the execution record
|
||||||
|
// closest to a persisted tool_call detail. Eino can persist a tool_call with empty
|
||||||
|
// model arguments while the monitor execution row still has the real command/URL.
|
||||||
|
func (db *DB) FindNearestToolExecutionArguments(conversationID, toolName string, at time.Time, window time.Duration) (string, map[string]interface{}, error) {
|
||||||
|
conversationID = strings.TrimSpace(conversationID)
|
||||||
|
toolName = strings.TrimSpace(toolName)
|
||||||
|
if db == nil || conversationID == "" || toolName == "" || at.IsZero() {
|
||||||
|
return "", nil, sql.ErrNoRows
|
||||||
|
}
|
||||||
|
if window <= 0 {
|
||||||
|
window = 5 * time.Second
|
||||||
|
}
|
||||||
|
names := []string{toolName}
|
||||||
|
if !strings.Contains(toolName, "::") {
|
||||||
|
names = append(names, "eino_fs::"+toolName)
|
||||||
|
}
|
||||||
|
start := at.Add(-window)
|
||||||
|
end := at.Add(window)
|
||||||
|
rows, err := db.Query(`
|
||||||
|
SELECT id, arguments
|
||||||
|
FROM tool_executions
|
||||||
|
WHERE conversation_id = ?
|
||||||
|
AND tool_name IN (?, ?)
|
||||||
|
AND julianday(start_time) BETWEEN julianday(?) AND julianday(?)
|
||||||
|
ORDER BY ABS(julianday(start_time) - julianday(?)) ASC, start_time ASC
|
||||||
|
LIMIT 1`, conversationID, names[0], names[len(names)-1], start, end, at)
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
if !rows.Next() {
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return "", nil, err
|
||||||
|
}
|
||||||
|
return "", nil, sql.ErrNoRows
|
||||||
|
}
|
||||||
|
var id string
|
||||||
|
var raw string
|
||||||
|
if err := rows.Scan(&id, &raw); err != nil {
|
||||||
|
return "", nil, err
|
||||||
|
}
|
||||||
|
var args map[string]interface{}
|
||||||
|
if err := json.Unmarshal([]byte(raw), &args); err != nil {
|
||||||
|
return "", nil, fmt.Errorf("parse tool execution arguments: %w", err)
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(id), args, nil
|
||||||
|
}
|
||||||
@@ -191,6 +191,7 @@ func (db *DB) CreateVulnerability(vuln *Vulnerability) (*Vulnerability, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("创建漏洞失败: %w", err)
|
return nil, fmt.Errorf("创建漏洞失败: %w", err)
|
||||||
}
|
}
|
||||||
|
db.refreshAssetRiskCacheForConversationsBestEffort(vuln.ConversationID)
|
||||||
return vuln, nil
|
return vuln, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -299,6 +300,8 @@ func (db *DB) CountVulnerabilitiesForAccess(filter VulnerabilityListFilter, acce
|
|||||||
// UpdateVulnerability 更新漏洞
|
// UpdateVulnerability 更新漏洞
|
||||||
func (db *DB) UpdateVulnerability(id string, vuln *Vulnerability) error {
|
func (db *DB) UpdateVulnerability(id string, vuln *Vulnerability) error {
|
||||||
vuln.UpdatedAt = time.Now()
|
vuln.UpdatedAt = time.Now()
|
||||||
|
var oldConversationID string
|
||||||
|
_ = db.QueryRow(`SELECT COALESCE(conversation_id,'') FROM vulnerabilities WHERE id = ?`, id).Scan(&oldConversationID)
|
||||||
|
|
||||||
query := `
|
query := `
|
||||||
UPDATE vulnerabilities
|
UPDATE vulnerabilities
|
||||||
@@ -318,6 +321,7 @@ func (db *DB) UpdateVulnerability(id string, vuln *Vulnerability) error {
|
|||||||
return fmt.Errorf("更新漏洞失败: %w", err)
|
return fmt.Errorf("更新漏洞失败: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
db.refreshAssetRiskCacheForConversationsBestEffort(oldConversationID, vuln.ConversationID)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -337,6 +341,10 @@ func (db *DB) DeleteVulnerabilitiesByFilterForAccess(filter VulnerabilityListFil
|
|||||||
args := []interface{}{}
|
args := []interface{}{}
|
||||||
where, args = filter.appendWhere(where, args)
|
where, args = filter.appendWhere(where, args)
|
||||||
where, args = appendVulnerabilityAccessFilter(where, args, access)
|
where, args = appendVulnerabilityAccessFilter(where, args, access)
|
||||||
|
affectedConversations, err := collectVulnerabilityConversationIDs(tx, where, args)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
clearQuery := `UPDATE project_facts SET related_vulnerability_id = NULL
|
clearQuery := `UPDATE project_facts SET related_vulnerability_id = NULL
|
||||||
WHERE related_vulnerability_id IN (SELECT id FROM vulnerabilities ` + where + `)`
|
WHERE related_vulnerability_id IN (SELECT id FROM vulnerabilities ` + where + `)`
|
||||||
@@ -356,6 +364,7 @@ func (db *DB) DeleteVulnerabilitiesByFilterForAccess(filter VulnerabilityListFil
|
|||||||
if err := tx.Commit(); err != nil {
|
if err := tx.Commit(); err != nil {
|
||||||
return 0, fmt.Errorf("提交事务失败: %w", err)
|
return 0, fmt.Errorf("提交事务失败: %w", err)
|
||||||
}
|
}
|
||||||
|
db.refreshAssetRiskCacheForConversationsBestEffort(affectedConversations...)
|
||||||
return deleted, nil
|
return deleted, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -366,6 +375,8 @@ func (db *DB) DeleteVulnerability(id string) error {
|
|||||||
return fmt.Errorf("开启事务失败: %w", err)
|
return fmt.Errorf("开启事务失败: %w", err)
|
||||||
}
|
}
|
||||||
defer func() { _ = tx.Rollback() }()
|
defer func() { _ = tx.Rollback() }()
|
||||||
|
var conversationID string
|
||||||
|
_ = tx.QueryRow(`SELECT COALESCE(conversation_id,'') FROM vulnerabilities WHERE id = ?`, id).Scan(&conversationID)
|
||||||
|
|
||||||
// 删除漏洞前先解除项目事实中的关联,避免前端继续显示已删除漏洞的短 ID。
|
// 删除漏洞前先解除项目事实中的关联,避免前端继续显示已删除漏洞的短 ID。
|
||||||
if _, err := tx.Exec("UPDATE project_facts SET related_vulnerability_id = NULL WHERE related_vulnerability_id = ?", id); err != nil {
|
if _, err := tx.Exec("UPDATE project_facts SET related_vulnerability_id = NULL WHERE related_vulnerability_id = ?", id); err != nil {
|
||||||
@@ -377,9 +388,29 @@ func (db *DB) DeleteVulnerability(id string) error {
|
|||||||
if err := tx.Commit(); err != nil {
|
if err := tx.Commit(); err != nil {
|
||||||
return fmt.Errorf("提交事务失败: %w", err)
|
return fmt.Errorf("提交事务失败: %w", err)
|
||||||
}
|
}
|
||||||
|
db.refreshAssetRiskCacheForConversationsBestEffort(conversationID)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func collectVulnerabilityConversationIDs(tx *sql.Tx, where string, args []interface{}) ([]string, error) {
|
||||||
|
rows, err := tx.Query(`SELECT DISTINCT COALESCE(conversation_id,'') FROM vulnerabilities `+where, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("查询受影响漏洞会话失败: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
ids := []string{}
|
||||||
|
for rows.Next() {
|
||||||
|
var id string
|
||||||
|
if err := rows.Scan(&id); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(id) != "" {
|
||||||
|
ids = append(ids, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ids, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
// GetVulnerabilityStats 获取漏洞统计(筛选条件与 ListVulnerabilities / CountVulnerabilities 一致)
|
// GetVulnerabilityStats 获取漏洞统计(筛选条件与 ListVulnerabilities / CountVulnerabilities 一致)
|
||||||
func (db *DB) GetVulnerabilityStats(filter VulnerabilityListFilter) (map[string]interface{}, error) {
|
func (db *DB) GetVulnerabilityStats(filter VulnerabilityListFilter) (map[string]interface{}, error) {
|
||||||
return db.GetVulnerabilityStatsForAccess(filter, RBACListAccess{})
|
return db.GetVulnerabilityStatsForAccess(filter, RBACListAccess{})
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ type Params struct {
|
|||||||
ConversationID string
|
ConversationID string
|
||||||
OrchMode string
|
OrchMode string
|
||||||
OrchestratorName string
|
OrchestratorName string
|
||||||
|
RunID string
|
||||||
}
|
}
|
||||||
|
|
||||||
// AttachAgentRunCallbacks returns ctx wrapped with callbacks.InitCallbacks when enabled.
|
// AttachAgentRunCallbacks returns ctx wrapped with callbacks.InitCallbacks when enabled.
|
||||||
@@ -53,7 +54,10 @@ func AttachAgentRunCallbacks(ctx context.Context, cfg *config.MultiAgentEinoCall
|
|||||||
if mode == "off" {
|
if mode == "off" {
|
||||||
return ctx
|
return ctx
|
||||||
}
|
}
|
||||||
runID := uuid.New().String()
|
runID := strings.TrimSpace(p.RunID)
|
||||||
|
if runID == "" {
|
||||||
|
runID = uuid.New().String()
|
||||||
|
}
|
||||||
if p.Progress != nil && cfg.ShouldEmitEinoTraceSSE(mode) {
|
if p.Progress != nil && cfg.ShouldEmitEinoTraceSSE(mode) {
|
||||||
p.Progress("eino_trace_run", "Eino callbacks session", map[string]interface{}{
|
p.Progress("eino_trace_run", "Eino callbacks session", map[string]interface{}{
|
||||||
"runId": runID,
|
"runId": runID,
|
||||||
@@ -206,7 +210,7 @@ func (h *runHandler) onStart(ctx context.Context, info *callbacks.RunInfo, input
|
|||||||
"spanId": spanID,
|
"spanId": spanID,
|
||||||
"parentSpanId": parentID,
|
"parentSpanId": parentID,
|
||||||
"conversationId": strings.TrimSpace(h.params.ConversationID),
|
"conversationId": strings.TrimSpace(h.params.ConversationID),
|
||||||
"orchestration": strings.TrimSpace(h.params.OrchMode),
|
"orchestration": strings.TrimSpace(h.params.OrchMode),
|
||||||
"component": string(ri.Component),
|
"component": string(ri.Component),
|
||||||
"name": ri.Name,
|
"name": ri.Name,
|
||||||
"type": ri.Type,
|
"type": ri.Type,
|
||||||
@@ -255,7 +259,7 @@ func (h *runHandler) onEnd(ctx context.Context, info *callbacks.RunInfo, output
|
|||||||
"runId": h.runID,
|
"runId": h.runID,
|
||||||
"spanId": spanID,
|
"spanId": spanID,
|
||||||
"conversationId": strings.TrimSpace(h.params.ConversationID),
|
"conversationId": strings.TrimSpace(h.params.ConversationID),
|
||||||
"orchestration": strings.TrimSpace(h.params.OrchMode),
|
"orchestration": strings.TrimSpace(h.params.OrchMode),
|
||||||
"component": string(ri.Component),
|
"component": string(ri.Component),
|
||||||
"name": ri.Name,
|
"name": ri.Name,
|
||||||
"type": ri.Type,
|
"type": ri.Type,
|
||||||
@@ -301,7 +305,7 @@ func (h *runHandler) onError(ctx context.Context, info *callbacks.RunInfo, err e
|
|||||||
"runId": h.runID,
|
"runId": h.runID,
|
||||||
"spanId": spanID,
|
"spanId": spanID,
|
||||||
"conversationId": strings.TrimSpace(h.params.ConversationID),
|
"conversationId": strings.TrimSpace(h.params.ConversationID),
|
||||||
"orchestration": strings.TrimSpace(h.params.OrchMode),
|
"orchestration": strings.TrimSpace(h.params.OrchMode),
|
||||||
"component": string(ri.Component),
|
"component": string(ri.Component),
|
||||||
"name": ri.Name,
|
"name": ri.Name,
|
||||||
"type": ri.Type,
|
"type": ri.Type,
|
||||||
|
|||||||
@@ -16,6 +16,29 @@ func TestAttachAgentRunCallbacks_Disabled(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAttachAgentRunCallbacksUsesProvidedRunID(t *testing.T) {
|
||||||
|
emit := true
|
||||||
|
var gotRunID string
|
||||||
|
ctx := context.Background()
|
||||||
|
cfg := &config.MultiAgentEinoCallbacksConfig{Enabled: true, Mode: "sse", SseTraceToClient: &emit}
|
||||||
|
|
||||||
|
AttachAgentRunCallbacks(ctx, cfg, Params{
|
||||||
|
RunID: "run-shared",
|
||||||
|
Progress: func(eventType, _ string, data interface{}) {
|
||||||
|
if eventType != "eino_trace_run" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if m, ok := data.(map[string]interface{}); ok {
|
||||||
|
gotRunID, _ = m["runId"].(string)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if gotRunID != "run-shared" {
|
||||||
|
t.Fatalf("runId = %q, want run-shared", gotRunID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestTruncateRunes(t *testing.T) {
|
func TestTruncateRunes(t *testing.T) {
|
||||||
if got := truncateRunes("abc", 10); got != "abc" {
|
if got := truncateRunes("abc", 10); got != "abc" {
|
||||||
t.Fatalf("got %q", got)
|
t.Fatalf("got %q", got)
|
||||||
|
|||||||
+139
-43
@@ -75,8 +75,11 @@ found:
|
|||||||
|
|
||||||
// responsePlanAgg buffers main-assistant response_stream chunks for one "planning" process_detail row.
|
// responsePlanAgg buffers main-assistant response_stream chunks for one "planning" process_detail row.
|
||||||
type responsePlanAgg struct {
|
type responsePlanAgg struct {
|
||||||
meta map[string]interface{}
|
meta map[string]interface{}
|
||||||
b strings.Builder
|
b strings.Builder
|
||||||
|
detailID string
|
||||||
|
lastPersistAt time.Time
|
||||||
|
lastPersistSize int
|
||||||
}
|
}
|
||||||
|
|
||||||
// thinkingBuf aggregates thinking_stream_* / reasoning_chain_stream_* before flush to process_details.
|
// thinkingBuf aggregates thinking_stream_* / reasoning_chain_stream_* before flush to process_details.
|
||||||
@@ -145,30 +148,36 @@ func responseStreamIterationFromMeta(m map[string]interface{}) int {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func discardPlanningIfEchoesToolResult(respPlan *responsePlanAgg, toolData interface{}) {
|
func discardPlanningIfEchoesToolResult(respPlan *responsePlanAgg, toolData interface{}) string {
|
||||||
if respPlan == nil {
|
if respPlan == nil {
|
||||||
return
|
return ""
|
||||||
}
|
}
|
||||||
plan := normalizeProcessDetailText(respPlan.b.String())
|
plan := normalizeProcessDetailText(respPlan.b.String())
|
||||||
if plan == "" {
|
if plan == "" {
|
||||||
return
|
return ""
|
||||||
}
|
}
|
||||||
dataMap, ok := toolData.(map[string]interface{})
|
dataMap, ok := toolData.(map[string]interface{})
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return ""
|
||||||
}
|
}
|
||||||
res, ok := dataMap["result"].(string)
|
res, ok := dataMap["result"].(string)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return ""
|
||||||
}
|
}
|
||||||
r := normalizeProcessDetailText(res)
|
r := normalizeProcessDetailText(res)
|
||||||
if r == "" {
|
if r == "" {
|
||||||
return
|
return ""
|
||||||
}
|
}
|
||||||
if plan == r || strings.HasSuffix(plan, r) {
|
if plan == r || strings.HasSuffix(plan, r) {
|
||||||
|
detailID := respPlan.detailID
|
||||||
respPlan.meta = nil
|
respPlan.meta = nil
|
||||||
respPlan.b.Reset()
|
respPlan.b.Reset()
|
||||||
|
respPlan.detailID = ""
|
||||||
|
respPlan.lastPersistAt = time.Time{}
|
||||||
|
respPlan.lastPersistSize = 0
|
||||||
|
return detailID
|
||||||
}
|
}
|
||||||
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// AgentHandler Agent处理器
|
// AgentHandler Agent处理器
|
||||||
@@ -221,6 +230,20 @@ func (h *AgentHandler) CancelRunningTaskForConversation(conversationID string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ConversationTaskRuntimeState exposes the authoritative live state and start
|
||||||
|
// time used to scope persisted TaskCreate files to the current run. A task
|
||||||
|
// already entering cancellation must stop driving progress UI immediately.
|
||||||
|
func (h *AgentHandler) ConversationTaskRuntimeState(conversationID string) (bool, time.Time) {
|
||||||
|
if h == nil || h.tasks == nil || strings.TrimSpace(conversationID) == "" {
|
||||||
|
return false, time.Time{}
|
||||||
|
}
|
||||||
|
task := h.tasks.GetTaskSnapshot(strings.TrimSpace(conversationID))
|
||||||
|
if task == nil || !strings.EqualFold(strings.TrimSpace(task.Status), "running") {
|
||||||
|
return false, time.Time{}
|
||||||
|
}
|
||||||
|
return true, task.StartedAt
|
||||||
|
}
|
||||||
|
|
||||||
func (h *AgentHandler) cancelRunningMCPToolsForConversation(conversationID string) {
|
func (h *AgentHandler) cancelRunningMCPToolsForConversation(conversationID string) {
|
||||||
if h == nil || h.agent == nil {
|
if h == nil || h.agent == nil {
|
||||||
return
|
return
|
||||||
@@ -333,17 +356,24 @@ type ChatReasoningRequest struct {
|
|||||||
Effort string `json:"effort,omitempty"`
|
Effort string `json:"effort,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ChatFinalizationRequest is a caller-provided delivery policy. The server does
|
||||||
|
// not infer execution intent from natural-language user text.
|
||||||
|
type ChatFinalizationRequest struct {
|
||||||
|
RequireExecutionEvidence *bool `json:"requireExecutionEvidence,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
// ChatRequest 聊天请求
|
// ChatRequest 聊天请求
|
||||||
type ChatRequest struct {
|
type ChatRequest struct {
|
||||||
Message string `json:"message" binding:"required"`
|
Message string `json:"message" binding:"required"`
|
||||||
ConversationID string `json:"conversationId,omitempty"`
|
ConversationID string `json:"conversationId,omitempty"`
|
||||||
ProjectID string `json:"projectId,omitempty"` // 新对话绑定的项目(可选;未指定时可用 config.project.default_project_id)
|
ProjectID string `json:"projectId,omitempty"` // 新对话绑定的项目(可选;未指定时可用 config.project.default_project_id)
|
||||||
Role string `json:"role,omitempty"` // 角色名称
|
Role string `json:"role,omitempty"` // 角色名称
|
||||||
Attachments []ChatAttachment `json:"attachments,omitempty"`
|
Attachments []ChatAttachment `json:"attachments,omitempty"`
|
||||||
WebShellConnectionID string `json:"webshellConnectionId,omitempty"` // WebShell 管理 - AI 助手:当前选中的连接 ID,仅使用 webshell_* 工具
|
WebShellConnectionID string `json:"webshellConnectionId,omitempty"` // WebShell 管理 - AI 助手:当前选中的连接 ID,仅使用 webshell_* 工具
|
||||||
AIChannelID string `json:"aiChannelId,omitempty"` // 会话级 AI 通道;空则使用 ai.default_channel
|
AIChannelID string `json:"aiChannelId,omitempty"` // 会话级 AI 通道;空则使用 ai.default_channel
|
||||||
Hitl *HITLRequest `json:"hitl,omitempty"`
|
Hitl *HITLRequest `json:"hitl,omitempty"`
|
||||||
Reasoning *ChatReasoningRequest `json:"reasoning,omitempty"`
|
Reasoning *ChatReasoningRequest `json:"reasoning,omitempty"`
|
||||||
|
Finalization ChatFinalizationRequest `json:"finalization,omitempty"`
|
||||||
// Orchestration 仅对 /api/multi-agent、/api/multi-agent/stream:deep | plan_execute | supervisor;空则等同 deep。机器人/批量等无请求体时由服务端默认 deep。/api/eino-agent* 不使用此字段。
|
// Orchestration 仅对 /api/multi-agent、/api/multi-agent/stream:deep | plan_execute | supervisor;空则等同 deep。机器人/批量等无请求体时由服务端默认 deep。/api/eino-agent* 不使用此字段。
|
||||||
Orchestration string `json:"orchestration,omitempty"`
|
Orchestration string `json:"orchestration,omitempty"`
|
||||||
}
|
}
|
||||||
@@ -668,10 +698,18 @@ func (h *AgentHandler) mergeAssistantMessagePartialOnCancel(messageID, partial s
|
|||||||
|
|
||||||
// ChatResponse 聊天响应
|
// ChatResponse 聊天响应
|
||||||
type ChatResponse struct {
|
type ChatResponse struct {
|
||||||
Response string `json:"response"`
|
Response string `json:"response"`
|
||||||
MCPExecutionIDs []string `json:"mcpExecutionIds,omitempty"` // 本次对话中执行的MCP调用ID列表
|
MCPExecutionIDs []string `json:"mcpExecutionIds,omitempty"` // 本次对话中执行的MCP调用ID列表
|
||||||
ConversationID string `json:"conversationId"` // 对话ID
|
ConversationID string `json:"conversationId"` // 对话ID
|
||||||
Time time.Time `json:"time"`
|
Time time.Time `json:"time"`
|
||||||
|
Finalizable bool `json:"finalizable"`
|
||||||
|
Finalized bool `json:"finalized"`
|
||||||
|
Status string `json:"status,omitempty"`
|
||||||
|
CompletionReason string `json:"completionReason,omitempty"`
|
||||||
|
EvidenceVerified bool `json:"evidenceVerified"`
|
||||||
|
EvidenceRefs []string `json:"evidenceRefs,omitempty"`
|
||||||
|
PendingExecutionIDs []string `json:"pendingExecutionIds,omitempty"`
|
||||||
|
MissingChecks []string `json:"missingChecks,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *AgentHandler) finalizeRobotAgentError(ctx context.Context, assistantMessageID, conversationID string, resultMA *multiagent.RunResult, errMA error) (string, string, error) {
|
func (h *AgentHandler) finalizeRobotAgentError(ctx context.Context, assistantMessageID, conversationID string, resultMA *multiagent.RunResult, errMA error) (string, string, error) {
|
||||||
@@ -687,19 +725,20 @@ func (h *AgentHandler) finalizeRobotAgentError(ctx context.Context, assistantMes
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *AgentHandler) finalizeRobotAgentSuccess(assistantMessageID, conversationID string, resultMA *multiagent.RunResult) (string, string, error) {
|
func (h *AgentHandler) finalizeRobotAgentSuccess(assistantMessageID, conversationID string, resultMA *multiagent.RunResult) (string, string, error) {
|
||||||
if assistantMessageID != "" {
|
decision := h.finalizeAgentRunForDeliveryWithPolicy(conversationID, assistantMessageID, "robot", resultMA, resultMA.MCPExecutionIDs, multiagent.AggregatedReasoningFromTraceJSON(resultMA.LastAgentTraceInput), true)
|
||||||
if errU := h.db.UpdateAssistantMessageFinalize(assistantMessageID, resultMA.Response, resultMA.MCPExecutionIDs, multiagent.AggregatedReasoningFromTraceJSON(resultMA.LastAgentTraceInput)); errU != nil {
|
responseText := decision.FinalText
|
||||||
h.logger.Warn("机器人:更新助手消息失败", zap.Error(errU))
|
if !decision.Finalizable {
|
||||||
}
|
responseText = finalizationBlockedMessage(decision)
|
||||||
} else {
|
}
|
||||||
if _, err := h.db.AddMessage(conversationID, "assistant", resultMA.Response, resultMA.MCPExecutionIDs); err != nil {
|
if assistantMessageID == "" {
|
||||||
|
if _, err := h.db.AddMessage(conversationID, "assistant", responseText, resultMA.MCPExecutionIDs); err != nil {
|
||||||
h.logger.Warn("机器人:保存助手消息失败", zap.Error(err))
|
h.logger.Warn("机器人:保存助手消息失败", zap.Error(err))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if resultMA.LastAgentTraceInput != "" || resultMA.LastAgentTraceOutput != "" {
|
if resultMA.LastAgentTraceInput != "" || resultMA.LastAgentTraceOutput != "" {
|
||||||
_ = h.db.SaveAgentTrace(conversationID, resultMA.LastAgentTraceInput, resultMA.LastAgentTraceOutput)
|
_ = h.db.SaveAgentTrace(conversationID, resultMA.LastAgentTraceInput, resultMA.LastAgentTraceOutput)
|
||||||
}
|
}
|
||||||
return resultMA.Response, conversationID, nil
|
return responseText, conversationID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *AgentHandler) runRobotEinoSingleWithRetry(
|
func (h *AgentHandler) runRobotEinoSingleWithRetry(
|
||||||
@@ -830,6 +869,9 @@ func (h *AgentHandler) ProcessMessageForRobot(ctx context.Context, platform stri
|
|||||||
progressCallback := h.createProgressCallback(taskCtx, cancelWithCause, conversationID, assistantMessageID, nil)
|
progressCallback := h.createProgressCallback(taskCtx, cancelWithCause, conversationID, assistantMessageID, nil)
|
||||||
|
|
||||||
robotMode := config.NormalizeAgentMode(agentMode)
|
robotMode := config.NormalizeAgentMode(agentMode)
|
||||||
|
if err := h.db.SetConversationAgentMode(conversationID, robotMode); err != nil {
|
||||||
|
h.logger.Warn("机器人:更新对话模式失败", zap.String("conversationId", conversationID), zap.String("agentMode", robotMode), zap.Error(err))
|
||||||
|
}
|
||||||
switch robotMode {
|
switch robotMode {
|
||||||
case "eino_single":
|
case "eino_single":
|
||||||
return h.runRobotEinoSingleWithRetry(taskCtx, conversationID, finalMessage, agentHistoryMessages, roleTools, progressCallback, assistantMessageID, &taskStatus)
|
return h.runRobotEinoSingleWithRetry(taskCtx, conversationID, finalMessage, agentHistoryMessages, roleTools, progressCallback, assistantMessageID, &taskStatus)
|
||||||
@@ -869,6 +911,32 @@ func (h *AgentHandler) publishProgressToTaskEventBus(conversationID, eventType,
|
|||||||
h.taskEventBus.Publish(conversationID, sseLine)
|
h.taskEventBus.Publish(conversationID, sseLine)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isInternalEinoDiagnosticProgress(eventType, message string, data interface{}) bool {
|
||||||
|
switch eventType {
|
||||||
|
case "model_output_rejected":
|
||||||
|
return true
|
||||||
|
case "progress":
|
||||||
|
msg := strings.TrimSpace(message)
|
||||||
|
if msg == "Eino TurnLoop 常驻多轮 runtime 已接管本轮会话。" ||
|
||||||
|
msg == "Eino TurnLoop 已在安全点切换到用户补充后的下一轮。" ||
|
||||||
|
msg == "已将用户补充推入 Eino TurnLoop,正在等待安全点切换…" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
m, ok := data.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
switch strings.TrimSpace(fmt.Sprint(m["kind"])) {
|
||||||
|
case "turn_loop_takeover", "turn_loop_preempted":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// enrichProgressEventData 为 SSE / taskEventBus 事件补齐 conversationId、messageId,便于前端懒加载过程详情。
|
// enrichProgressEventData 为 SSE / taskEventBus 事件补齐 conversationId、messageId,便于前端懒加载过程详情。
|
||||||
func enrichProgressEventData(data interface{}, conversationID, assistantMessageID string) interface{} {
|
func enrichProgressEventData(data interface{}, conversationID, assistantMessageID string) interface{} {
|
||||||
if strings.TrimSpace(conversationID) == "" && strings.TrimSpace(assistantMessageID) == "" {
|
if strings.TrimSpace(conversationID) == "" && strings.TrimSpace(assistantMessageID) == "" {
|
||||||
@@ -957,14 +1025,15 @@ func (h *AgentHandler) createProgressCallback(runCtx context.Context, cancelRun
|
|||||||
syncHitlCognition := func() {
|
syncHitlCognition := func() {
|
||||||
h.syncHitlCognitionFromProgress(conversationID, assistantMessageID, thinkingStreams, &respPlan)
|
h.syncHitlCognitionFromProgress(conversationID, assistantMessageID, thinkingStreams, &respPlan)
|
||||||
}
|
}
|
||||||
flushResponsePlan := func() {
|
persistResponsePlan := func(reset bool) {
|
||||||
if assistantMessageID == "" {
|
if assistantMessageID == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
content := strings.TrimSpace(respPlan.b.String())
|
content := strings.TrimSpace(respPlan.b.String())
|
||||||
if content == "" {
|
if content == "" {
|
||||||
respPlan.meta = nil
|
if reset {
|
||||||
respPlan.b.Reset()
|
respPlan = responsePlanAgg{}
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
data := map[string]interface{}{
|
data := map[string]interface{}{
|
||||||
@@ -973,13 +1042,26 @@ func (h *AgentHandler) createProgressCallback(runCtx context.Context, cancelRun
|
|||||||
for k, v := range respPlan.meta {
|
for k, v := range respPlan.meta {
|
||||||
data[k] = v
|
data[k] = v
|
||||||
}
|
}
|
||||||
if err := h.db.AddProcessDetail(assistantMessageID, conversationID, "planning", content, data); err != nil {
|
var err error
|
||||||
|
if respPlan.detailID == "" {
|
||||||
|
respPlan.detailID, err = h.db.AddProcessDetailWithID(
|
||||||
|
assistantMessageID, conversationID, "planning", content, data,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
err = h.db.UpdateProcessDetailContent(respPlan.detailID, content, data)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
h.logger.Warn("保存过程详情失败", zap.Error(err), zap.String("eventType", "planning"))
|
h.logger.Warn("保存过程详情失败", zap.Error(err), zap.String("eventType", "planning"))
|
||||||
|
} else {
|
||||||
|
respPlan.lastPersistAt = time.Now()
|
||||||
|
respPlan.lastPersistSize = respPlan.b.Len()
|
||||||
}
|
}
|
||||||
syncHitlCognition()
|
syncHitlCognition()
|
||||||
respPlan.meta = nil
|
if reset {
|
||||||
respPlan.b.Reset()
|
respPlan = responsePlanAgg{}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
flushResponsePlan := func() { persistResponsePlan(true) }
|
||||||
|
|
||||||
flushThinkingStreams := func() {
|
flushThinkingStreams := func() {
|
||||||
if assistantMessageID == "" {
|
if assistantMessageID == "" {
|
||||||
@@ -1020,6 +1102,10 @@ func (h *AgentHandler) createProgressCallback(runCtx context.Context, cancelRun
|
|||||||
progressMu.Lock()
|
progressMu.Lock()
|
||||||
defer progressMu.Unlock()
|
defer progressMu.Unlock()
|
||||||
|
|
||||||
|
if isInternalEinoDiagnosticProgress(eventType, message, data) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// 上游在重试/补偿时可能重复回调相同 tool_call/tool_result。
|
// 上游在重试/补偿时可能重复回调相同 tool_call/tool_result。
|
||||||
// 这里做幂等过滤,保证前端展示和 process_details 都以唯一事件为准。
|
// 这里做幂等过滤,保证前端展示和 process_details 都以唯一事件为准。
|
||||||
if (eventType == "tool_call" || eventType == "tool_result") && data != nil {
|
if (eventType == "tool_call" || eventType == "tool_result") && data != nil {
|
||||||
@@ -1049,15 +1135,15 @@ func (h *AgentHandler) createProgressCallback(runCtx context.Context, cancelRun
|
|||||||
}
|
}
|
||||||
|
|
||||||
deferToolProgressSend := eventType == "tool_call" || eventType == "tool_result"
|
deferToolProgressSend := eventType == "tool_call" || eventType == "tool_result"
|
||||||
// 流式:写 HTTP SSE;非流式(机器人等):镜像到 taskEventBus 供 Web 订阅。
|
// 主 HTTP SSE 与 taskEventBus 必须同时写入:页面刷新会切断原连接,刷新后的
|
||||||
// 工具事件需先落库拿 processDetailId,再向前端发送摘要,避免大 payload 默认进入浏览器。
|
// GET task-events 订阅依赖 eventBus 才能继续收到后续迭代。机器人等无主 SSE
|
||||||
|
// 的来源同样只写 eventBus。工具事件需先落库拿 processDetailId,再发送摘要。
|
||||||
if !deferToolProgressSend {
|
if !deferToolProgressSend {
|
||||||
clientData := enrichProgressEventData(data, conversationID, assistantMessageID)
|
clientData := enrichProgressEventData(data, conversationID, assistantMessageID)
|
||||||
if sendEventFunc != nil {
|
if sendEventFunc != nil {
|
||||||
sendEventFunc(eventType, message, clientData)
|
sendEventFunc(eventType, message, clientData)
|
||||||
} else {
|
|
||||||
h.publishProgressToTaskEventBus(conversationID, eventType, message, clientData)
|
|
||||||
}
|
}
|
||||||
|
h.publishProgressToTaskEventBus(conversationID, eventType, message, clientData)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 保存tool_call事件中的参数
|
// 保存tool_call事件中的参数
|
||||||
@@ -1310,6 +1396,14 @@ func (h *AgentHandler) createProgressCallback(runCtx context.Context, cancelRun
|
|||||||
respPlan.meta[k] = v
|
respPlan.meta[k] = v
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 运行中的主回复不能只保存在内存:刷新会销毁旧页面,新的 task-events
|
||||||
|
// 订阅只能收到未来增量。按时间或增量大小节流更新同一条 planning 记录,
|
||||||
|
// 这样刷新时能从数据库恢复刷新前已经展示的全部文本。
|
||||||
|
if respPlan.lastPersistAt.IsZero() ||
|
||||||
|
time.Since(respPlan.lastPersistAt) >= 300*time.Millisecond ||
|
||||||
|
respPlan.b.Len()-respPlan.lastPersistSize >= 1024 {
|
||||||
|
persistResponsePlan(false)
|
||||||
|
}
|
||||||
syncHitlCognition()
|
syncHitlCognition()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1420,7 +1514,11 @@ func (h *AgentHandler) createProgressCallback(runCtx context.Context, cancelRun
|
|||||||
eventType != "eino_agent_reply_stream_delta" &&
|
eventType != "eino_agent_reply_stream_delta" &&
|
||||||
eventType != "eino_agent_reply_stream_end" {
|
eventType != "eino_agent_reply_stream_end" {
|
||||||
if eventType == "tool_result" {
|
if eventType == "tool_result" {
|
||||||
discardPlanningIfEchoesToolResult(&respPlan, data)
|
if detailID := discardPlanningIfEchoesToolResult(&respPlan, data); detailID != "" {
|
||||||
|
if err := h.db.DeleteProcessDetail(detailID); err != nil {
|
||||||
|
h.logger.Warn("删除工具结果回显规划失败", zap.Error(err), zap.String("processDetailId", detailID))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// 在关键过程事件落库前,先把「规划中」与聚合中的 thinking / reasoning_chain 流落库
|
// 在关键过程事件落库前,先把「规划中」与聚合中的 thinking / reasoning_chain 流落库
|
||||||
flushResponsePlan()
|
flushResponsePlan()
|
||||||
@@ -1436,17 +1534,15 @@ func (h *AgentHandler) createProgressCallback(runCtx context.Context, cancelRun
|
|||||||
}
|
}
|
||||||
if sendEventFunc != nil {
|
if sendEventFunc != nil {
|
||||||
sendEventFunc(eventType, message, clientData)
|
sendEventFunc(eventType, message, clientData)
|
||||||
} else {
|
|
||||||
h.publishProgressToTaskEventBus(conversationID, eventType, message, clientData)
|
|
||||||
}
|
}
|
||||||
|
h.publishProgressToTaskEventBus(conversationID, eventType, message, clientData)
|
||||||
}
|
}
|
||||||
} else if deferToolProgressSend {
|
} else if deferToolProgressSend {
|
||||||
clientData := enrichProgressEventData(summarizeProcessDetailData(eventType, data), conversationID, assistantMessageID)
|
clientData := enrichProgressEventData(summarizeProcessDetailData(eventType, data), conversationID, assistantMessageID)
|
||||||
if sendEventFunc != nil {
|
if sendEventFunc != nil {
|
||||||
sendEventFunc(eventType, message, clientData)
|
sendEventFunc(eventType, message, clientData)
|
||||||
} else {
|
|
||||||
h.publishProgressToTaskEventBus(conversationID, eventType, message, clientData)
|
|
||||||
}
|
}
|
||||||
|
h.publishProgressToTaskEventBus(conversationID, eventType, message, clientData)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"cyberstrike-ai/internal/config"
|
"cyberstrike-ai/internal/config"
|
||||||
"cyberstrike-ai/internal/database"
|
"cyberstrike-ai/internal/database"
|
||||||
@@ -51,6 +53,124 @@ func TestCreateProgressCallback_ConcurrentToolEvents(t *testing.T) {
|
|||||||
wg.Wait()
|
wg.Wait()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestCreateProgressCallback_MirrorsWebStreamEvents 页面刷新后 task-events 订阅必须
|
||||||
|
// 继续收到原 Web SSE 任务的后续事件,不能只等数据库最终结果。
|
||||||
|
func TestCreateProgressCallback_MirrorsWebStreamEvents(t *testing.T) {
|
||||||
|
bus := NewTaskEventBus()
|
||||||
|
h := &AgentHandler{logger: zap.NewNop(), config: &config.Config{}, taskEventBus: bus}
|
||||||
|
_, events := bus.Subscribe("conv-refresh-stream")
|
||||||
|
primaryCalls := 0
|
||||||
|
cb := h.createProgressCallback(
|
||||||
|
context.Background(), nil, "conv-refresh-stream", "",
|
||||||
|
func(eventType, message string, data interface{}) { primaryCalls++ },
|
||||||
|
)
|
||||||
|
|
||||||
|
cb("progress", "第 3 轮", map[string]interface{}{"iteration": 3})
|
||||||
|
if primaryCalls != 1 {
|
||||||
|
t.Fatalf("expected primary SSE callback once, got %d", primaryCalls)
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case payload := <-events:
|
||||||
|
body := string(payload)
|
||||||
|
if !strings.Contains(body, `"type":"progress"`) || !strings.Contains(body, `"conversationId":"conv-refresh-stream"`) {
|
||||||
|
t.Fatalf("unexpected mirrored event: %s", body)
|
||||||
|
}
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("expected progress event mirrored to task event bus")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateProgressCallback_HidesInternalEinoDiagnostics(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
db, err := database.NewDB(filepath.Join(tmp, "test.sqlite"), zap.NewNop())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewDB: %v", err)
|
||||||
|
}
|
||||||
|
conv, err := db.CreateConversation("diag-hidden", database.ConversationCreateMeta{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateConversation: %v", err)
|
||||||
|
}
|
||||||
|
asst, err := db.AddMessage(conv.ID, "assistant", "处理中...", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("AddMessage: %v", err)
|
||||||
|
}
|
||||||
|
bus := NewTaskEventBus()
|
||||||
|
h := &AgentHandler{logger: zap.NewNop(), db: db, taskEventBus: bus}
|
||||||
|
_, events := bus.Subscribe(conv.ID)
|
||||||
|
primaryCalls := 0
|
||||||
|
cb := h.createProgressCallback(
|
||||||
|
context.Background(), nil, conv.ID, asst.ID,
|
||||||
|
func(string, string, interface{}) { primaryCalls++ },
|
||||||
|
)
|
||||||
|
|
||||||
|
cb("model_output_rejected", "模型工具调用不完整或参数不安全,已阻止执行并要求重写。", map[string]interface{}{
|
||||||
|
"reason": "invalid_tool_arguments_json",
|
||||||
|
})
|
||||||
|
cb("progress", "Eino TurnLoop 常驻多轮 runtime 已接管本轮会话。", map[string]interface{}{
|
||||||
|
"kind": "turn_loop_takeover",
|
||||||
|
})
|
||||||
|
|
||||||
|
if primaryCalls != 0 {
|
||||||
|
t.Fatalf("primary SSE calls = %d, want hidden diagnostics", primaryCalls)
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case payload := <-events:
|
||||||
|
t.Fatalf("unexpected mirrored diagnostic event: %s", string(payload))
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
details, err := db.GetProcessDetails(asst.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetProcessDetails: %v", err)
|
||||||
|
}
|
||||||
|
if len(details) != 0 {
|
||||||
|
t.Fatalf("process details = %+v, want no diagnostics persisted", details)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateProgressCallback_PersistsRunningResponseBeforeDone(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
db, err := database.NewDB(filepath.Join(tmp, "test.sqlite"), zap.NewNop())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewDB: %v", err)
|
||||||
|
}
|
||||||
|
conv, err := db.CreateConversation("refresh-running", database.ConversationCreateMeta{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateConversation: %v", err)
|
||||||
|
}
|
||||||
|
asst, err := db.AddMessage(conv.ID, "assistant", "处理中...", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("AddMessage: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
h := &AgentHandler{logger: zap.NewNop(), db: db}
|
||||||
|
cb := h.createProgressCallback(context.Background(), nil, conv.ID, asst.ID, nil)
|
||||||
|
meta := map[string]interface{}{
|
||||||
|
"streamId": "response-refresh-1",
|
||||||
|
"einoAgent": "cyberstrike-eino-single",
|
||||||
|
"orchestration": "eino_single",
|
||||||
|
}
|
||||||
|
cb("response_start", "", meta)
|
||||||
|
cb("response_delta", "刷新前已生成的第一部分", openai.WithSSEAccumulated(meta, "刷新前已生成的第一部分"))
|
||||||
|
|
||||||
|
details, err := db.GetProcessDetails(asst.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetProcessDetails: %v", err)
|
||||||
|
}
|
||||||
|
if len(details) != 1 || details[0].EventType != "planning" || details[0].Message != "刷新前已生成的第一部分" {
|
||||||
|
t.Fatalf("expected one running planning snapshot, got %+v", details)
|
||||||
|
}
|
||||||
|
|
||||||
|
longer := "刷新前已生成的第一部分" + strings.Repeat("继续迭代", 300)
|
||||||
|
cb("response_delta", "继续迭代", openai.WithSSEAccumulated(meta, longer))
|
||||||
|
details, err = db.GetProcessDetails(asst.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetProcessDetails after update: %v", err)
|
||||||
|
}
|
||||||
|
if len(details) != 1 || details[0].Message != longer {
|
||||||
|
t.Fatalf("running snapshot should update in-place, rows=%d len=%d", len(details), len(details[0].Message))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestCreateProgressCallback_FlushesReasoningOnDone 流式推理聚合须在 done/response 时落库,刷新后可回放。
|
// TestCreateProgressCallback_FlushesReasoningOnDone 流式推理聚合须在 done/response 时落库,刷新后可回放。
|
||||||
func TestCreateProgressCallback_FlushesReasoningOnDone(t *testing.T) {
|
func TestCreateProgressCallback_FlushesReasoningOnDone(t *testing.T) {
|
||||||
tmp := t.TempDir()
|
tmp := t.TempDir()
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"cyberstrike-ai/internal/audit"
|
"cyberstrike-ai/internal/audit"
|
||||||
"cyberstrike-ai/internal/authctx"
|
"cyberstrike-ai/internal/authctx"
|
||||||
"cyberstrike-ai/internal/config"
|
"cyberstrike-ai/internal/config"
|
||||||
|
"cyberstrike-ai/internal/database"
|
||||||
"cyberstrike-ai/internal/mcp"
|
"cyberstrike-ai/internal/mcp"
|
||||||
"cyberstrike-ai/internal/multiagent"
|
"cyberstrike-ai/internal/multiagent"
|
||||||
|
|
||||||
@@ -118,8 +119,7 @@ func (h *AgentHandler) executeOneBatchSubTask(queueID string, queue *BatchTaskQu
|
|||||||
}
|
}
|
||||||
principal := authctx.NewPrincipalWithScopes(access.User.ID, access.User.Username, access.Scope, access.Permissions, access.PermissionScopes)
|
principal := authctx.NewPrincipalWithScopes(access.User.ID, access.User.Username, access.Scope, access.Permissions, access.PermissionScopes)
|
||||||
title := safeTruncateString(task.Message, 50)
|
title := safeTruncateString(task.Message, 50)
|
||||||
batchMeta := audit.ConversationCreateMeta("batch_task")
|
batchMeta := batchSubTaskConversationMeta(h.config, queue)
|
||||||
batchMeta.ProjectID = effectiveProjectID(h.config, queue.ProjectID)
|
|
||||||
conv, err := h.db.CreateConversation(title, batchMeta)
|
conv, err := h.db.CreateConversation(title, batchMeta)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.logger.Error("创建对话失败", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.Error(err))
|
h.logger.Error("创建对话失败", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.Error(err))
|
||||||
@@ -238,6 +238,11 @@ func (h *AgentHandler) executeOneBatchSubTask(queueID string, queue *BatchTaskQu
|
|||||||
useBatchMulti = true
|
useBatchMulti = true
|
||||||
batchOrch = "deep"
|
batchOrch = "deep"
|
||||||
}
|
}
|
||||||
|
if useBatchMulti {
|
||||||
|
_ = h.db.SetConversationAgentMode(conversationID, batchOrch)
|
||||||
|
} else {
|
||||||
|
_ = h.db.SetConversationAgentMode(conversationID, "eino_single")
|
||||||
|
}
|
||||||
|
|
||||||
var resultMA *multiagent.RunResult
|
var resultMA *multiagent.RunResult
|
||||||
var runErr error
|
var runErr error
|
||||||
@@ -268,19 +273,38 @@ func (h *AgentHandler) executeOneBatchSubTask(queueID string, queue *BatchTaskQu
|
|||||||
|
|
||||||
h.logger.Info("批量任务执行成功", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.String("conversationId", conversationID))
|
h.logger.Info("批量任务执行成功", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.String("conversationId", conversationID))
|
||||||
|
|
||||||
resText := resultMA.Response
|
|
||||||
mcpIDs := resultMA.MCPExecutionIDs
|
mcpIDs := resultMA.MCPExecutionIDs
|
||||||
lastIn := resultMA.LastAgentTraceInput
|
lastIn := resultMA.LastAgentTraceInput
|
||||||
lastOut := resultMA.LastAgentTraceOutput
|
lastOut := resultMA.LastAgentTraceOutput
|
||||||
|
reasoningContent := multiagent.AggregatedReasoningFromTraceJSON(lastIn)
|
||||||
|
agentMode := "batch_eino_single"
|
||||||
|
if useBatchMulti {
|
||||||
|
agentMode = "batch_eino_" + batchOrch
|
||||||
|
}
|
||||||
|
decision := h.finalizeAgentRunForDeliveryWithPolicy(conversationID, assistantMessageID, agentMode, resultMA, mcpIDs, reasoningContent, true)
|
||||||
|
resText := decision.FinalText
|
||||||
|
if !decision.Finalizable {
|
||||||
|
resText = finalizationBlockedMessage(decision)
|
||||||
|
finishStatus = decision.Status
|
||||||
|
sendEvent("finalization_check", resText, decision)
|
||||||
|
}
|
||||||
|
sendEvent("response", resText, finalizationResponsePayload(decision, map[string]interface{}{
|
||||||
|
"conversationId": conversationID,
|
||||||
|
"messageId": assistantMessageID,
|
||||||
|
"agentMode": agentMode,
|
||||||
|
"mcpExecutionIds": mcpIDs,
|
||||||
|
"batchQueueId": queueID,
|
||||||
|
"batchTaskId": task.ID,
|
||||||
|
"batchTaskStatus": map[bool]string{true: string(BatchTaskStatusCompleted), false: string(BatchTaskStatusFailed)}[decision.Finalizable],
|
||||||
|
"candidatePreview": safeTruncateString(resultMA.Response, 500),
|
||||||
|
}))
|
||||||
|
|
||||||
if assistantMessageID != "" {
|
if assistantMessageID == "" {
|
||||||
if updateErr := h.db.UpdateAssistantMessageFinalize(assistantMessageID, resText, mcpIDs, multiagent.AggregatedReasoningFromTraceJSON(lastIn)); updateErr != nil {
|
_, err = h.db.AddMessage(conversationID, "assistant", resText, mcpIDs)
|
||||||
h.logger.Warn("更新助手消息失败", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.Error(updateErr))
|
} else if !decision.Finalizable {
|
||||||
if _, err = h.db.AddMessage(conversationID, "assistant", resText, mcpIDs); err != nil {
|
err = nil
|
||||||
h.logger.Error("保存助手消息失败", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.String("conversationId", conversationID), zap.Error(err))
|
}
|
||||||
}
|
if err != nil {
|
||||||
}
|
|
||||||
} else if _, err = h.db.AddMessage(conversationID, "assistant", resText, mcpIDs); err != nil {
|
|
||||||
h.logger.Error("保存助手消息失败", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.String("conversationId", conversationID), zap.Error(err))
|
h.logger.Error("保存助手消息失败", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.String("conversationId", conversationID), zap.Error(err))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -290,9 +314,24 @@ func (h *AgentHandler) executeOneBatchSubTask(queueID string, queue *BatchTaskQu
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !decision.Finalizable {
|
||||||
|
h.batchTaskManager.UpdateTaskStatusWithConversationID(queueID, task.ID, BatchTaskStatusFailed, resText, finalizationCheckMessage(decision), conversationID)
|
||||||
|
return
|
||||||
|
}
|
||||||
h.batchTaskManager.UpdateTaskStatusWithConversationID(queueID, task.ID, BatchTaskStatusCompleted, resText, "", conversationID)
|
h.batchTaskManager.UpdateTaskStatusWithConversationID(queueID, task.ID, BatchTaskStatusCompleted, resText, "", conversationID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func batchSubTaskConversationMeta(cfg *config.Config, queue *BatchTaskQueue) database.ConversationCreateMeta {
|
||||||
|
meta := audit.ConversationCreateMeta("batch_task")
|
||||||
|
if queue == nil {
|
||||||
|
meta.ProjectID = effectiveProjectID(cfg, "")
|
||||||
|
return meta
|
||||||
|
}
|
||||||
|
meta.ProjectID = effectiveProjectID(cfg, queue.ProjectID)
|
||||||
|
meta.RoleName = strings.TrimSpace(queue.Role)
|
||||||
|
return meta
|
||||||
|
}
|
||||||
|
|
||||||
func (h *AgentHandler) handleBatchSubTaskRunError(
|
func (h *AgentHandler) handleBatchSubTaskRunError(
|
||||||
queueID string,
|
queueID string,
|
||||||
task *BatchTask,
|
task *BatchTask,
|
||||||
|
|||||||
@@ -61,6 +61,18 @@ func TestBatchQueueExecutionShouldStop(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBatchSubTaskConversationMetaKeepsQueueRole(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
meta := batchSubTaskConversationMeta(nil, &BatchTaskQueue{Role: " 渗透测试 "})
|
||||||
|
if meta.Source != "batch_task" {
|
||||||
|
t.Fatalf("expected batch_task source, got %q", meta.Source)
|
||||||
|
}
|
||||||
|
if meta.RoleName != "渗透测试" {
|
||||||
|
t.Fatalf("expected queue role to be stored on child conversation, got %q", meta.RoleName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestDeleteQueueBlockedWhileExecutorActive(t *testing.T) {
|
func TestDeleteQueueBlockedWhileExecutorActive(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
m := NewBatchTaskManager(zap.NewNop())
|
m := NewBatchTaskManager(zap.NewNop())
|
||||||
|
|||||||
@@ -27,11 +27,14 @@ import (
|
|||||||
const (
|
const (
|
||||||
chatUploadsRootDirName = "chat_uploads"
|
chatUploadsRootDirName = "chat_uploads"
|
||||||
reductionRootDirName = "tmp/reduction"
|
reductionRootDirName = "tmp/reduction"
|
||||||
|
workspaceRootDirName = "tmp/workspace"
|
||||||
artifactsRootDirName = "data/conversation_artifacts"
|
artifactsRootDirName = "data/conversation_artifacts"
|
||||||
reductionVirtualPrefix = "__reduction__/"
|
reductionVirtualPrefix = "__reduction__/"
|
||||||
|
workspaceVirtualPrefix = "__workspace__/"
|
||||||
artifactVirtualPrefix = "__conversation_artifact__/"
|
artifactVirtualPrefix = "__conversation_artifact__/"
|
||||||
chatUploadSourceUpload = "upload"
|
chatUploadSourceUpload = "upload"
|
||||||
chatUploadSourceReduction = "reduction"
|
chatUploadSourceReduction = "reduction"
|
||||||
|
chatUploadSourceWorkspace = "workspace"
|
||||||
chatUploadSourceConversation = "conversation_artifact"
|
chatUploadSourceConversation = "conversation_artifact"
|
||||||
maxChatUploadEditBytes = 2 * 1024 * 1024 // 文本编辑上限
|
maxChatUploadEditBytes = 2 * 1024 * 1024 // 文本编辑上限
|
||||||
)
|
)
|
||||||
@@ -104,6 +107,11 @@ func (h *ChatUploadsHandler) reductionPathAllowed(c *gin.Context, scope, id stri
|
|||||||
|
|
||||||
func (h *ChatUploadsHandler) reductionVirtualPathAllowed(c *gin.Context, relativePath string) bool {
|
func (h *ChatUploadsHandler) reductionVirtualPathAllowed(c *gin.Context, relativePath string) bool {
|
||||||
rel := strings.TrimPrefix(strings.TrimSpace(relativePath), reductionVirtualPrefix)
|
rel := strings.TrimPrefix(strings.TrimSpace(relativePath), reductionVirtualPrefix)
|
||||||
|
rel = strings.Trim(filepath.ToSlash(filepath.Clean(filepath.FromSlash(rel))), "/")
|
||||||
|
if rel == "projects" || rel == "conversations" {
|
||||||
|
_, ok := security.CurrentSession(c)
|
||||||
|
return ok
|
||||||
|
}
|
||||||
parts := strings.Split(filepath.ToSlash(rel), "/")
|
parts := strings.Split(filepath.ToSlash(rel), "/")
|
||||||
if len(parts) < 2 {
|
if len(parts) < 2 {
|
||||||
return false
|
return false
|
||||||
@@ -111,6 +119,24 @@ func (h *ChatUploadsHandler) reductionVirtualPathAllowed(c *gin.Context, relativ
|
|||||||
return h.reductionPathAllowed(c, parts[0], parts[1])
|
return h.reductionPathAllowed(c, parts[0], parts[1])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *ChatUploadsHandler) workspacePathAllowed(c *gin.Context, scope, id string) bool {
|
||||||
|
return h.reductionPathAllowed(c, scope, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ChatUploadsHandler) workspaceVirtualPathAllowed(c *gin.Context, relativePath string) bool {
|
||||||
|
rel := strings.TrimPrefix(strings.TrimSpace(relativePath), workspaceVirtualPrefix)
|
||||||
|
rel = strings.Trim(filepath.ToSlash(filepath.Clean(filepath.FromSlash(rel))), "/")
|
||||||
|
if rel == "projects" || rel == "conversations" {
|
||||||
|
_, ok := security.CurrentSession(c)
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
parts := strings.Split(filepath.ToSlash(rel), "/")
|
||||||
|
if len(parts) < 2 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return h.workspacePathAllowed(c, parts[0], parts[1])
|
||||||
|
}
|
||||||
|
|
||||||
func (h *ChatUploadsHandler) conversationArtifactPathAllowed(c *gin.Context, conversationID string) bool {
|
func (h *ChatUploadsHandler) conversationArtifactPathAllowed(c *gin.Context, conversationID string) bool {
|
||||||
session, ok := security.CurrentSession(c)
|
session, ok := security.CurrentSession(c)
|
||||||
if !ok || h.db == nil {
|
if !ok || h.db == nil {
|
||||||
@@ -163,6 +189,26 @@ func (h *ChatUploadsHandler) absReductionRoot() (string, error) {
|
|||||||
return filepath.Abs(filepath.Join(cwd, reductionRootDirName))
|
return filepath.Abs(filepath.Join(cwd, reductionRootDirName))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *ChatUploadsHandler) absWorkspaceRoot() (string, error) {
|
||||||
|
if h.db != nil {
|
||||||
|
if base := strings.TrimSpace(h.db.EinoWorkspaceBaseDir()); base != "" {
|
||||||
|
if filepath.IsAbs(base) {
|
||||||
|
return filepath.Abs(base)
|
||||||
|
}
|
||||||
|
cwd, err := os.Getwd()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return filepath.Abs(filepath.Join(cwd, base))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cwd, err := os.Getwd()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return filepath.Abs(filepath.Join(cwd, workspaceRootDirName))
|
||||||
|
}
|
||||||
|
|
||||||
func (h *ChatUploadsHandler) absConversationArtifactsRoot() (string, error) {
|
func (h *ChatUploadsHandler) absConversationArtifactsRoot() (string, error) {
|
||||||
if h.db != nil {
|
if h.db != nil {
|
||||||
if base := strings.TrimSpace(h.db.ConversationArtifactsBaseDir()); base != "" {
|
if base := strings.TrimSpace(h.db.ConversationArtifactsBaseDir()); base != "" {
|
||||||
@@ -211,15 +257,17 @@ func (h *ChatUploadsHandler) resolveUnderChatUploads(relativePath string) (abs s
|
|||||||
|
|
||||||
// ChatUploadFileItem 列表项
|
// ChatUploadFileItem 列表项
|
||||||
type ChatUploadFileItem struct {
|
type ChatUploadFileItem struct {
|
||||||
RelativePath string `json:"relativePath"`
|
RelativePath string `json:"relativePath"`
|
||||||
AbsolutePath string `json:"absolutePath"` // 服务器上的绝对路径,便于在对话中引用(与附件落盘路径一致)
|
AbsolutePath string `json:"absolutePath"` // 服务器上的绝对路径,便于在对话中引用(与附件落盘路径一致)
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Source string `json:"source,omitempty"`
|
Source string `json:"source,omitempty"`
|
||||||
Size int64 `json:"size"`
|
Size int64 `json:"size"`
|
||||||
ModifiedUnix int64 `json:"modifiedUnix"`
|
ModifiedUnix int64 `json:"modifiedUnix"`
|
||||||
Date string `json:"date"`
|
Date string `json:"date"`
|
||||||
ConversationID string `json:"conversationId"`
|
ConversationID string `json:"conversationId"`
|
||||||
ProjectID string `json:"projectId,omitempty"`
|
ConversationTitle string `json:"conversationTitle,omitempty"`
|
||||||
|
ProjectID string `json:"projectId,omitempty"`
|
||||||
|
ProjectName string `json:"projectName,omitempty"`
|
||||||
// SubPath 为日期、会话目录之下的子路径(不含文件名),如 date/conv/a/b/file 则为 "a/b";无嵌套则为 ""。
|
// SubPath 为日期、会话目录之下的子路径(不含文件名),如 date/conv/a/b/file 则为 "a/b";无嵌套则为 ""。
|
||||||
SubPath string `json:"subPath"`
|
SubPath string `json:"subPath"`
|
||||||
}
|
}
|
||||||
@@ -240,6 +288,38 @@ func (h *ChatUploadsHandler) conversationProjectID(conversationID string, cache
|
|||||||
return projectID
|
return projectID
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *ChatUploadsHandler) conversationTitle(conversationID string, cache map[string]string) string {
|
||||||
|
conversationID = strings.TrimSpace(conversationID)
|
||||||
|
if conversationID == "" || conversationID == "_manual" || conversationID == "_new" || h.db == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if v, ok := cache[conversationID]; ok {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
title, err := h.db.GetConversationTitle(conversationID)
|
||||||
|
if err != nil {
|
||||||
|
title = ""
|
||||||
|
}
|
||||||
|
cache[conversationID] = title
|
||||||
|
return title
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ChatUploadsHandler) projectName(projectID string, cache map[string]string) string {
|
||||||
|
projectID = strings.TrimSpace(projectID)
|
||||||
|
if projectID == "" || h.db == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if v, ok := cache[projectID]; ok {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
name, err := h.db.GetProjectName(projectID)
|
||||||
|
if err != nil {
|
||||||
|
name = ""
|
||||||
|
}
|
||||||
|
cache[projectID] = name
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
func (h *ChatUploadsHandler) collectFiles(c *gin.Context, conversationFilter, projectFilter string) ([]ChatUploadFileItem, []string, error) {
|
func (h *ChatUploadsHandler) collectFiles(c *gin.Context, conversationFilter, projectFilter string) ([]ChatUploadFileItem, []string, error) {
|
||||||
root, err := h.absRoot()
|
root, err := h.absRoot()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -252,6 +332,8 @@ func (h *ChatUploadsHandler) collectFiles(c *gin.Context, conversationFilter, pr
|
|||||||
var files []ChatUploadFileItem
|
var files []ChatUploadFileItem
|
||||||
var folders []string
|
var folders []string
|
||||||
projectCache := make(map[string]string)
|
projectCache := make(map[string]string)
|
||||||
|
projectNameCache := make(map[string]string)
|
||||||
|
conversationTitleCache := make(map[string]string)
|
||||||
err = filepath.WalkDir(root, func(path string, d os.DirEntry, walkErr error) error {
|
err = filepath.WalkDir(root, func(path string, d os.DirEntry, walkErr error) error {
|
||||||
if walkErr != nil {
|
if walkErr != nil {
|
||||||
return walkErr
|
return walkErr
|
||||||
@@ -293,16 +375,18 @@ func (h *ChatUploadsHandler) collectFiles(c *gin.Context, conversationFilter, pr
|
|||||||
}
|
}
|
||||||
absPath, _ := filepath.Abs(path)
|
absPath, _ := filepath.Abs(path)
|
||||||
files = append(files, ChatUploadFileItem{
|
files = append(files, ChatUploadFileItem{
|
||||||
RelativePath: relSlash,
|
RelativePath: relSlash,
|
||||||
AbsolutePath: absPath,
|
AbsolutePath: absPath,
|
||||||
Name: d.Name(),
|
Name: d.Name(),
|
||||||
Source: chatUploadSourceUpload,
|
Source: chatUploadSourceUpload,
|
||||||
Size: info.Size(),
|
Size: info.Size(),
|
||||||
ModifiedUnix: info.ModTime().Unix(),
|
ModifiedUnix: info.ModTime().Unix(),
|
||||||
Date: dateStr,
|
Date: dateStr,
|
||||||
ConversationID: convID,
|
ConversationID: convID,
|
||||||
ProjectID: projectID,
|
ConversationTitle: h.conversationTitle(convID, conversationTitleCache),
|
||||||
SubPath: subPath,
|
ProjectID: projectID,
|
||||||
|
ProjectName: h.projectName(projectID, projectNameCache),
|
||||||
|
SubPath: subPath,
|
||||||
})
|
})
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
@@ -354,6 +438,12 @@ func (h *ChatUploadsHandler) collectFiles(c *gin.Context, conversationFilter, pr
|
|||||||
} else if len(reductionFiles) > 0 {
|
} else if len(reductionFiles) > 0 {
|
||||||
files = append(files, reductionFiles...)
|
files = append(files, reductionFiles...)
|
||||||
}
|
}
|
||||||
|
workspaceFiles, err := h.collectWorkspaceFiles(c, conversationFilter, projectFilter)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Warn("列举 workspace 产物失败", zap.Error(err))
|
||||||
|
} else if len(workspaceFiles) > 0 {
|
||||||
|
files = append(files, workspaceFiles...)
|
||||||
|
}
|
||||||
artifactFiles, err := h.collectConversationArtifactFiles(c, conversationFilter, projectFilter)
|
artifactFiles, err := h.collectConversationArtifactFiles(c, conversationFilter, projectFilter)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.logger.Warn("列举 conversation_artifacts 产物失败", zap.Error(err))
|
h.logger.Warn("列举 conversation_artifacts 产物失败", zap.Error(err))
|
||||||
@@ -375,6 +465,8 @@ func (h *ChatUploadsHandler) collectReductionFiles(c *gin.Context, conversationF
|
|||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
projectCache := make(map[string]string)
|
projectCache := make(map[string]string)
|
||||||
|
projectNameCache := make(map[string]string)
|
||||||
|
conversationTitleCache := make(map[string]string)
|
||||||
files := make([]ChatUploadFileItem, 0)
|
files := make([]ChatUploadFileItem, 0)
|
||||||
for _, scope := range []string{"conversations", "projects"} {
|
for _, scope := range []string{"conversations", "projects"} {
|
||||||
scopeRoot := filepath.Join(root, scope)
|
scopeRoot := filepath.Join(root, scope)
|
||||||
@@ -403,7 +495,10 @@ func (h *ChatUploadsHandler) collectReductionFiles(c *gin.Context, conversationF
|
|||||||
projectID = ownerID
|
projectID = ownerID
|
||||||
}
|
}
|
||||||
if conversationFilter != "" && convID != conversationFilter {
|
if conversationFilter != "" && convID != conversationFilter {
|
||||||
return nil
|
if scope != "projects" || h.conversationProjectID(conversationFilter, projectCache) != projectID {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
convID = conversationFilter
|
||||||
}
|
}
|
||||||
if projectFilter != "" && projectID != projectFilter {
|
if projectFilter != "" && projectID != projectFilter {
|
||||||
return nil
|
return nil
|
||||||
@@ -418,16 +513,90 @@ func (h *ChatUploadsHandler) collectReductionFiles(c *gin.Context, conversationF
|
|||||||
}
|
}
|
||||||
abs, _ := filepath.Abs(path)
|
abs, _ := filepath.Abs(path)
|
||||||
files = append(files, ChatUploadFileItem{
|
files = append(files, ChatUploadFileItem{
|
||||||
RelativePath: reductionVirtualPrefix + relSlash,
|
RelativePath: reductionVirtualPrefix + relSlash,
|
||||||
AbsolutePath: abs,
|
AbsolutePath: abs,
|
||||||
Name: name,
|
Name: name,
|
||||||
Source: chatUploadSourceReduction,
|
Source: chatUploadSourceReduction,
|
||||||
Size: info.Size(),
|
Size: info.Size(),
|
||||||
ModifiedUnix: info.ModTime().Unix(),
|
ModifiedUnix: info.ModTime().Unix(),
|
||||||
Date: info.ModTime().Format("2006-01-02"),
|
Date: info.ModTime().Format("2006-01-02"),
|
||||||
ConversationID: convID,
|
ConversationID: convID,
|
||||||
ProjectID: projectID,
|
ConversationTitle: h.conversationTitle(convID, conversationTitleCache),
|
||||||
SubPath: strings.Join(parts[2:len(parts)-1], "/"),
|
ProjectID: projectID,
|
||||||
|
ProjectName: h.projectName(projectID, projectNameCache),
|
||||||
|
SubPath: strings.Join(parts[2:len(parts)-1], "/"),
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return files, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ChatUploadsHandler) collectWorkspaceFiles(c *gin.Context, conversationFilter, projectFilter string) ([]ChatUploadFileItem, error) {
|
||||||
|
root, err := h.absWorkspaceRoot()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if st, err := os.Stat(root); err != nil || !st.IsDir() {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
projectCache := make(map[string]string)
|
||||||
|
projectNameCache := make(map[string]string)
|
||||||
|
conversationTitleCache := make(map[string]string)
|
||||||
|
files := make([]ChatUploadFileItem, 0)
|
||||||
|
for _, scope := range []string{"conversations", "projects"} {
|
||||||
|
scopeRoot := filepath.Join(root, scope)
|
||||||
|
_ = filepath.WalkDir(scopeRoot, func(path string, d os.DirEntry, walkErr error) error {
|
||||||
|
if walkErr != nil || d == nil || d.IsDir() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
rel, err := filepath.Rel(root, path)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
relSlash := filepath.ToSlash(rel)
|
||||||
|
parts := strings.Split(relSlash, "/")
|
||||||
|
if len(parts) < 3 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
ownerID := parts[1]
|
||||||
|
if !h.workspacePathAllowed(c, scope, ownerID) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var convID, projectID string
|
||||||
|
if scope == "conversations" {
|
||||||
|
convID = ownerID
|
||||||
|
projectID = h.conversationProjectID(convID, projectCache)
|
||||||
|
} else {
|
||||||
|
projectID = ownerID
|
||||||
|
}
|
||||||
|
if conversationFilter != "" && convID != conversationFilter {
|
||||||
|
if scope != "projects" || h.conversationProjectID(conversationFilter, projectCache) != projectID {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
convID = conversationFilter
|
||||||
|
}
|
||||||
|
if projectFilter != "" && projectID != projectFilter {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
info, err := d.Info()
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
abs, _ := filepath.Abs(path)
|
||||||
|
files = append(files, ChatUploadFileItem{
|
||||||
|
RelativePath: workspaceVirtualPrefix + relSlash,
|
||||||
|
AbsolutePath: abs,
|
||||||
|
Name: d.Name(),
|
||||||
|
Source: chatUploadSourceWorkspace,
|
||||||
|
Size: info.Size(),
|
||||||
|
ModifiedUnix: info.ModTime().Unix(),
|
||||||
|
Date: info.ModTime().Format("2006-01-02"),
|
||||||
|
ConversationID: convID,
|
||||||
|
ConversationTitle: h.conversationTitle(convID, conversationTitleCache),
|
||||||
|
ProjectID: projectID,
|
||||||
|
ProjectName: h.projectName(projectID, projectNameCache),
|
||||||
|
SubPath: strings.Join(parts[2:len(parts)-1], "/"),
|
||||||
})
|
})
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
@@ -444,6 +613,8 @@ func (h *ChatUploadsHandler) collectConversationArtifactFiles(c *gin.Context, co
|
|||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
projectCache := make(map[string]string)
|
projectCache := make(map[string]string)
|
||||||
|
projectNameCache := make(map[string]string)
|
||||||
|
conversationTitleCache := make(map[string]string)
|
||||||
files := make([]ChatUploadFileItem, 0)
|
files := make([]ChatUploadFileItem, 0)
|
||||||
_ = filepath.WalkDir(root, func(path string, d os.DirEntry, walkErr error) error {
|
_ = filepath.WalkDir(root, func(path string, d os.DirEntry, walkErr error) error {
|
||||||
if walkErr != nil || d == nil || d.IsDir() {
|
if walkErr != nil || d == nil || d.IsDir() {
|
||||||
@@ -479,16 +650,18 @@ func (h *ChatUploadsHandler) collectConversationArtifactFiles(c *gin.Context, co
|
|||||||
}
|
}
|
||||||
abs, _ := filepath.Abs(path)
|
abs, _ := filepath.Abs(path)
|
||||||
files = append(files, ChatUploadFileItem{
|
files = append(files, ChatUploadFileItem{
|
||||||
RelativePath: artifactVirtualPrefix + relSlash,
|
RelativePath: artifactVirtualPrefix + relSlash,
|
||||||
AbsolutePath: abs,
|
AbsolutePath: abs,
|
||||||
Name: name,
|
Name: name,
|
||||||
Source: chatUploadSourceConversation,
|
Source: chatUploadSourceConversation,
|
||||||
Size: info.Size(),
|
Size: info.Size(),
|
||||||
ModifiedUnix: info.ModTime().Unix(),
|
ModifiedUnix: info.ModTime().Unix(),
|
||||||
Date: info.ModTime().Format("2006-01-02"),
|
Date: info.ModTime().Format("2006-01-02"),
|
||||||
ConversationID: convID,
|
ConversationID: convID,
|
||||||
ProjectID: projectID,
|
ConversationTitle: h.conversationTitle(convID, conversationTitleCache),
|
||||||
SubPath: strings.Join(parts[1:len(parts)-1], "/"),
|
ProjectID: projectID,
|
||||||
|
ProjectName: h.projectName(projectID, projectNameCache),
|
||||||
|
SubPath: strings.Join(parts[1:len(parts)-1], "/"),
|
||||||
})
|
})
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
@@ -516,6 +689,27 @@ func (h *ChatUploadsHandler) resolveReductionVirtualPath(relativePath string) (s
|
|||||||
return full, nil
|
return full, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *ChatUploadsHandler) resolveWorkspaceVirtualPath(relativePath string) (string, error) {
|
||||||
|
rel := strings.TrimPrefix(strings.TrimSpace(relativePath), workspaceVirtualPrefix)
|
||||||
|
rel = filepath.Clean(filepath.FromSlash(rel))
|
||||||
|
if rel == "." || strings.HasPrefix(rel, "..") {
|
||||||
|
return "", fmt.Errorf("invalid path")
|
||||||
|
}
|
||||||
|
root, err := h.absWorkspaceRoot()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
full, err := filepath.Abs(filepath.Join(root, rel))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
rootAbs, _ := filepath.Abs(root)
|
||||||
|
if full != rootAbs && !strings.HasPrefix(full, rootAbs+string(filepath.Separator)) {
|
||||||
|
return "", fmt.Errorf("path escapes workspace root")
|
||||||
|
}
|
||||||
|
return full, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (h *ChatUploadsHandler) resolveConversationArtifactVirtualPath(relativePath string) (string, error) {
|
func (h *ChatUploadsHandler) resolveConversationArtifactVirtualPath(relativePath string) (string, error) {
|
||||||
rel := strings.TrimPrefix(strings.TrimSpace(relativePath), artifactVirtualPrefix)
|
rel := strings.TrimPrefix(strings.TrimSpace(relativePath), artifactVirtualPrefix)
|
||||||
rel = filepath.Clean(filepath.FromSlash(rel))
|
rel = filepath.Clean(filepath.FromSlash(rel))
|
||||||
@@ -539,8 +733,10 @@ func (h *ChatUploadsHandler) resolveConversationArtifactVirtualPath(relativePath
|
|||||||
|
|
||||||
func chatUploadItemIsInternal(item ChatUploadFileItem) bool {
|
func chatUploadItemIsInternal(item ChatUploadFileItem) bool {
|
||||||
return item.Source == chatUploadSourceReduction ||
|
return item.Source == chatUploadSourceReduction ||
|
||||||
|
item.Source == chatUploadSourceWorkspace ||
|
||||||
item.Source == chatUploadSourceConversation ||
|
item.Source == chatUploadSourceConversation ||
|
||||||
strings.HasPrefix(item.RelativePath, reductionVirtualPrefix) ||
|
strings.HasPrefix(item.RelativePath, reductionVirtualPrefix) ||
|
||||||
|
strings.HasPrefix(item.RelativePath, workspaceVirtualPrefix) ||
|
||||||
strings.HasPrefix(item.RelativePath, artifactVirtualPrefix)
|
strings.HasPrefix(item.RelativePath, artifactVirtualPrefix)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -548,6 +744,8 @@ func (h *ChatUploadsHandler) resolveListedFilePath(item ChatUploadFileItem) (str
|
|||||||
switch {
|
switch {
|
||||||
case item.Source == chatUploadSourceReduction || strings.HasPrefix(item.RelativePath, reductionVirtualPrefix):
|
case item.Source == chatUploadSourceReduction || strings.HasPrefix(item.RelativePath, reductionVirtualPrefix):
|
||||||
return h.resolveReductionVirtualPath(item.RelativePath)
|
return h.resolveReductionVirtualPath(item.RelativePath)
|
||||||
|
case item.Source == chatUploadSourceWorkspace || strings.HasPrefix(item.RelativePath, workspaceVirtualPrefix):
|
||||||
|
return h.resolveWorkspaceVirtualPath(item.RelativePath)
|
||||||
case item.Source == chatUploadSourceConversation || strings.HasPrefix(item.RelativePath, artifactVirtualPrefix):
|
case item.Source == chatUploadSourceConversation || strings.HasPrefix(item.RelativePath, artifactVirtualPrefix):
|
||||||
return h.resolveConversationArtifactVirtualPath(item.RelativePath)
|
return h.resolveConversationArtifactVirtualPath(item.RelativePath)
|
||||||
default:
|
default:
|
||||||
@@ -705,7 +903,7 @@ func (h *ChatUploadsHandler) Export(c *gin.Context) {
|
|||||||
"fileCount": len(files),
|
"fileCount": len(files),
|
||||||
"files": files,
|
"files": files,
|
||||||
"layout": "chat uploads are stored under conversations/<conversationId>/; internal outputs are stored under internal/<source>/",
|
"layout": "chat uploads are stored under conversations/<conversationId>/; internal outputs are stored under internal/<source>/",
|
||||||
"sourceDirectory": []string{chatUploadsRootDirName, reductionRootDirName, artifactsRootDirName},
|
"sourceDirectory": []string{chatUploadsRootDirName, reductionRootDirName, workspaceRootDirName, artifactsRootDirName},
|
||||||
}
|
}
|
||||||
manifestBytes, _ := json.MarshalIndent(manifest, "", " ")
|
manifestBytes, _ := json.MarshalIndent(manifest, "", " ")
|
||||||
mw, err := zw.Create("manifest.json")
|
mw, err := zw.Create("manifest.json")
|
||||||
@@ -732,6 +930,9 @@ func (h *ChatUploadsHandler) Export(c *gin.Context) {
|
|||||||
if filepath.Ext(zipName) == "" {
|
if filepath.Ext(zipName) == "" {
|
||||||
zipName += ".txt"
|
zipName += ".txt"
|
||||||
}
|
}
|
||||||
|
} else if item.Source == chatUploadSourceWorkspace || strings.HasPrefix(item.RelativePath, workspaceVirtualPrefix) {
|
||||||
|
rel := strings.TrimPrefix(item.RelativePath, workspaceVirtualPrefix)
|
||||||
|
zipName = filepath.ToSlash(filepath.Join("internal", "workspace", rel))
|
||||||
} else if item.Source == chatUploadSourceConversation || strings.HasPrefix(item.RelativePath, artifactVirtualPrefix) {
|
} else if item.Source == chatUploadSourceConversation || strings.HasPrefix(item.RelativePath, artifactVirtualPrefix) {
|
||||||
rel := strings.TrimPrefix(item.RelativePath, artifactVirtualPrefix)
|
rel := strings.TrimPrefix(item.RelativePath, artifactVirtualPrefix)
|
||||||
zipName = filepath.ToSlash(filepath.Join("internal", "conversation_artifacts", rel))
|
zipName = filepath.ToSlash(filepath.Join("internal", "conversation_artifacts", rel))
|
||||||
@@ -800,6 +1001,24 @@ func (h *ChatUploadsHandler) Download(c *gin.Context) {
|
|||||||
c.FileAttachment(abs, name)
|
c.FileAttachment(abs, name)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if strings.HasPrefix(strings.TrimSpace(p), workspaceVirtualPrefix) {
|
||||||
|
if !h.workspaceVirtualPathAllowed(c, p) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
abs, err := h.resolveWorkspaceVirtualPath(p)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
st, err := os.Stat(abs)
|
||||||
|
if err != nil || st.IsDir() {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "file not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.FileAttachment(abs, filepath.Base(abs))
|
||||||
|
return
|
||||||
|
}
|
||||||
if strings.HasPrefix(strings.TrimSpace(p), artifactVirtualPrefix) {
|
if strings.HasPrefix(strings.TrimSpace(p), artifactVirtualPrefix) {
|
||||||
if !h.conversationArtifactVirtualPathAllowed(c, p) {
|
if !h.conversationArtifactVirtualPathAllowed(c, p) {
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"})
|
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"})
|
||||||
@@ -839,6 +1058,93 @@ func (h *ChatUploadsHandler) Download(c *gin.Context) {
|
|||||||
c.FileAttachment(abs, filepath.Base(abs))
|
c.FileAttachment(abs, filepath.Base(abs))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ResolvePath GET /api/chat-uploads/path?path=...&kind=file|directory
|
||||||
|
func (h *ChatUploadsHandler) ResolvePath(c *gin.Context) {
|
||||||
|
p := strings.TrimSpace(c.Query("path"))
|
||||||
|
kind := strings.TrimSpace(c.Query("kind"))
|
||||||
|
if kind == "" {
|
||||||
|
kind = "file"
|
||||||
|
}
|
||||||
|
var abs string
|
||||||
|
var err error
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(p, reductionVirtualPrefix):
|
||||||
|
if strings.Trim(strings.TrimPrefix(p, reductionVirtualPrefix), "/") == "" {
|
||||||
|
if _, ok := security.CurrentSession(c); !ok {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
abs, err = h.absReductionRoot()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if !h.reductionVirtualPathAllowed(c, p) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
abs, err = h.resolveReductionVirtualPath(p)
|
||||||
|
case strings.HasPrefix(p, workspaceVirtualPrefix):
|
||||||
|
if strings.Trim(strings.TrimPrefix(p, workspaceVirtualPrefix), "/") == "" {
|
||||||
|
if _, ok := security.CurrentSession(c); !ok {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
abs, err = h.absWorkspaceRoot()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if !h.workspaceVirtualPathAllowed(c, p) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
abs, err = h.resolveWorkspaceVirtualPath(p)
|
||||||
|
case strings.HasPrefix(p, artifactVirtualPrefix):
|
||||||
|
if strings.Trim(strings.TrimPrefix(p, artifactVirtualPrefix), "/") == "" {
|
||||||
|
if _, ok := security.CurrentSession(c); !ok {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
abs, err = h.absConversationArtifactsRoot()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if !h.conversationArtifactVirtualPathAllowed(c, p) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
abs, err = h.resolveConversationArtifactVirtualPath(p)
|
||||||
|
default:
|
||||||
|
if p == "" || p == "." {
|
||||||
|
if _, ok := security.CurrentSession(c); !ok {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
abs, err = h.absRoot()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if !h.pathAllowed(c, p) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
abs, err = h.resolveUnderChatUploads(p)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
st, err := os.Stat(abs)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "path not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if kind == "directory" && !st.IsDir() {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "not a directory"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if kind != "directory" && st.IsDir() {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "not a file"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"absolutePath": abs, "isDir": st.IsDir()})
|
||||||
|
}
|
||||||
|
|
||||||
type chatUploadPathBody struct {
|
type chatUploadPathBody struct {
|
||||||
Path string `json:"path"`
|
Path string `json:"path"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,11 +18,13 @@ import (
|
|||||||
"cyberstrike-ai/internal/config"
|
"cyberstrike-ai/internal/config"
|
||||||
"cyberstrike-ai/internal/database"
|
"cyberstrike-ai/internal/database"
|
||||||
"cyberstrike-ai/internal/knowledge"
|
"cyberstrike-ai/internal/knowledge"
|
||||||
|
"cyberstrike-ai/internal/llm"
|
||||||
"cyberstrike-ai/internal/mcp"
|
"cyberstrike-ai/internal/mcp"
|
||||||
"cyberstrike-ai/internal/mcp/builtin"
|
"cyberstrike-ai/internal/mcp/builtin"
|
||||||
"cyberstrike-ai/internal/openai"
|
"cyberstrike-ai/internal/openai"
|
||||||
"cyberstrike-ai/internal/security"
|
"cyberstrike-ai/internal/security"
|
||||||
|
|
||||||
|
"github.com/cloudwego/eino/schema"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
@@ -356,6 +358,10 @@ func (h *ConfigHandler) GetConfig(c *gin.Context) {
|
|||||||
LatestUserMessageMaxRunes: h.config.MultiAgent.EinoMiddleware.LatestUserMessageMaxRunesEffective(),
|
LatestUserMessageMaxRunes: h.config.MultiAgent.EinoMiddleware.LatestUserMessageMaxRunesEffective(),
|
||||||
LatestUserMessageHeadRunes: h.config.MultiAgent.EinoMiddleware.LatestUserMessageHeadRunesEffective(),
|
LatestUserMessageHeadRunes: h.config.MultiAgent.EinoMiddleware.LatestUserMessageHeadRunesEffective(),
|
||||||
LatestUserMessageTailRunes: h.config.MultiAgent.EinoMiddleware.LatestUserMessageTailRunesEffective(),
|
LatestUserMessageTailRunes: h.config.MultiAgent.EinoMiddleware.LatestUserMessageTailRunesEffective(),
|
||||||
|
ModelRetryMaxRetries: h.config.MultiAgent.EinoMiddleware.ModelRetryMaxRetries,
|
||||||
|
ModelRetryMaxBackoffSec: h.config.MultiAgent.EinoMiddleware.ModelRetryMaxBackoffSec,
|
||||||
|
ModelFailoverChannels: append([]string(nil), h.config.MultiAgent.EinoMiddleware.ModelFailoverChannels...),
|
||||||
|
ModelFailoverMaxRetries: h.config.MultiAgent.EinoMiddleware.ModelFailoverMaxRetries,
|
||||||
ToolSearchAlwaysVisibleTools: append([]string(nil), h.config.MultiAgent.EinoMiddleware.ToolSearchAlwaysVisibleTools...),
|
ToolSearchAlwaysVisibleTools: append([]string(nil), h.config.MultiAgent.EinoMiddleware.ToolSearchAlwaysVisibleTools...),
|
||||||
ToolSearchAlwaysVisibleEffectiveTools: mergeToolNameLists(
|
ToolSearchAlwaysVisibleEffectiveTools: mergeToolNameLists(
|
||||||
h.config.MultiAgent.EinoMiddleware.ToolSearchAlwaysVisibleTools,
|
h.config.MultiAgent.EinoMiddleware.ToolSearchAlwaysVisibleTools,
|
||||||
@@ -1002,6 +1008,30 @@ func (h *ConfigHandler) UpdateConfig(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
h.config.MultiAgent.EinoMiddleware.LatestUserMessageTailRunes = v
|
h.config.MultiAgent.EinoMiddleware.LatestUserMessageTailRunes = v
|
||||||
}
|
}
|
||||||
|
if req.MultiAgent.ModelRetryMaxRetries != nil {
|
||||||
|
v := *req.MultiAgent.ModelRetryMaxRetries
|
||||||
|
if v < 0 {
|
||||||
|
v = 0
|
||||||
|
}
|
||||||
|
h.config.MultiAgent.EinoMiddleware.ModelRetryMaxRetries = v
|
||||||
|
}
|
||||||
|
if req.MultiAgent.ModelRetryMaxBackoffSec != nil {
|
||||||
|
v := *req.MultiAgent.ModelRetryMaxBackoffSec
|
||||||
|
if v < 0 {
|
||||||
|
v = 0
|
||||||
|
}
|
||||||
|
h.config.MultiAgent.EinoMiddleware.ModelRetryMaxBackoffSec = v
|
||||||
|
}
|
||||||
|
if req.MultiAgent.ModelFailoverChannels != nil {
|
||||||
|
h.config.MultiAgent.EinoMiddleware.ModelFailoverChannels = dedupeTrimmedStringList(*req.MultiAgent.ModelFailoverChannels)
|
||||||
|
}
|
||||||
|
if req.MultiAgent.ModelFailoverMaxRetries != nil {
|
||||||
|
v := *req.MultiAgent.ModelFailoverMaxRetries
|
||||||
|
if v < 0 {
|
||||||
|
v = 0
|
||||||
|
}
|
||||||
|
h.config.MultiAgent.EinoMiddleware.ModelFailoverMaxRetries = v
|
||||||
|
}
|
||||||
if req.MultiAgent.ToolSearchAlwaysVisibleTools != nil {
|
if req.MultiAgent.ToolSearchAlwaysVisibleTools != nil {
|
||||||
h.config.MultiAgent.EinoMiddleware.ToolSearchAlwaysVisibleTools = dedupeToolNameList(*req.MultiAgent.ToolSearchAlwaysVisibleTools)
|
h.config.MultiAgent.EinoMiddleware.ToolSearchAlwaysVisibleTools = dedupeToolNameList(*req.MultiAgent.ToolSearchAlwaysVisibleTools)
|
||||||
}
|
}
|
||||||
@@ -1015,6 +1045,10 @@ func (h *ConfigHandler) UpdateConfig(c *gin.Context) {
|
|||||||
zap.Int("latest_user_message_max_runes", h.config.MultiAgent.EinoMiddleware.LatestUserMessageMaxRunesEffective()),
|
zap.Int("latest_user_message_max_runes", h.config.MultiAgent.EinoMiddleware.LatestUserMessageMaxRunesEffective()),
|
||||||
zap.Int("latest_user_message_head_runes", h.config.MultiAgent.EinoMiddleware.LatestUserMessageHeadRunesEffective()),
|
zap.Int("latest_user_message_head_runes", h.config.MultiAgent.EinoMiddleware.LatestUserMessageHeadRunesEffective()),
|
||||||
zap.Int("latest_user_message_tail_runes", h.config.MultiAgent.EinoMiddleware.LatestUserMessageTailRunesEffective()),
|
zap.Int("latest_user_message_tail_runes", h.config.MultiAgent.EinoMiddleware.LatestUserMessageTailRunesEffective()),
|
||||||
|
zap.Int("model_retry_max_retries", h.config.MultiAgent.EinoMiddleware.ModelRetryMaxRetries),
|
||||||
|
zap.Int("model_retry_max_backoff_sec", h.config.MultiAgent.EinoMiddleware.ModelRetryMaxBackoffSec),
|
||||||
|
zap.Int("model_failover_channels", len(h.config.MultiAgent.EinoMiddleware.ModelFailoverChannels)),
|
||||||
|
zap.Int("model_failover_max_retries", h.config.MultiAgent.EinoMiddleware.ModelFailoverMaxRetries),
|
||||||
zap.Int("tool_search_always_visible_tools", len(h.config.MultiAgent.EinoMiddleware.ToolSearchAlwaysVisibleTools)),
|
zap.Int("tool_search_always_visible_tools", len(h.config.MultiAgent.EinoMiddleware.ToolSearchAlwaysVisibleTools)),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1184,7 +1218,7 @@ func (h *ConfigHandler) TestOpenAI(c *gin.Context) {
|
|||||||
"max_completion_tokens": 5,
|
"max_completion_tokens": 5,
|
||||||
}
|
}
|
||||||
|
|
||||||
// 使用内部 openai Client 进行测试,若 provider 为 claude 会自动走桥接层
|
// OpenAI-compatible 通道使用内部客户端;Claude 通道在下方直接使用 Eino agenticclaude。
|
||||||
tmpCfg := &config.OpenAIConfig{
|
tmpCfg := &config.OpenAIConfig{
|
||||||
Provider: req.Provider,
|
Provider: req.Provider,
|
||||||
BaseURL: baseURL,
|
BaseURL: baseURL,
|
||||||
@@ -1197,6 +1231,28 @@ func (h *ConfigHandler) TestOpenAI(c *gin.Context) {
|
|||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
start := time.Now()
|
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 {
|
var chatResp struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Object string `json:"object"`
|
Object string `json:"object"`
|
||||||
@@ -2191,10 +2247,18 @@ func updateMultiAgentConfig(doc *yaml.Node, cfg config.MultiAgentConfig) {
|
|||||||
setIntInMap(mwNode, "latest_user_message_max_runes", cfg.EinoMiddleware.LatestUserMessageMaxRunesEffective())
|
setIntInMap(mwNode, "latest_user_message_max_runes", cfg.EinoMiddleware.LatestUserMessageMaxRunesEffective())
|
||||||
setIntInMap(mwNode, "latest_user_message_head_runes", cfg.EinoMiddleware.LatestUserMessageHeadRunesEffective())
|
setIntInMap(mwNode, "latest_user_message_head_runes", cfg.EinoMiddleware.LatestUserMessageHeadRunesEffective())
|
||||||
setIntInMap(mwNode, "latest_user_message_tail_runes", cfg.EinoMiddleware.LatestUserMessageTailRunesEffective())
|
setIntInMap(mwNode, "latest_user_message_tail_runes", cfg.EinoMiddleware.LatestUserMessageTailRunesEffective())
|
||||||
|
setIntInMap(mwNode, "model_retry_max_retries", cfg.EinoMiddleware.ModelRetryMaxRetries)
|
||||||
|
setIntInMap(mwNode, "model_retry_max_backoff_sec", cfg.EinoMiddleware.ModelRetryMaxBackoffSec)
|
||||||
|
setFlowStringSliceInMap(mwNode, "model_failover_channels", dedupeTrimmedStringList(cfg.EinoMiddleware.ModelFailoverChannels))
|
||||||
|
setIntInMap(mwNode, "model_failover_max_retries", cfg.EinoMiddleware.ModelFailoverMaxRetries)
|
||||||
setFlowStringSliceInMap(mwNode, "tool_search_always_visible_tools", dedupeToolNameList(cfg.EinoMiddleware.ToolSearchAlwaysVisibleTools))
|
setFlowStringSliceInMap(mwNode, "tool_search_always_visible_tools", dedupeToolNameList(cfg.EinoMiddleware.ToolSearchAlwaysVisibleTools))
|
||||||
}
|
}
|
||||||
|
|
||||||
func dedupeToolNameList(in []string) []string {
|
func dedupeToolNameList(in []string) []string {
|
||||||
|
return dedupeTrimmedStringList(in)
|
||||||
|
}
|
||||||
|
|
||||||
|
func dedupeTrimmedStringList(in []string) []string {
|
||||||
if len(in) == 0 {
|
if len(in) == 0 {
|
||||||
return []string{}
|
return []string{}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"cyberstrike-ai/internal/config"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestUpdateMultiAgentConfigWritesEinoModelResilience(t *testing.T) {
|
||||||
|
doc := &yaml.Node{
|
||||||
|
Kind: yaml.DocumentNode,
|
||||||
|
Content: []*yaml.Node{{
|
||||||
|
Kind: yaml.MappingNode,
|
||||||
|
Tag: "!!map",
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
|
||||||
|
updateMultiAgentConfig(doc, config.MultiAgentConfig{
|
||||||
|
Enabled: true,
|
||||||
|
RobotDefaultAgentMode: "deep",
|
||||||
|
PlanExecuteLoopMaxIterations: 3,
|
||||||
|
EinoMiddleware: config.MultiAgentEinoMiddlewareConfig{
|
||||||
|
ModelRetryMaxRetries: 5,
|
||||||
|
ModelRetryMaxBackoffSec: 45,
|
||||||
|
ModelFailoverChannels: []string{"backup-openai", "backup-claude", "backup-openai"},
|
||||||
|
ModelFailoverMaxRetries: 2,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
var got struct {
|
||||||
|
MultiAgent struct {
|
||||||
|
EinoMiddleware struct {
|
||||||
|
ModelRetryMaxRetries int `yaml:"model_retry_max_retries"`
|
||||||
|
ModelRetryMaxBackoffSec int `yaml:"model_retry_max_backoff_sec"`
|
||||||
|
ModelFailoverChannels []string `yaml:"model_failover_channels"`
|
||||||
|
ModelFailoverMaxRetries int `yaml:"model_failover_max_retries"`
|
||||||
|
} `yaml:"eino_middleware"`
|
||||||
|
} `yaml:"multi_agent"`
|
||||||
|
}
|
||||||
|
if err := doc.Decode(&got); err != nil {
|
||||||
|
t.Fatalf("decode config yaml: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mw := got.MultiAgent.EinoMiddleware
|
||||||
|
if mw.ModelRetryMaxRetries != 5 {
|
||||||
|
t.Fatalf("model_retry_max_retries = %d, want 5", mw.ModelRetryMaxRetries)
|
||||||
|
}
|
||||||
|
if mw.ModelRetryMaxBackoffSec != 45 {
|
||||||
|
t.Fatalf("model_retry_max_backoff_sec = %d, want 45", mw.ModelRetryMaxBackoffSec)
|
||||||
|
}
|
||||||
|
if mw.ModelFailoverMaxRetries != 2 {
|
||||||
|
t.Fatalf("model_failover_max_retries = %d, want 2", mw.ModelFailoverMaxRetries)
|
||||||
|
}
|
||||||
|
wantChannels := []string{"backup-openai", "backup-claude"}
|
||||||
|
if len(mw.ModelFailoverChannels) != len(wantChannels) {
|
||||||
|
t.Fatalf("model_failover_channels = %#v, want %#v", mw.ModelFailoverChannels, wantChannels)
|
||||||
|
}
|
||||||
|
for i, want := range wantChannels {
|
||||||
|
if mw.ModelFailoverChannels[i] != want {
|
||||||
|
t.Fatalf("model_failover_channels[%d] = %q, want %q", i, mw.ModelFailoverChannels[i], want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,9 +2,11 @@ package handler
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"cyberstrike-ai/internal/audit"
|
"cyberstrike-ai/internal/audit"
|
||||||
"cyberstrike-ai/internal/database"
|
"cyberstrike-ai/internal/database"
|
||||||
@@ -18,12 +20,20 @@ type ConversationTaskStopper interface {
|
|||||||
CancelRunningTaskForConversation(conversationID string)
|
CancelRunningTaskForConversation(conversationID string)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ConversationTaskStateProvider reports whether the in-memory agent task for
|
||||||
|
// a conversation is still genuinely running. Plan files may survive a service
|
||||||
|
// restart or cancellation, so their status alone is not authoritative.
|
||||||
|
type ConversationTaskStateProvider interface {
|
||||||
|
ConversationTaskRuntimeState(conversationID string) (running bool, startedAt time.Time)
|
||||||
|
}
|
||||||
|
|
||||||
// ConversationHandler 对话处理器
|
// ConversationHandler 对话处理器
|
||||||
type ConversationHandler struct {
|
type ConversationHandler struct {
|
||||||
db *database.DB
|
db *database.DB
|
||||||
logger *zap.Logger
|
logger *zap.Logger
|
||||||
audit *audit.Service
|
audit *audit.Service
|
||||||
taskStopper ConversationTaskStopper
|
taskStopper ConversationTaskStopper
|
||||||
|
taskState ConversationTaskStateProvider
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetAudit wires platform audit logging.
|
// SetAudit wires platform audit logging.
|
||||||
@@ -36,6 +46,12 @@ func (h *ConversationHandler) SetTaskStopper(stopper ConversationTaskStopper) {
|
|||||||
h.taskStopper = stopper
|
h.taskStopper = stopper
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetTaskStateProvider wires the live agent task registry used by supplemental
|
||||||
|
// conversation UI such as the agent-maintained plan list.
|
||||||
|
func (h *ConversationHandler) SetTaskStateProvider(provider ConversationTaskStateProvider) {
|
||||||
|
h.taskState = provider
|
||||||
|
}
|
||||||
|
|
||||||
// NewConversationHandler 创建新的对话处理器
|
// NewConversationHandler 创建新的对话处理器
|
||||||
func NewConversationHandler(db *database.DB, logger *zap.Logger) *ConversationHandler {
|
func NewConversationHandler(db *database.DB, logger *zap.Logger) *ConversationHandler {
|
||||||
return &ConversationHandler{
|
return &ConversationHandler{
|
||||||
@@ -206,6 +222,70 @@ func (h *ConversationHandler) GetConversation(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, conv)
|
c.JSON(http.StatusOK, conv)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetConversationPlanTasks returns the task list maintained by the agent's
|
||||||
|
// TaskCreate/TaskUpdate tools for this conversation.
|
||||||
|
func (h *ConversationHandler) GetConversationPlanTasks(c *gin.Context) {
|
||||||
|
id := strings.TrimSpace(c.Param("id"))
|
||||||
|
session, ok := security.CurrentSession(c)
|
||||||
|
if !ok || !h.db.UserCanAccessResource(session.UserID, session.Scope, "conversation", id) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该对话"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := h.db.GetConversationLite(id); err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "对话不存在"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
running := false
|
||||||
|
startedAt := time.Time{}
|
||||||
|
if h.taskState != nil {
|
||||||
|
running, startedAt = h.taskState.ConversationTaskRuntimeState(id)
|
||||||
|
}
|
||||||
|
if !running {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"tasks": []database.ConversationPlanTask{}, "total": 0,
|
||||||
|
"completed": 0, "activeStep": 0, "running": false,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tasks, err := h.db.ListConversationPlanTasksSince(id, startedAt)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("获取对话任务列表失败", zap.String("conversationId", id), zap.Error(err))
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取任务列表失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
completed := 0
|
||||||
|
activeStep := 0
|
||||||
|
for i, task := range tasks {
|
||||||
|
status := strings.ToLower(strings.TrimSpace(task.Status))
|
||||||
|
if status == "completed" {
|
||||||
|
completed++
|
||||||
|
}
|
||||||
|
if activeStep == 0 && status == "in_progress" {
|
||||||
|
activeStep = i + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if activeStep == 0 {
|
||||||
|
for i, task := range tasks {
|
||||||
|
if strings.ToLower(strings.TrimSpace(task.Status)) != "completed" {
|
||||||
|
activeStep = i + 1
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if activeStep == 0 && len(tasks) > 0 {
|
||||||
|
activeStep = len(tasks)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"tasks": tasks,
|
||||||
|
"total": len(tasks),
|
||||||
|
"completed": completed,
|
||||||
|
"activeStep": activeStep,
|
||||||
|
"running": true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
defaultProcessDetailsPageLimit = 50
|
defaultProcessDetailsPageLimit = 50
|
||||||
maxProcessDetailsPageLimit = 500
|
maxProcessDetailsPageLimit = 500
|
||||||
@@ -246,7 +326,7 @@ func (h *ConversationHandler) GetMessageProcessDetails(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
details = database.DedupeConsecutiveProcessDetails(details)
|
details = database.DedupeConsecutiveProcessDetails(details)
|
||||||
out := processDetailsToJSON(h.logger, details, true)
|
out := processDetailsToJSON(h.logger, h.db, details, true)
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"processDetails": out,
|
"processDetails": out,
|
||||||
"total": len(out),
|
"total": len(out),
|
||||||
@@ -295,7 +375,7 @@ func (h *ConversationHandler) GetMessageProcessDetails(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
details = database.DedupeConsecutiveProcessDetails(details)
|
details = database.DedupeConsecutiveProcessDetails(details)
|
||||||
out := processDetailsToJSON(h.logger, details, false)
|
out := processDetailsToJSON(h.logger, h.db, details, false)
|
||||||
// A page may end between tool_call and tool_result. Return the full-history
|
// A page may end between tool_call and tool_result. Return the full-history
|
||||||
// execution summary so the UI can render terminal status without pretending
|
// execution summary so the UI can render terminal status without pretending
|
||||||
// that an unloaded result is still running.
|
// that an unloaded result is still running.
|
||||||
@@ -330,7 +410,7 @@ func (h *ConversationHandler) GetProcessDetail(c *gin.Context) {
|
|||||||
c.JSON(http.StatusNotFound, gin.H{"error": "过程详情不存在"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "过程详情不存在"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
out := processDetailsToJSON(h.logger, []database.ProcessDetail{*detail}, true)
|
out := processDetailsToJSON(h.logger, h.db, []database.ProcessDetail{*detail}, true)
|
||||||
if len(out) == 0 {
|
if len(out) == 0 {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "过程详情不存在"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "过程详情不存在"})
|
||||||
return
|
return
|
||||||
@@ -338,7 +418,7 @@ func (h *ConversationHandler) GetProcessDetail(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"processDetail": out[0]})
|
c.JSON(http.StatusOK, gin.H{"processDetail": out[0]})
|
||||||
}
|
}
|
||||||
|
|
||||||
func processDetailsToJSON(logger *zap.Logger, details []database.ProcessDetail, includeToolPayload bool) []map[string]interface{} {
|
func processDetailsToJSON(logger *zap.Logger, db *database.DB, details []database.ProcessDetail, includeToolPayload bool) []map[string]interface{} {
|
||||||
out := make([]map[string]interface{}, 0, len(details))
|
out := make([]map[string]interface{}, 0, len(details))
|
||||||
for _, d := range details {
|
for _, d := range details {
|
||||||
var data interface{}
|
var data interface{}
|
||||||
@@ -347,6 +427,9 @@ func processDetailsToJSON(logger *zap.Logger, details []database.ProcessDetail,
|
|||||||
logger.Warn("解析过程详情数据失败", zap.Error(err))
|
logger.Warn("解析过程详情数据失败", zap.Error(err))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if m, ok := data.(map[string]interface{}); ok {
|
||||||
|
enrichEmptyToolCallArgumentsFromExecution(logger, db, d, m)
|
||||||
|
}
|
||||||
if !includeToolPayload {
|
if !includeToolPayload {
|
||||||
data = summarizeProcessDetailData(d.EventType, data)
|
data = summarizeProcessDetailData(d.EventType, data)
|
||||||
}
|
}
|
||||||
@@ -363,6 +446,50 @@ func processDetailsToJSON(logger *zap.Logger, details []database.ProcessDetail,
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func enrichEmptyToolCallArgumentsFromExecution(logger *zap.Logger, db *database.DB, detail database.ProcessDetail, data map[string]interface{}) {
|
||||||
|
if db == nil || detail.EventType != "tool_call" || !toolCallArgumentsEmpty(data) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
toolName := strings.TrimSpace(fmt.Sprint(data["toolName"]))
|
||||||
|
if toolName == "" || detail.ConversationID == "" || detail.CreatedAt.IsZero() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
execID, args, err := db.FindNearestToolExecutionArguments(detail.ConversationID, toolName, detail.CreatedAt, 5*time.Second)
|
||||||
|
if err != nil {
|
||||||
|
if logger != nil {
|
||||||
|
logger.Debug("未能从工具执行记录补全过程详情参数",
|
||||||
|
zap.Error(err),
|
||||||
|
zap.String("processDetailId", detail.ID),
|
||||||
|
zap.String("toolName", toolName))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(args) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data["argumentsObj"] = args
|
||||||
|
if b, err := json.Marshal(args); err == nil {
|
||||||
|
data["arguments"] = string(b)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(execID) != "" {
|
||||||
|
data["executionId"] = strings.TrimSpace(execID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func toolCallArgumentsEmpty(data map[string]interface{}) bool {
|
||||||
|
if data == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if args, ok := data["argumentsObj"].(map[string]interface{}); ok && len(args) > 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if raw, ok := data["arguments"]; ok {
|
||||||
|
s := strings.TrimSpace(fmt.Sprint(raw))
|
||||||
|
return s == "" || s == "{}" || s == "null"
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
func summarizeProcessDetailData(eventType string, data interface{}) interface{} {
|
func summarizeProcessDetailData(eventType string, data interface{}) interface{} {
|
||||||
m, ok := data.(map[string]interface{})
|
m, ok := data.(map[string]interface{})
|
||||||
if !ok || (eventType != "tool_call" && eventType != "tool_result") {
|
if !ok || (eventType != "tool_call" && eventType != "tool_result") {
|
||||||
@@ -370,10 +497,11 @@ func summarizeProcessDetailData(eventType string, data interface{}) interface{}
|
|||||||
}
|
}
|
||||||
allow := map[string]bool{
|
allow := map[string]bool{
|
||||||
"toolName": true, "toolCallId": true, "index": true, "total": true,
|
"toolName": true, "toolCallId": true, "index": true, "total": true,
|
||||||
|
"arguments": true, "argumentsObj": true,
|
||||||
"success": true, "isError": true, "executionId": true,
|
"success": true, "isError": true, "executionId": true,
|
||||||
"einoAgent": true, "einoRole": true, "einoScope": true, "orchestration": true,
|
"einoAgent": true, "einoRole": true, "einoScope": true, "orchestration": true,
|
||||||
"agentFacing": true,
|
"agentFacing": true,
|
||||||
"status": true, "modelFacingIsError": true, "resultPreview": true,
|
"status": true, "modelFacingIsError": true, "resultPreview": true,
|
||||||
}
|
}
|
||||||
out := make(map[string]interface{}, len(allow)+1)
|
out := make(map[string]interface{}, len(allow)+1)
|
||||||
for k, v := range m {
|
for k, v := range m {
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"cyberstrike-ai/internal/database"
|
||||||
|
"cyberstrike-ai/internal/security"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
type staticConversationTaskState struct {
|
||||||
|
running bool
|
||||||
|
startedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s staticConversationTaskState) ConversationTaskRuntimeState(string) (bool, time.Time) {
|
||||||
|
return s.running, s.startedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetConversationPlanTasksRequiresAccessAndReportsProgress(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
tmp := t.TempDir()
|
||||||
|
db, err := database.NewDB(filepath.Join(tmp, "conversation-plantask.db"), zap.NewNop())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewDB: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = db.Close() })
|
||||||
|
conversation, err := db.CreateConversation("plan", database.ConversationCreateMeta{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateConversation: %v", err)
|
||||||
|
}
|
||||||
|
user, err := db.CreateRBACUser("plan-user", "Plan User", "hash", true, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateRBACUser: %v", err)
|
||||||
|
}
|
||||||
|
base := filepath.Join(tmp, "plantask")
|
||||||
|
db.SetEinoConversationDirs(base, "", "", "")
|
||||||
|
dir := filepath.Join(base, conversation.ID)
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
t.Fatalf("MkdirAll: %v", err)
|
||||||
|
}
|
||||||
|
for name, content := range map[string]string{
|
||||||
|
"1.json": `{"id":"1","subject":"完成项","status":"completed"}`,
|
||||||
|
"2.json": `{"id":"2","subject":"当前项","status":"in_progress"}`,
|
||||||
|
"3.json": `{"id":"3","subject":"等待项","status":"pending"}`,
|
||||||
|
} {
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644); err != nil {
|
||||||
|
t.Fatalf("WriteFile: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := NewConversationHandler(db, zap.NewNop())
|
||||||
|
handler.SetTaskStateProvider(staticConversationTaskState{running: true})
|
||||||
|
request := func() *httptest.ResponseRecorder {
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(w)
|
||||||
|
c.Request = httptest.NewRequest(http.MethodGet, "/api/conversations/"+conversation.ID+"/plan-tasks", nil)
|
||||||
|
c.Params = gin.Params{{Key: "id", Value: conversation.ID}}
|
||||||
|
c.Set(security.ContextSessionKey, security.Session{
|
||||||
|
UserID: user.ID,
|
||||||
|
Scope: database.RBACScopeAssigned,
|
||||||
|
})
|
||||||
|
handler.GetConversationPlanTasks(c)
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
w := request()
|
||||||
|
if w.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("unassigned status = %d, want %d", w.Code, http.StatusForbidden)
|
||||||
|
}
|
||||||
|
if err := db.AssignResourceToUser(user.ID, "conversation", conversation.ID); err != nil {
|
||||||
|
t.Fatalf("AssignResourceToUser: %v", err)
|
||||||
|
}
|
||||||
|
w = request()
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("assigned status = %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
var response struct {
|
||||||
|
Total int `json:"total"`
|
||||||
|
Completed int `json:"completed"`
|
||||||
|
ActiveStep int `json:"activeStep"`
|
||||||
|
Tasks []database.ConversationPlanTask `json:"tasks"`
|
||||||
|
Running bool `json:"running"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
|
||||||
|
t.Fatalf("decode response: %v", err)
|
||||||
|
}
|
||||||
|
if response.Total != 3 || response.Completed != 1 || response.ActiveStep != 2 || !response.Running {
|
||||||
|
t.Fatalf("progress = %#v", response)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetConversationPlanTasksReportsStoppedLiveTask(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
tmp := t.TempDir()
|
||||||
|
db, err := database.NewDB(filepath.Join(tmp, "conversation-plantask-stopped.db"), zap.NewNop())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewDB: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = db.Close() })
|
||||||
|
conversation, err := db.CreateConversation("stopped plan", database.ConversationCreateMeta{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateConversation: %v", err)
|
||||||
|
}
|
||||||
|
user, err := db.CreateRBACUser("stopped-plan-user", "Stopped Plan User", "hash", true, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateRBACUser: %v", err)
|
||||||
|
}
|
||||||
|
if err := db.AssignResourceToUser(user.ID, "conversation", conversation.ID); err != nil {
|
||||||
|
t.Fatalf("AssignResourceToUser: %v", err)
|
||||||
|
}
|
||||||
|
base := filepath.Join(tmp, "plantask")
|
||||||
|
db.SetEinoConversationDirs(base, "", "", "")
|
||||||
|
dir := filepath.Join(base, conversation.ID)
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
t.Fatalf("MkdirAll: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "1.json"), []byte(`{"id":"1","subject":"残留项","status":"in_progress"}`), 0o644); err != nil {
|
||||||
|
t.Fatalf("WriteFile: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := NewConversationHandler(db, zap.NewNop())
|
||||||
|
handler.SetTaskStateProvider(staticConversationTaskState{running: false})
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(w)
|
||||||
|
c.Request = httptest.NewRequest(http.MethodGet, "/api/conversations/"+conversation.ID+"/plan-tasks", nil)
|
||||||
|
c.Params = gin.Params{{Key: "id", Value: conversation.ID}}
|
||||||
|
c.Set(security.ContextSessionKey, security.Session{UserID: user.ID, Scope: database.RBACScopeAssigned})
|
||||||
|
handler.GetConversationPlanTasks(c)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
var response struct {
|
||||||
|
Running bool `json:"running"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
|
||||||
|
t.Fatalf("decode response: %v", err)
|
||||||
|
}
|
||||||
|
if response.Running || response.Total != 0 {
|
||||||
|
t.Fatalf("response = %#v", response)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,8 +6,10 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"cyberstrike-ai/internal/database"
|
"cyberstrike-ai/internal/database"
|
||||||
|
"cyberstrike-ai/internal/mcp"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
@@ -73,3 +75,131 @@ func TestProcessDetailsPageIncludesTerminalToolStatusAcrossPageBoundary(t *testi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestProcessDetailsFullBackfillsEmptyToolCallArgumentsFromExecution(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
db, err := database.NewDB(filepath.Join(t.TempDir(), "process-details-args.db"), zap.NewNop())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewDB: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = db.Close() })
|
||||||
|
conversation, err := db.CreateConversation("empty args", database.ConversationCreateMeta{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateConversation: %v", err)
|
||||||
|
}
|
||||||
|
message, err := db.AddMessage(conversation.ID, "assistant", "done", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("AddMessage: %v", err)
|
||||||
|
}
|
||||||
|
if err := db.AddProcessDetail(message.ID, conversation.ID, "tool_call", "calling exec", map[string]interface{}{
|
||||||
|
"toolName": "exec", "toolCallId": "call-empty", "arguments": "", "argumentsObj": nil,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("AddProcessDetail(tool_call): %v", err)
|
||||||
|
}
|
||||||
|
if err := db.SaveToolExecution(&mcp.ToolExecution{
|
||||||
|
ID: "exec-whoami",
|
||||||
|
ToolName: "exec",
|
||||||
|
Arguments: map[string]interface{}{"command": "whoami"},
|
||||||
|
Status: "completed",
|
||||||
|
StartTime: time.Now(),
|
||||||
|
ConversationID: conversation.ID,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("SaveToolExecution: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(w)
|
||||||
|
c.Request = httptest.NewRequest("GET", "/api/messages/"+message.ID+"/process-details?full=1", nil)
|
||||||
|
c.Params = gin.Params{{Key: "id", Value: message.ID}}
|
||||||
|
NewConversationHandler(db, zap.NewNop()).GetMessageProcessDetails(c)
|
||||||
|
if w.Code != 200 {
|
||||||
|
t.Fatalf("status = %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
var response struct {
|
||||||
|
ProcessDetails []map[string]interface{} `json:"processDetails"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
|
||||||
|
t.Fatalf("decode response: %v", err)
|
||||||
|
}
|
||||||
|
if len(response.ProcessDetails) != 1 {
|
||||||
|
t.Fatalf("process details = %d, want 1", len(response.ProcessDetails))
|
||||||
|
}
|
||||||
|
data, ok := response.ProcessDetails[0]["data"].(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("data = %#v", response.ProcessDetails[0]["data"])
|
||||||
|
}
|
||||||
|
args, ok := data["argumentsObj"].(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("argumentsObj = %#v", data["argumentsObj"])
|
||||||
|
}
|
||||||
|
if args["command"] != "whoami" {
|
||||||
|
t.Fatalf("command = %#v, want whoami", args["command"])
|
||||||
|
}
|
||||||
|
if data["executionId"] != "exec-whoami" {
|
||||||
|
t.Fatalf("executionId = %#v, want exec-whoami", data["executionId"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProcessDetailsPageBackfillsEinoFilesystemArgumentsFromPrefixedExecution(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
db, err := database.NewDB(filepath.Join(t.TempDir(), "process-details-eino-fs-args.db"), zap.NewNop())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewDB: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = db.Close() })
|
||||||
|
conversation, err := db.CreateConversation("eino fs args", database.ConversationCreateMeta{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateConversation: %v", err)
|
||||||
|
}
|
||||||
|
message, err := db.AddMessage(conversation.ID, "assistant", "done", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("AddMessage: %v", err)
|
||||||
|
}
|
||||||
|
if err := db.AddProcessDetail(message.ID, conversation.ID, "tool_call", "calling read_file", map[string]interface{}{
|
||||||
|
"toolName": "read_file", "toolCallId": "call-read", "arguments": "", "argumentsObj": nil,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("AddProcessDetail(tool_call): %v", err)
|
||||||
|
}
|
||||||
|
if err := db.SaveToolExecution(&mcp.ToolExecution{
|
||||||
|
ID: "exec-read",
|
||||||
|
ToolName: "eino_fs::read_file",
|
||||||
|
Arguments: map[string]interface{}{"file_path": "/tmp/requirements.txt", "limit": float64(2000)},
|
||||||
|
Status: "completed",
|
||||||
|
StartTime: time.Now(),
|
||||||
|
ConversationID: conversation.ID,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("SaveToolExecution: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(w)
|
||||||
|
c.Request = httptest.NewRequest("GET", "/api/messages/"+message.ID+"/process-details?limit=50&offset=0", nil)
|
||||||
|
c.Params = gin.Params{{Key: "id", Value: message.ID}}
|
||||||
|
NewConversationHandler(db, zap.NewNop()).GetMessageProcessDetails(c)
|
||||||
|
if w.Code != 200 {
|
||||||
|
t.Fatalf("status = %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
var response struct {
|
||||||
|
ProcessDetails []map[string]interface{} `json:"processDetails"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
|
||||||
|
t.Fatalf("decode response: %v", err)
|
||||||
|
}
|
||||||
|
if len(response.ProcessDetails) != 1 {
|
||||||
|
t.Fatalf("process details = %d, want 1", len(response.ProcessDetails))
|
||||||
|
}
|
||||||
|
data, ok := response.ProcessDetails[0]["data"].(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("data = %#v", response.ProcessDetails[0]["data"])
|
||||||
|
}
|
||||||
|
args, ok := data["argumentsObj"].(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("argumentsObj = %#v", data["argumentsObj"])
|
||||||
|
}
|
||||||
|
if args["file_path"] != "/tmp/requirements.txt" {
|
||||||
|
t.Fatalf("file_path = %#v, want /tmp/requirements.txt", args["file_path"])
|
||||||
|
}
|
||||||
|
if data["arguments"] == nil {
|
||||||
|
t.Fatalf("arguments should be preserved in summarized page data: %#v", data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -68,15 +68,15 @@ func (h *AgentHandler) tryContinueOnEinoEmptyResponse(
|
|||||||
case <-time.After(backoff):
|
case <-time.After(backoff):
|
||||||
}
|
}
|
||||||
|
|
||||||
inject := multiagent.FormatEmptyResponseContinueUserMessage()
|
h.applyEinoTraceResumeSegment(conversationID, result, curHistory, curFinalMessage, "")
|
||||||
h.applyEinoTraceResumeSegment(conversationID, result, curHistory, curFinalMessage, inject)
|
|
||||||
if progressCallback != nil {
|
if progressCallback != nil {
|
||||||
progressCallback("eino_empty_response_continue", "已恢复上下文,正在续跑…", map[string]interface{}{
|
progressCallback("eino_empty_response_continue", "已恢复上下文,正在续跑…", map[string]interface{}{
|
||||||
"conversationId": conversationID,
|
"conversationId": conversationID,
|
||||||
"source": "eino",
|
"source": "eino",
|
||||||
"attempt": *attempt,
|
"attempt": *attempt,
|
||||||
"maxAttempts": maxAttempts,
|
"maxAttempts": maxAttempts,
|
||||||
"contextSource": "empty_response_continue",
|
"contextSource": "empty_response_continue",
|
||||||
|
"contextInjection": false,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"cyberstrike-ai/internal/agentfinalizer"
|
||||||
"cyberstrike-ai/internal/mcp"
|
"cyberstrike-ai/internal/mcp"
|
||||||
"cyberstrike-ai/internal/multiagent"
|
"cyberstrike-ai/internal/multiagent"
|
||||||
|
|
||||||
@@ -189,6 +190,8 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
|
|||||||
// 同一请求内分段续跑时,主代理 iteration 事件按偏移累计,避免 UI 出现「第3轮 → 第1轮」回跳。
|
// 同一请求内分段续跑时,主代理 iteration 事件按偏移累计,避免 UI 出现「第3轮 → 第1轮」回跳。
|
||||||
var mainIterationOffset int
|
var mainIterationOffset int
|
||||||
var emptyResponseContinueAttempt int
|
var emptyResponseContinueAttempt int
|
||||||
|
var finalizationAutoContinueAttempt int
|
||||||
|
var decision agentfinalizer.Decision
|
||||||
|
|
||||||
for {
|
for {
|
||||||
segmentMainIterationMax := 0
|
segmentMainIterationMax := 0
|
||||||
@@ -224,6 +227,12 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
|
|||||||
taskCtxLoop := mcp.WithMCPConversationID(taskCtx, conversationID)
|
taskCtxLoop := mcp.WithMCPConversationID(taskCtx, conversationID)
|
||||||
taskCtxLoop = mcp.WithToolRunRegistry(taskCtxLoop, h.tasks)
|
taskCtxLoop = mcp.WithToolRunRegistry(taskCtxLoop, h.tasks)
|
||||||
taskCtxLoop = mcp.WithEinoExecuteRunRegistry(taskCtxLoop, h.tasks)
|
taskCtxLoop = mcp.WithEinoExecuteRunRegistry(taskCtxLoop, h.tasks)
|
||||||
|
taskCtxLoop = multiagent.WithAgentRuntimeCancelRegistrar(taskCtxLoop, func(cancel func(error) bool) func() {
|
||||||
|
return h.tasks.BindAgentRuntimeCancel(conversationID, cancel)
|
||||||
|
})
|
||||||
|
taskCtxLoop = multiagent.WithAgentTurnLoopInterruptRegistrar(taskCtxLoop, func(push func(string) bool) func() {
|
||||||
|
return h.tasks.BindAgentTurnLoopInterrupt(conversationID, push)
|
||||||
|
})
|
||||||
taskCtxLoop = multiagent.WithHITLToolInterceptor(taskCtxLoop, func(ctx context.Context, toolName, arguments string) (string, error) {
|
taskCtxLoop = multiagent.WithHITLToolInterceptor(taskCtxLoop, func(ctx context.Context, toolName, arguments string) (string, error) {
|
||||||
return h.interceptHITLForEinoTool(ctx, cancelWithCause, conversationID, assistantMessageID, sendEvent, toolName, arguments)
|
return h.interceptHITLForEinoTool(ctx, cancelWithCause, conversationID, assistantMessageID, sendEvent, toolName, arguments)
|
||||||
})
|
})
|
||||||
@@ -258,11 +267,26 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
|
|||||||
baseCtx, cancelWithCause, taskCtx, timeoutCancel = h.rebindEinoRunningTask(taskCtx, conversationID, timeoutCancel)
|
baseCtx, cancelWithCause, taskCtx, timeoutCancel = h.rebindEinoRunningTask(taskCtx, conversationID, timeoutCancel)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
decision = h.decideAgentRunForDeliveryWithPolicy(conversationID, assistantMessageID, "eino_single", result, cumulativeMCPExecutionIDs, requestRequiresExecutionEvidence(&req))
|
||||||
|
if h.tryAutoContinueAfterFinalization(taskCtx, conversationID, result, decision, &finalizationAutoContinueAttempt, &curHistory, &curFinalMessage, progressCallback) {
|
||||||
|
mainIterationOffset += segmentMainIterationMax
|
||||||
|
timeoutCancel()
|
||||||
|
baseCtx, cancelWithCause, taskCtx, timeoutCancel = h.rebindEinoRunningTask(taskCtx, conversationID, timeoutCancel)
|
||||||
|
continue
|
||||||
|
}
|
||||||
timeoutCancel()
|
timeoutCancel()
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
cause := context.Cause(baseCtx)
|
cause := context.Cause(baseCtx)
|
||||||
|
if cause == nil {
|
||||||
|
switch {
|
||||||
|
case errors.Is(runErr, multiagent.ErrInterruptContinue):
|
||||||
|
cause = multiagent.ErrInterruptContinue
|
||||||
|
case errors.Is(runErr, ErrTaskCancelled):
|
||||||
|
cause = ErrTaskCancelled
|
||||||
|
}
|
||||||
|
}
|
||||||
if errors.Is(cause, multiagent.ErrInterruptContinue) {
|
if errors.Is(cause, multiagent.ErrInterruptContinue) {
|
||||||
if shouldPersistEinoAgentTraceAfterRunError(baseCtx) {
|
if shouldPersistEinoAgentTraceAfterRunError(baseCtx) {
|
||||||
h.persistEinoAgentTraceForResume(conversationID, result)
|
h.persistEinoAgentTraceForResume(conversationID, result)
|
||||||
@@ -358,9 +382,10 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
|
|||||||
|
|
||||||
timeoutCancel()
|
timeoutCancel()
|
||||||
|
|
||||||
if assistantMessageID != "" {
|
if decision.CompletionReason == "" {
|
||||||
_ = h.db.UpdateAssistantMessageFinalize(assistantMessageID, result.Response, cumulativeMCPExecutionIDs, multiagent.AggregatedReasoningFromTraceJSON(result.LastAgentTraceInput))
|
decision = h.decideAgentRunForDeliveryWithPolicy(conversationID, assistantMessageID, "eino_single", result, cumulativeMCPExecutionIDs, requestRequiresExecutionEvidence(&req))
|
||||||
}
|
}
|
||||||
|
h.persistFinalizationDecision(conversationID, assistantMessageID, "eino_single", cumulativeMCPExecutionIDs, multiagent.AggregatedReasoningFromTraceJSON(result.LastAgentTraceInput), decision)
|
||||||
|
|
||||||
if result.LastAgentTraceInput != "" || result.LastAgentTraceOutput != "" {
|
if result.LastAgentTraceInput != "" || result.LastAgentTraceOutput != "" {
|
||||||
if err := h.db.SaveAgentTrace(conversationID, result.LastAgentTraceInput, result.LastAgentTraceOutput); err != nil {
|
if err := h.db.SaveAgentTrace(conversationID, result.LastAgentTraceInput, result.LastAgentTraceOutput); err != nil {
|
||||||
@@ -368,12 +393,19 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
sendEvent("response", result.Response, map[string]interface{}{
|
responseText := decision.FinalText
|
||||||
|
if !decision.Finalizable {
|
||||||
|
responseText = finalizationBlockedMessage(decision)
|
||||||
|
sendEvent("finalization_check", responseText, decision)
|
||||||
|
taskStatus = decision.Status
|
||||||
|
h.tasks.UpdateTaskStatus(conversationID, taskStatus)
|
||||||
|
}
|
||||||
|
sendEvent("response", responseText, finalizationResponsePayload(decision, map[string]interface{}{
|
||||||
"mcpExecutionIds": cumulativeMCPExecutionIDs,
|
"mcpExecutionIds": cumulativeMCPExecutionIDs,
|
||||||
"conversationId": conversationID,
|
"conversationId": conversationID,
|
||||||
"messageId": assistantMessageID,
|
"messageId": assistantMessageID,
|
||||||
"agentMode": "eino_single",
|
"agentMode": "eino_single",
|
||||||
})
|
}))
|
||||||
sendEvent("done", "", map[string]interface{}{"conversationId": conversationID})
|
sendEvent("done", "", map[string]interface{}{"conversationId": conversationID})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -429,6 +461,9 @@ func (h *AgentHandler) EinoSingleAgentLoop(c *gin.Context) {
|
|||||||
curMsg := prep.FinalMessage
|
curMsg := prep.FinalMessage
|
||||||
var result *multiagent.RunResult
|
var result *multiagent.RunResult
|
||||||
var runErr error
|
var runErr error
|
||||||
|
var emptyResponseContinueAttempt int
|
||||||
|
var finalizationAutoContinueAttempt int
|
||||||
|
var decision agentfinalizer.Decision
|
||||||
for {
|
for {
|
||||||
result, runErr = multiagent.RunEinoSingleChatModelAgent(
|
result, runErr = multiagent.RunEinoSingleChatModelAgent(
|
||||||
taskCtx,
|
taskCtx,
|
||||||
@@ -446,28 +481,46 @@ func (h *AgentHandler) EinoSingleAgentLoop(c *gin.Context) {
|
|||||||
chatReasoningToClientIntent(req.Reasoning),
|
chatReasoningToClientIntent(req.Reasoning),
|
||||||
h.agentSessionContextBlock(prep.ConversationID),
|
h.agentSessionContextBlock(prep.ConversationID),
|
||||||
)
|
)
|
||||||
if runErr == nil {
|
if runErr != nil {
|
||||||
break
|
if shouldPersistEinoAgentTraceAfterRunError(baseCtx) {
|
||||||
|
h.persistEinoAgentTraceForResume(prep.ConversationID, result)
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": runErr.Error()})
|
||||||
|
return
|
||||||
}
|
}
|
||||||
if shouldPersistEinoAgentTraceAfterRunError(baseCtx) {
|
mw := &h.config.MultiAgent.EinoMiddleware
|
||||||
h.persistEinoAgentTraceForResume(prep.ConversationID, result)
|
if h.tryContinueOnEinoEmptyResponse(taskCtx, mw, prep.ConversationID, result, &emptyResponseContinueAttempt, &curHist, &curMsg, progressCallback) {
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": runErr.Error()})
|
decision = h.decideAgentRunForDeliveryWithPolicy(prep.ConversationID, prep.AssistantMessageID, "eino_single", result, result.MCPExecutionIDs, requestRequiresExecutionEvidence(&req))
|
||||||
return
|
if h.tryAutoContinueAfterFinalization(taskCtx, prep.ConversationID, result, decision, &finalizationAutoContinueAttempt, &curHist, &curMsg, progressCallback) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
if prep.AssistantMessageID != "" {
|
h.persistFinalizationDecision(prep.ConversationID, prep.AssistantMessageID, "eino_single", result.MCPExecutionIDs, multiagent.AggregatedReasoningFromTraceJSON(result.LastAgentTraceInput), decision)
|
||||||
_ = h.db.UpdateAssistantMessageFinalize(prep.AssistantMessageID, result.Response, result.MCPExecutionIDs, multiagent.AggregatedReasoningFromTraceJSON(result.LastAgentTraceInput))
|
|
||||||
}
|
|
||||||
if result.LastAgentTraceInput != "" || result.LastAgentTraceOutput != "" {
|
if result.LastAgentTraceInput != "" || result.LastAgentTraceOutput != "" {
|
||||||
_ = h.db.SaveAgentTrace(prep.ConversationID, result.LastAgentTraceInput, result.LastAgentTraceOutput)
|
_ = h.db.SaveAgentTrace(prep.ConversationID, result.LastAgentTraceInput, result.LastAgentTraceOutput)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
responseText := decision.FinalText
|
||||||
|
if !decision.Finalizable {
|
||||||
|
responseText = finalizationBlockedMessage(decision)
|
||||||
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"response": result.Response,
|
"response": responseText,
|
||||||
"conversationId": prep.ConversationID,
|
"conversationId": prep.ConversationID,
|
||||||
"mcpExecutionIds": result.MCPExecutionIDs,
|
"mcpExecutionIds": result.MCPExecutionIDs,
|
||||||
"assistantMessageId": prep.AssistantMessageID,
|
"assistantMessageId": prep.AssistantMessageID,
|
||||||
"agentMode": "eino_single",
|
"agentMode": "eino_single",
|
||||||
|
"finalized": decision.Finalized,
|
||||||
|
"finalizable": decision.Finalizable,
|
||||||
|
"status": decision.Status,
|
||||||
|
"completionReason": decision.CompletionReason,
|
||||||
|
"evidenceVerified": decision.EvidenceVerified,
|
||||||
|
"evidenceRefs": decision.EvidenceRefs,
|
||||||
|
"pendingExecutionIds": decision.PendingExecutionIDs,
|
||||||
|
"missingChecks": decision.MissingChecks,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"cyberstrike-ai/internal/agent"
|
||||||
|
"cyberstrike-ai/internal/agentfinalizer"
|
||||||
|
"cyberstrike-ai/internal/multiagent"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
const finalizationAutoContinueMaxAttempts = 2
|
||||||
|
|
||||||
|
func shouldAutoContinueAfterFinalization(d agentfinalizer.Decision, attempt int) bool {
|
||||||
|
if d.Finalizable || d.Finalized {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if attempt >= finalizationAutoContinueMaxAttempts {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return d.CompletionReason == agentfinalizer.ReasonMissingEvidence
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AgentHandler) tryAutoContinueAfterFinalization(
|
||||||
|
taskCtx context.Context,
|
||||||
|
conversationID string,
|
||||||
|
result *multiagent.RunResult,
|
||||||
|
decision agentfinalizer.Decision,
|
||||||
|
attempt *int,
|
||||||
|
curHistory *[]agent.ChatMessage,
|
||||||
|
curFinalMessage *string,
|
||||||
|
progressCallback func(eventType, message string, data interface{}),
|
||||||
|
) bool {
|
||||||
|
if !shouldAutoContinueAfterFinalization(decision, *attempt) || result == nil || !multiagent.HasEinoResumeTrace(result) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
*attempt++
|
||||||
|
h.persistEinoAgentTraceForResume(conversationID, result)
|
||||||
|
if hist, err := h.loadHistoryFromAgentTrace(conversationID); err == nil && len(hist) > 0 {
|
||||||
|
*curHistory = hist
|
||||||
|
} else if h.logger != nil {
|
||||||
|
h.logger.Warn("finalization auto-continue could not restore trace",
|
||||||
|
zap.String("conversationId", conversationID),
|
||||||
|
zap.Error(err))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// Agent 无感续跑:不追加新的 user/system 文案,只使用上一段模型可见轨迹继续 Runner。
|
||||||
|
*curFinalMessage = ""
|
||||||
|
if progressCallback != nil {
|
||||||
|
progressCallback("finalization_auto_continue", "最终回复检查尚未收敛,正在基于已有轨迹继续执行…", map[string]interface{}{
|
||||||
|
"conversationId": conversationID,
|
||||||
|
"source": "finalizer",
|
||||||
|
"attempt": *attempt,
|
||||||
|
"maxAttempts": finalizationAutoContinueMaxAttempts,
|
||||||
|
"status": decision.Status,
|
||||||
|
"completionReason": decision.CompletionReason,
|
||||||
|
"missingChecks": decision.MissingChecks,
|
||||||
|
"pendingExecutionIds": decision.PendingExecutionIDs,
|
||||||
|
"contextInjection": false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-taskCtx.Done():
|
||||||
|
return false
|
||||||
|
case <-time.After(finalizationAutoContinueBackoff(*attempt)):
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func finalizationAutoContinueBackoff(attempt int) time.Duration {
|
||||||
|
if attempt <= 1 {
|
||||||
|
return 500 * time.Millisecond
|
||||||
|
}
|
||||||
|
return time.Duration(attempt) * time.Second
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"cyberstrike-ai/internal/agentfinalizer"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestShouldAutoContinueAfterFinalization(t *testing.T) {
|
||||||
|
missingEvidence := agentfinalizer.Decision{
|
||||||
|
Status: agentfinalizer.StatusBlocked,
|
||||||
|
CompletionReason: agentfinalizer.ReasonMissingEvidence,
|
||||||
|
}
|
||||||
|
if !shouldAutoContinueAfterFinalization(missingEvidence, 0) {
|
||||||
|
t.Fatal("missing execution evidence should trigger auto-continue")
|
||||||
|
}
|
||||||
|
if shouldAutoContinueAfterFinalization(missingEvidence, finalizationAutoContinueMaxAttempts) {
|
||||||
|
t.Fatal("auto-continue should stop at max attempts")
|
||||||
|
}
|
||||||
|
|
||||||
|
finalized := agentfinalizer.Decision{
|
||||||
|
Status: agentfinalizer.StatusCompleted,
|
||||||
|
CompletionReason: agentfinalizer.ReasonVerified,
|
||||||
|
Finalizable: true,
|
||||||
|
Finalized: true,
|
||||||
|
}
|
||||||
|
if shouldAutoContinueAfterFinalization(finalized, 0) {
|
||||||
|
t.Fatal("finalized decision should not auto-continue")
|
||||||
|
}
|
||||||
|
|
||||||
|
awaitingHITL := agentfinalizer.Decision{
|
||||||
|
Status: agentfinalizer.StatusAwaitingHITL,
|
||||||
|
CompletionReason: agentfinalizer.ReasonAwaitingHITL,
|
||||||
|
}
|
||||||
|
if shouldAutoContinueAfterFinalization(awaitingHITL, 0) {
|
||||||
|
t.Fatal("awaiting HITL should not auto-continue without approval")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequestRequiresExecutionEvidenceUsesExplicitPolicyOnly(t *testing.T) {
|
||||||
|
if requestRequiresExecutionEvidence(nil) {
|
||||||
|
t.Fatal("nil request should not require execution evidence")
|
||||||
|
}
|
||||||
|
if requestRequiresExecutionEvidence(&ChatRequest{}) {
|
||||||
|
t.Fatal("missing finalization policy should not require execution evidence")
|
||||||
|
}
|
||||||
|
require := true
|
||||||
|
if !requestRequiresExecutionEvidence(&ChatRequest{
|
||||||
|
Finalization: ChatFinalizationRequest{RequireExecutionEvidence: &require},
|
||||||
|
}) {
|
||||||
|
t.Fatal("explicit true policy should require execution evidence")
|
||||||
|
}
|
||||||
|
require = false
|
||||||
|
if requestRequiresExecutionEvidence(&ChatRequest{
|
||||||
|
Finalization: ChatFinalizationRequest{RequireExecutionEvidence: &require},
|
||||||
|
}) {
|
||||||
|
t.Fatal("explicit false policy should not require execution evidence")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"cyberstrike-ai/internal/agentfinalizer"
|
||||||
|
"cyberstrike-ai/internal/multiagent"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (h *AgentHandler) finalizeAgentRunForDelivery(
|
||||||
|
conversationID string,
|
||||||
|
assistantMessageID string,
|
||||||
|
agentMode string,
|
||||||
|
result *multiagent.RunResult,
|
||||||
|
mcpExecutionIDs []string,
|
||||||
|
reasoningContent string,
|
||||||
|
) agentfinalizer.Decision {
|
||||||
|
return h.finalizeAgentRunForDeliveryWithPolicy(conversationID, assistantMessageID, agentMode, result, mcpExecutionIDs, reasoningContent, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AgentHandler) finalizeAgentRunForDeliveryWithPolicy(
|
||||||
|
conversationID string,
|
||||||
|
assistantMessageID string,
|
||||||
|
agentMode string,
|
||||||
|
result *multiagent.RunResult,
|
||||||
|
mcpExecutionIDs []string,
|
||||||
|
reasoningContent string,
|
||||||
|
requireExecutionEvidence bool,
|
||||||
|
) agentfinalizer.Decision {
|
||||||
|
decision := agentfinalizer.FromRunResult(h.db, result, agentfinalizer.Input{
|
||||||
|
ConversationID: conversationID,
|
||||||
|
AssistantMessageID: assistantMessageID,
|
||||||
|
AgentMode: agentMode,
|
||||||
|
MCPExecutionIDs: mcpExecutionIDs,
|
||||||
|
RequireExecutionEvidence: requireExecutionEvidence,
|
||||||
|
})
|
||||||
|
h.persistFinalizationDecision(conversationID, assistantMessageID, agentMode, mcpExecutionIDs, reasoningContent, decision)
|
||||||
|
return decision
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AgentHandler) decideAgentRunForDeliveryWithPolicy(
|
||||||
|
conversationID string,
|
||||||
|
assistantMessageID string,
|
||||||
|
agentMode string,
|
||||||
|
result *multiagent.RunResult,
|
||||||
|
mcpExecutionIDs []string,
|
||||||
|
requireExecutionEvidence bool,
|
||||||
|
) agentfinalizer.Decision {
|
||||||
|
return agentfinalizer.FromRunResult(h.db, result, agentfinalizer.Input{
|
||||||
|
ConversationID: conversationID,
|
||||||
|
AssistantMessageID: assistantMessageID,
|
||||||
|
AgentMode: agentMode,
|
||||||
|
MCPExecutionIDs: mcpExecutionIDs,
|
||||||
|
RequireExecutionEvidence: requireExecutionEvidence,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AgentHandler) decideAgentRunForDelivery(
|
||||||
|
conversationID string,
|
||||||
|
assistantMessageID string,
|
||||||
|
agentMode string,
|
||||||
|
result *multiagent.RunResult,
|
||||||
|
mcpExecutionIDs []string,
|
||||||
|
) agentfinalizer.Decision {
|
||||||
|
return agentfinalizer.FromRunResult(h.db, result, agentfinalizer.Input{
|
||||||
|
ConversationID: conversationID,
|
||||||
|
AssistantMessageID: assistantMessageID,
|
||||||
|
AgentMode: agentMode,
|
||||||
|
MCPExecutionIDs: mcpExecutionIDs,
|
||||||
|
RequireExecutionEvidence: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AgentHandler) persistFinalizationDecision(
|
||||||
|
conversationID string,
|
||||||
|
assistantMessageID string,
|
||||||
|
agentMode string,
|
||||||
|
mcpExecutionIDs []string,
|
||||||
|
reasoningContent string,
|
||||||
|
decision agentfinalizer.Decision,
|
||||||
|
) {
|
||||||
|
if assistantMessageID == "" || h.db == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = h.db.AddProcessDetail(assistantMessageID, conversationID, "finalization_check", finalizationCheckMessage(decision), decision)
|
||||||
|
if decision.Finalizable {
|
||||||
|
if err := h.db.UpdateAssistantMessageFinalize(assistantMessageID, decision.FinalText, mcpExecutionIDs, reasoningContent); err != nil && h.logger != nil {
|
||||||
|
h.logger.Warn("更新最终助手消息失败", zap.Error(err), zap.String("conversationId", conversationID), zap.String("agentMode", agentMode))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = h.db.Exec("UPDATE messages SET content = ?, updated_at = ? WHERE id = ?", finalizationBlockedMessage(decision), time.Now(), assistantMessageID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AgentHandler) finalizeCandidateForDelivery(
|
||||||
|
conversationID string,
|
||||||
|
assistantMessageID string,
|
||||||
|
agentMode string,
|
||||||
|
response string,
|
||||||
|
mcpExecutionIDs []string,
|
||||||
|
awaitingHITL bool,
|
||||||
|
reasoningContent string,
|
||||||
|
) agentfinalizer.Decision {
|
||||||
|
return h.finalizeCandidateForDeliveryWithPolicy(conversationID, assistantMessageID, agentMode, response, mcpExecutionIDs, awaitingHITL, reasoningContent, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AgentHandler) finalizeCandidateForDeliveryWithPolicy(
|
||||||
|
conversationID string,
|
||||||
|
assistantMessageID string,
|
||||||
|
agentMode string,
|
||||||
|
response string,
|
||||||
|
mcpExecutionIDs []string,
|
||||||
|
awaitingHITL bool,
|
||||||
|
reasoningContent string,
|
||||||
|
requireExecutionEvidence bool,
|
||||||
|
) agentfinalizer.Decision {
|
||||||
|
decision := agentfinalizer.Decide(h.db, agentfinalizer.Input{
|
||||||
|
Response: response,
|
||||||
|
ConversationID: conversationID,
|
||||||
|
AssistantMessageID: assistantMessageID,
|
||||||
|
AgentMode: agentMode,
|
||||||
|
MCPExecutionIDs: mcpExecutionIDs,
|
||||||
|
AwaitingHITL: awaitingHITL,
|
||||||
|
RequireExecutionEvidence: requireExecutionEvidence,
|
||||||
|
})
|
||||||
|
if assistantMessageID == "" || h.db == nil {
|
||||||
|
return decision
|
||||||
|
}
|
||||||
|
_ = h.db.AddProcessDetail(assistantMessageID, conversationID, "finalization_check", finalizationCheckMessage(decision), decision)
|
||||||
|
if decision.Finalizable {
|
||||||
|
if err := h.db.UpdateAssistantMessageFinalize(assistantMessageID, decision.FinalText, mcpExecutionIDs, reasoningContent); err != nil && h.logger != nil {
|
||||||
|
h.logger.Warn("更新最终助手消息失败", zap.Error(err), zap.String("conversationId", conversationID), zap.String("agentMode", agentMode))
|
||||||
|
}
|
||||||
|
return decision
|
||||||
|
}
|
||||||
|
_, _ = h.db.Exec("UPDATE messages SET content = ?, updated_at = ? WHERE id = ?", finalizationBlockedMessage(decision), time.Now(), assistantMessageID)
|
||||||
|
return decision
|
||||||
|
}
|
||||||
|
|
||||||
|
func finalizationCheckMessage(d agentfinalizer.Decision) string {
|
||||||
|
if d.Finalizable {
|
||||||
|
return "最终回复检查通过。"
|
||||||
|
}
|
||||||
|
return finalizationBlockedMessage(d)
|
||||||
|
}
|
||||||
|
|
||||||
|
func finalizationBlockedMessage(d agentfinalizer.Decision) string {
|
||||||
|
parts := []string{"任务尚未达到最终回复条件,暂不生成成功结论。"}
|
||||||
|
if d.CompletionReason != "" {
|
||||||
|
parts = append(parts, "原因: "+d.CompletionReason)
|
||||||
|
}
|
||||||
|
if len(d.PendingExecutionIDs) > 0 {
|
||||||
|
parts = append(parts, fmt.Sprintf("仍有 %d 个工具执行未结束: %s", len(d.PendingExecutionIDs), strings.Join(d.PendingExecutionIDs, ", ")))
|
||||||
|
}
|
||||||
|
if len(d.MissingChecks) > 0 {
|
||||||
|
parts = append(parts, "缺失检查: "+strings.Join(d.MissingChecks, "; "))
|
||||||
|
}
|
||||||
|
return strings.Join(parts, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func finalizationResponsePayload(d agentfinalizer.Decision, extra map[string]interface{}) map[string]interface{} {
|
||||||
|
return agentfinalizer.ResponsePayload(d, extra)
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestRequiresExecutionEvidence(req *ChatRequest) bool {
|
||||||
|
return req != nil && req.Finalization.RequireExecutionEvidence != nil && *req.Finalization.RequireExecutionEvidence
|
||||||
|
}
|
||||||
+228
-55
@@ -1,11 +1,13 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
@@ -87,6 +89,24 @@ type fofaSearchResponse struct {
|
|||||||
Results []map[string]interface{} `json:"results"`
|
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 {
|
func normalizeSpaceSearchProvider(provider string) string {
|
||||||
switch strings.ToLower(strings.TrimSpace(provider)) {
|
switch strings.ToLower(strings.TrimSpace(provider)) {
|
||||||
case "", "fofa":
|
case "", "fofa":
|
||||||
@@ -156,19 +176,20 @@ func (h *FofaHandler) resolveAPIKey(provider string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *FofaHandler) resolveBaseURL(provider string) string {
|
func (h *FofaHandler) resolveBaseURL(provider string) string {
|
||||||
|
provider = normalizeSpaceSearchProvider(provider)
|
||||||
if h.cfg != nil {
|
if h.cfg != nil {
|
||||||
switch normalizeSpaceSearchProvider(provider) {
|
switch provider {
|
||||||
case "zoomeye":
|
case "zoomeye":
|
||||||
if v := strings.TrimSpace(h.cfg.ZoomEye.BaseURL); v != "" {
|
if v := strings.TrimSpace(h.cfg.ZoomEye.BaseURL); v != "" {
|
||||||
return v
|
return canonicalizeSpaceSearchBaseURL(provider, v)
|
||||||
}
|
}
|
||||||
case "quake":
|
case "quake":
|
||||||
if v := strings.TrimSpace(h.cfg.Quake.BaseURL); v != "" {
|
if v := strings.TrimSpace(h.cfg.Quake.BaseURL); v != "" {
|
||||||
return v
|
return canonicalizeSpaceSearchBaseURL(provider, v)
|
||||||
}
|
}
|
||||||
case "shodan":
|
case "shodan":
|
||||||
if v := strings.TrimSpace(h.cfg.Shodan.BaseURL); v != "" {
|
if v := strings.TrimSpace(h.cfg.Shodan.BaseURL); v != "" {
|
||||||
return v
|
return canonicalizeSpaceSearchBaseURL(provider, v)
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
if v := strings.TrimSpace(h.cfg.FOFA.BaseURL); v != "" {
|
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":
|
case "zoomeye":
|
||||||
return "https://api.zoomeye.org/v2/search"
|
return "https://api.zoomeye.ai/v2/search"
|
||||||
case "quake":
|
case "quake":
|
||||||
return "https://quake.360.cn/api/v3/search/quake_service"
|
return "https://quake.360.net/api/v3/search/quake_service"
|
||||||
case "shodan":
|
case "shodan":
|
||||||
return "https://api.shodan.io"
|
return "https://api.shodan.io"
|
||||||
default:
|
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 查询语法(仅生成,不执行查询)
|
// ParseNaturalLanguage 将自然语言解析为 FOFA 查询语法(仅生成,不执行查询)
|
||||||
func (h *FofaHandler) ParseNaturalLanguage(c *gin.Context) {
|
func (h *FofaHandler) ParseNaturalLanguage(c *gin.Context) {
|
||||||
var req fofaParseRequest
|
var req fofaParseRequest
|
||||||
@@ -716,23 +751,17 @@ func (h *FofaHandler) searchZoomEye(c *gin.Context, req fofaSearchRequest, apiKe
|
|||||||
if fields := strings.TrimSpace(req.Fields); fields != "" {
|
if fields := strings.TrimSpace(req.Fields); fields != "" {
|
||||||
body["fields"] = fields
|
body["fields"] = fields
|
||||||
}
|
}
|
||||||
var apiResp struct {
|
var apiResp spaceSearchEnvelope
|
||||||
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"`
|
|
||||||
}
|
|
||||||
if !h.doJSONRequest(c, http.MethodPost, u.String(), apiKey, "API-KEY", body, &apiResp, "ZoomEye") {
|
if !h.doJSONRequest(c, http.MethodPost, u.String(), apiKey, "API-KEY", body, &apiResp, "ZoomEye") {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if apiResp.Code != 60000 {
|
rows, err := decodeSpaceSearchRows(apiResp.Data)
|
||||||
msg := strings.TrimSpace(apiResp.Message)
|
if err != nil {
|
||||||
if msg == "" {
|
c.JSON(http.StatusBadGateway, gin.H{"error": "解析 ZoomEye 响应失败: " + err.Error()})
|
||||||
msg = "ZoomEye 返回错误"
|
return
|
||||||
}
|
}
|
||||||
|
if zoomEyeRequestFailed(apiResp.Code, apiResp.Message) {
|
||||||
|
msg := firstNonEmptySpaceSearchValue(apiResp.Message, messageFromRawObject(apiResp.Data), "ZoomEye 返回错误")
|
||||||
c.JSON(http.StatusBadGateway, gin.H{"error": msg})
|
c.JSON(http.StatusBadGateway, gin.H{"error": msg})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -744,8 +773,8 @@ func (h *FofaHandler) searchZoomEye(c *gin.Context, req fofaSearchRequest, apiKe
|
|||||||
Page: firstPositive(apiResp.Page, req.Page),
|
Page: firstPositive(apiResp.Page, req.Page),
|
||||||
Total: apiResp.Total,
|
Total: apiResp.Total,
|
||||||
Fields: fields,
|
Fields: fields,
|
||||||
ResultsCount: len(apiResp.Data),
|
ResultsCount: len(rows),
|
||||||
Results: projectRows(apiResp.Data, fields),
|
Results: projectRows(rows, fields),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -766,25 +795,17 @@ func (h *FofaHandler) searchQuake(c *gin.Context, req fofaSearchRequest, apiKey
|
|||||||
if len(fields) > 0 {
|
if len(fields) > 0 {
|
||||||
body["include"] = fields
|
body["include"] = fields
|
||||||
}
|
}
|
||||||
var apiResp struct {
|
var apiResp spaceSearchEnvelope
|
||||||
Code int `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"`
|
|
||||||
}
|
|
||||||
if !h.doJSONRequest(c, http.MethodPost, u.String(), apiKey, "X-QuakeToken", body, &apiResp, "Quake") {
|
if !h.doJSONRequest(c, http.MethodPost, u.String(), apiKey, "X-QuakeToken", body, &apiResp, "Quake") {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if apiResp.Code != 0 {
|
rows, err := decodeSpaceSearchRows(apiResp.Data)
|
||||||
msg := strings.TrimSpace(apiResp.Message)
|
if err != nil {
|
||||||
if msg == "" {
|
c.JSON(http.StatusBadGateway, gin.H{"error": "解析 Quake 响应失败: " + err.Error()})
|
||||||
msg = "Quake 返回错误"
|
return
|
||||||
}
|
}
|
||||||
|
if !isZeroSpaceSearchCode(apiResp.Code) {
|
||||||
|
msg := firstNonEmptySpaceSearchValue(apiResp.Message, messageFromRawObject(apiResp.Data), "Quake 返回错误")
|
||||||
c.JSON(http.StatusBadGateway, gin.H{"error": msg})
|
c.JSON(http.StatusBadGateway, gin.H{"error": msg})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -796,11 +817,153 @@ func (h *FofaHandler) searchQuake(c *gin.Context, req fofaSearchRequest, apiKey
|
|||||||
Page: req.Page,
|
Page: req.Page,
|
||||||
Total: total,
|
Total: total,
|
||||||
Fields: fields,
|
Fields: fields,
|
||||||
ResultsCount: len(apiResp.Data),
|
ResultsCount: len(rows),
|
||||||
Results: projectRows(apiResp.Data, fields),
|
Results: projectRows(rows, fields),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isZeroSpaceSearchCode(code interface{}) bool {
|
||||||
|
switch v := code.(type) {
|
||||||
|
case nil:
|
||||||
|
return false
|
||||||
|
case int:
|
||||||
|
return v == 0
|
||||||
|
case int64:
|
||||||
|
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:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isZoomEyeSuccessCode(code interface{}) bool {
|
||||||
|
switch v := code.(type) {
|
||||||
|
case int:
|
||||||
|
return v == 60000
|
||||||
|
case int64:
|
||||||
|
return v == 60000
|
||||||
|
case float64:
|
||||||
|
return v == 60000
|
||||||
|
case json.Number:
|
||||||
|
n, err := v.Int64()
|
||||||
|
return err == nil && n == 60000
|
||||||
|
case string:
|
||||||
|
return strings.TrimSpace(v) == "60000"
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func zoomEyeRequestFailed(code interface{}, message string) bool {
|
||||||
|
if isZoomEyeSuccessCode(code) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
msg := strings.ToLower(strings.TrimSpace(message))
|
||||||
|
if code == nil || isZeroSpaceSearchCode(code) {
|
||||||
|
return msg != "" && msg != "success" && msg != "ok" && msg != "successful."
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeSpaceSearchRows(raw json.RawMessage) ([]map[string]interface{}, error) {
|
||||||
|
raw = bytes.TrimSpace(raw)
|
||||||
|
if len(raw) == 0 || bytes.Equal(raw, []byte("null")) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
switch raw[0] {
|
||||||
|
case '[':
|
||||||
|
var rows []map[string]interface{}
|
||||||
|
if err := json.Unmarshal(raw, &rows); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if rows == nil {
|
||||||
|
return []map[string]interface{}{}, nil
|
||||||
|
}
|
||||||
|
return rows, nil
|
||||||
|
case '{':
|
||||||
|
var obj map[string]interface{}
|
||||||
|
if err := json.Unmarshal(raw, &obj); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(obj) == 0 {
|
||||||
|
return []map[string]interface{}{}, nil
|
||||||
|
}
|
||||||
|
for _, key := range []string{"data", "items", "matches", "results", "list", "records"} {
|
||||||
|
nested, ok := obj[key]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch rows := nested.(type) {
|
||||||
|
case []map[string]interface{}:
|
||||||
|
return rows, nil
|
||||||
|
case []interface{}:
|
||||||
|
return interfaceSliceToRowMaps(rows), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return []map[string]interface{}{}, nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unexpected JSON value")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func interfaceSliceToRowMaps(items []interface{}) []map[string]interface{} {
|
||||||
|
out := make([]map[string]interface{}, 0, len(items))
|
||||||
|
for _, item := range items {
|
||||||
|
if row, ok := item.(map[string]interface{}); ok {
|
||||||
|
out = append(out, row)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func messageFromRawObject(raw json.RawMessage) string {
|
||||||
|
raw = bytes.TrimSpace(raw)
|
||||||
|
if len(raw) == 0 || raw[0] != '{' {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var obj map[string]interface{}
|
||||||
|
if err := json.Unmarshal(raw, &obj); err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
for _, key := range []string{"message", "error", "errmsg", "msg"} {
|
||||||
|
if s, ok := obj[key].(string); ok {
|
||||||
|
if msg := strings.TrimSpace(s); msg != "" {
|
||||||
|
return msg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractRemoteAPIError(body []byte, statusCode int, label string) string {
|
||||||
|
trimmed := bytes.TrimSpace(body)
|
||||||
|
if len(trimmed) > 0 && trimmed[0] == '{' {
|
||||||
|
var obj map[string]interface{}
|
||||||
|
if err := json.Unmarshal(trimmed, &obj); err == nil {
|
||||||
|
for _, key := range []string{"error", "message", "errmsg", "msg"} {
|
||||||
|
if s, ok := obj[key].(string); ok {
|
||||||
|
if msg := strings.TrimSpace(s); msg != "" {
|
||||||
|
return msg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(trimmed) > 0 && trimmed[0] == '<' {
|
||||||
|
return fmt.Sprintf("%s 返回了网页而不是 JSON(HTTP %d),请检查 Base URL 或网络是否被拦截", label, statusCode)
|
||||||
|
}
|
||||||
|
if statusCode < 200 || statusCode >= 300 {
|
||||||
|
return fmt.Sprintf("%s 返回非 2xx: %d", label, statusCode)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
func (h *FofaHandler) searchShodan(c *gin.Context, req fofaSearchRequest, apiKey string) {
|
func (h *FofaHandler) searchShodan(c *gin.Context, req fofaSearchRequest, apiKey string) {
|
||||||
baseURL := strings.TrimRight(h.resolveBaseURL("shodan"), "/") + "/shodan/host/search"
|
baseURL := strings.TrimRight(h.resolveBaseURL("shodan"), "/") + "/shodan/host/search"
|
||||||
u, err := url.Parse(baseURL)
|
u, err := url.Parse(baseURL)
|
||||||
@@ -809,11 +972,7 @@ func (h *FofaHandler) searchShodan(c *gin.Context, req fofaSearchRequest, apiKey
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var apiResp struct {
|
var apiResp spaceSearchEnvelope
|
||||||
Total int `json:"total"`
|
|
||||||
Matches []map[string]interface{} `json:"matches"`
|
|
||||||
Error string `json:"error"`
|
|
||||||
}
|
|
||||||
targetSize := req.Size
|
targetSize := req.Size
|
||||||
if targetSize <= 0 {
|
if targetSize <= 0 {
|
||||||
targetSize = 100
|
targetSize = 100
|
||||||
@@ -835,19 +994,23 @@ func (h *FofaHandler) searchShodan(c *gin.Context, req fofaSearchRequest, apiKey
|
|||||||
params.Set("fields", fields)
|
params.Set("fields", fields)
|
||||||
}
|
}
|
||||||
pageURL.RawQuery = params.Encode()
|
pageURL.RawQuery = params.Encode()
|
||||||
apiResp.Matches = nil
|
apiResp = spaceSearchEnvelope{}
|
||||||
apiResp.Error = ""
|
|
||||||
if !h.doJSONRequest(c, http.MethodGet, pageURL.String(), "", "", nil, &apiResp, "Shodan") {
|
if !h.doJSONRequest(c, http.MethodGet, pageURL.String(), "", "", nil, &apiResp, "Shodan") {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(apiResp.Error) != "" {
|
if errMsg := firstNonEmptySpaceSearchValue(apiResp.Error, apiResp.Message); errMsg != "" {
|
||||||
c.JSON(http.StatusBadGateway, gin.H{"error": apiResp.Error})
|
c.JSON(http.StatusBadGateway, gin.H{"error": errMsg})
|
||||||
return
|
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
|
break
|
||||||
}
|
}
|
||||||
matches = append(matches, apiResp.Matches...)
|
matches = append(matches, pageMatches...)
|
||||||
if len(matches) >= targetSize {
|
if len(matches) >= targetSize {
|
||||||
matches = matches[:targetSize]
|
matches = matches[:targetSize]
|
||||||
break
|
break
|
||||||
@@ -930,11 +1093,21 @@ func (h *FofaHandler) doJSONRequest(c *gin.Context, method, endpoint, apiKey, he
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
respBody, err := io.ReadAll(io.LimitReader(resp.Body, 32<<20))
|
||||||
c.JSON(http.StatusBadGateway, gin.H{"error": fmt.Sprintf("%s 返回非 2xx: %d", label, resp.StatusCode)})
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadGateway, gin.H{"error": "读取 " + label + " 响应失败: " + err.Error()})
|
||||||
return false
|
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()})
|
c.JSON(http.StatusBadGateway, gin.H{"error": "解析 " + label + " 响应失败: " + err.Error()})
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -155,6 +155,184 @@ func TestShodanSearchReportsShortfallWhenTotalExceedsMatches(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestQuakeSearchHandlesStringErrorCode(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
t.Setenv("QUAKE_API_KEY", "")
|
||||||
|
|
||||||
|
quakeServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if got := r.Header.Get("X-QuakeToken"); got != "test-quake-key" {
|
||||||
|
t.Fatalf("Quake token = %q, want test-quake-key", got)
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"code":"q5000","message":"查询语法错误","data":{}}`))
|
||||||
|
}))
|
||||||
|
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":"bad query","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, "查询语法错误") {
|
||||||
|
t.Fatalf("response should include Quake error message, got %s", bodyText)
|
||||||
|
}
|
||||||
|
if strings.Contains(bodyText, "cannot unmarshal") {
|
||||||
|
t.Fatalf("response exposed JSON type decoding failure: %s", bodyText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
func TestExtractInfoCollectJSONObject(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
"cyberstrike-ai/internal/database"
|
"cyberstrike-ai/internal/database"
|
||||||
"cyberstrike-ai/internal/security"
|
"cyberstrike-ai/internal/security"
|
||||||
@@ -17,6 +20,11 @@ type GroupHandler struct {
|
|||||||
logger *zap.Logger
|
logger *zap.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
maxGroupNameRunes = 64
|
||||||
|
maxGroupIconRunes = 16
|
||||||
|
)
|
||||||
|
|
||||||
// NewGroupHandler 创建新的分组处理器
|
// NewGroupHandler 创建新的分组处理器
|
||||||
func NewGroupHandler(db *database.DB, logger *zap.Logger) *GroupHandler {
|
func NewGroupHandler(db *database.DB, logger *zap.Logger) *GroupHandler {
|
||||||
return &GroupHandler{
|
return &GroupHandler{
|
||||||
@@ -25,6 +33,41 @@ func NewGroupHandler(db *database.DB, logger *zap.Logger) *GroupHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func validateGroupTextField(field, value string, maxRunes int, required bool) (string, error) {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if value == "" {
|
||||||
|
if required {
|
||||||
|
return "", errors.New(field + "不能为空")
|
||||||
|
}
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
if utf8.RuneCountInString(value) > maxRunes {
|
||||||
|
return "", errors.New(field + "过长")
|
||||||
|
}
|
||||||
|
for _, r := range value {
|
||||||
|
switch r {
|
||||||
|
case '<', '>', '"', '\'', '`':
|
||||||
|
return "", errors.New(field + "包含非法字符")
|
||||||
|
}
|
||||||
|
if r < 0x20 || r == 0x7f {
|
||||||
|
return "", errors.New(field + "包含非法控制字符")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateGroupFields(name, icon string) (string, string, error) {
|
||||||
|
validName, err := validateGroupTextField("分组名称", name, maxGroupNameRunes, true)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
validIcon, err := validateGroupTextField("分组图标", icon, maxGroupIconRunes, false)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
return validName, validIcon, nil
|
||||||
|
}
|
||||||
|
|
||||||
// CreateGroupRequest 创建分组请求
|
// CreateGroupRequest 创建分组请求
|
||||||
type CreateGroupRequest struct {
|
type CreateGroupRequest struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
@@ -39,13 +82,14 @@ func (h *GroupHandler) CreateGroup(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.Name == "" {
|
name, icon, err := validateGroupFields(req.Name, req.Icon)
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "分组名称不能为空"})
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
session, _ := security.CurrentSession(c)
|
session, _ := security.CurrentSession(c)
|
||||||
group, err := h.db.CreateGroup(req.Name, req.Icon, session.UserID)
|
group, err := h.db.CreateGroup(name, icon, session.UserID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.logger.Error("创建分组失败", zap.Error(err))
|
h.logger.Error("创建分组失败", zap.Error(err))
|
||||||
// 如果是名称重复错误,返回400状态码
|
// 如果是名称重复错误,返回400状态码
|
||||||
@@ -111,12 +155,13 @@ func (h *GroupHandler) UpdateGroup(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.Name == "" {
|
name, icon, err := validateGroupFields(req.Name, req.Icon)
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "分组名称不能为空"})
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := h.db.UpdateGroup(id, req.Name, req.Icon); err != nil {
|
if err := h.db.UpdateGroup(id, name, icon); err != nil {
|
||||||
h.logger.Error("更新分组失败", zap.Error(err))
|
h.logger.Error("更新分组失败", zap.Error(err))
|
||||||
// 如果是名称重复错误,返回400状态码
|
// 如果是名称重复错误,返回400状态码
|
||||||
if err.Error() == "分组名称已存在" {
|
if err.Error() == "分组名称已存在" {
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestValidateGroupFieldsAllowsNormalNamesAndIcons(t *testing.T) {
|
||||||
|
name, icon, err := validateGroupFields(" 日常安全巡检 ", " 📁 ")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("validateGroupFields returned error: %v", err)
|
||||||
|
}
|
||||||
|
if name != "日常安全巡检" {
|
||||||
|
t.Fatalf("name = %q, want trimmed normal name", name)
|
||||||
|
}
|
||||||
|
if icon != "📁" {
|
||||||
|
t.Fatalf("icon = %q, want trimmed icon", icon)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateGroupFieldsRejectsStoredXSSPayloads(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
icon string
|
||||||
|
}{
|
||||||
|
{name: `<img src=x onerror="alert(1)">`, icon: "📁"},
|
||||||
|
{name: "日常安全巡检", icon: `<svg onload=alert(1)>`},
|
||||||
|
{name: "日常安全巡检`onmouseover=alert(1)", icon: "📁"},
|
||||||
|
{name: "日常安全巡检\x00", icon: "📁"},
|
||||||
|
{name: strings.Repeat("分", maxGroupNameRunes+1), icon: "📁"},
|
||||||
|
{name: "日常安全巡检", icon: strings.Repeat("📁", maxGroupIconRunes+1)},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name+"/"+tt.icon, func(t *testing.T) {
|
||||||
|
if _, _, err := validateGroupFields(tt.name, tt.icon); err == nil {
|
||||||
|
t.Fatal("validateGroupFields returned nil error for unsafe input")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
+280
-57
@@ -74,6 +74,7 @@ CREATE TABLE IF NOT EXISTS hitl_interrupts (
|
|||||||
tool_call_id TEXT,
|
tool_call_id TEXT,
|
||||||
payload TEXT,
|
payload TEXT,
|
||||||
status TEXT NOT NULL,
|
status TEXT NOT NULL,
|
||||||
|
reviewer TEXT NOT NULL DEFAULT 'human',
|
||||||
decision TEXT,
|
decision TEXT,
|
||||||
decision_comment TEXT,
|
decision_comment TEXT,
|
||||||
created_at DATETIME NOT NULL,
|
created_at DATETIME NOT NULL,
|
||||||
@@ -98,15 +99,179 @@ CREATE TABLE IF NOT EXISTS hitl_conversation_configs (
|
|||||||
// On startup, cancel all orphaned pending interrupts from previous process.
|
// On startup, cancel all orphaned pending interrupts from previous process.
|
||||||
// Their in-memory channels are gone, so they can never be resolved.
|
// Their in-memory channels are gone, so they can never be resolved.
|
||||||
res, err := m.db.Exec(`UPDATE hitl_interrupts SET status='cancelled', decision='reject',
|
res, err := m.db.Exec(`UPDATE hitl_interrupts SET status='cancelled', decision='reject',
|
||||||
decision_comment='process restarted', decided_at=CURRENT_TIMESTAMP WHERE status='pending'`)
|
decision_comment='process restarted', decided_at=CURRENT_TIMESTAMP, decided_by='system'
|
||||||
|
WHERE status='pending'`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
m.logger.Warn("failed to cancel orphaned HITL interrupts", zap.Error(err))
|
m.logger.Warn("failed to cancel orphaned HITL interrupts", zap.Error(err))
|
||||||
} else if n, _ := res.RowsAffected(); n > 0 {
|
} else if n, _ := res.RowsAffected(); n > 0 {
|
||||||
m.logger.Info("cancelled orphaned HITL interrupts from previous process", zap.Int64("count", n))
|
m.logger.Info("cancelled orphaned HITL interrupts from previous process", zap.Int64("count", n))
|
||||||
}
|
}
|
||||||
|
if err := m.reconcileRestartInterruptedMessages(); err != nil {
|
||||||
|
m.logger.Warn("failed to finalize assistant messages interrupted by process restart", zap.Error(err))
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// reconcileRestartInterruptedMessages completes durable terminal state for
|
||||||
|
// historical assistant placeholders that have explicit evidence of being over:
|
||||||
|
// a terminal HITL/process event, or a later message in the same conversation.
|
||||||
|
// The evidence requirement avoids rewriting a placeholder that could still be
|
||||||
|
// recoverable by another runtime.
|
||||||
|
func (m *HITLManager) reconcileRestartInterruptedMessages() error {
|
||||||
|
rows, err := m.db.Query(`
|
||||||
|
SELECT msg.id, msg.conversation_id,
|
||||||
|
COALESCE((
|
||||||
|
SELECT pd.event_type
|
||||||
|
FROM process_details pd
|
||||||
|
WHERE pd.message_id = msg.id
|
||||||
|
AND pd.event_type IN ('cancelled', 'timeout', 'error')
|
||||||
|
ORDER BY pd.created_at DESC LIMIT 1
|
||||||
|
), '') AS terminal_event,
|
||||||
|
COALESCE((
|
||||||
|
SELECT hi.status
|
||||||
|
FROM hitl_interrupts hi
|
||||||
|
WHERE hi.message_id = msg.id
|
||||||
|
ORDER BY COALESCE(hi.decided_at, hi.created_at) DESC LIMIT 1
|
||||||
|
), '') AS hitl_status,
|
||||||
|
COALESCE((
|
||||||
|
SELECT hi.decision
|
||||||
|
FROM hitl_interrupts hi
|
||||||
|
WHERE hi.message_id = msg.id
|
||||||
|
ORDER BY COALESCE(hi.decided_at, hi.created_at) DESC LIMIT 1
|
||||||
|
), '') AS hitl_decision,
|
||||||
|
COALESCE((
|
||||||
|
SELECT hi.decision_comment
|
||||||
|
FROM hitl_interrupts hi
|
||||||
|
WHERE hi.message_id = msg.id
|
||||||
|
ORDER BY COALESCE(hi.decided_at, hi.created_at) DESC LIMIT 1
|
||||||
|
), '') AS decision_comment,
|
||||||
|
COALESCE((
|
||||||
|
SELECT MAX(COALESCE(hi.decided_at, hi.created_at))
|
||||||
|
FROM hitl_interrupts hi
|
||||||
|
WHERE hi.message_id = msg.id
|
||||||
|
), (
|
||||||
|
SELECT MIN(later.created_at)
|
||||||
|
FROM messages later
|
||||||
|
WHERE later.conversation_id = msg.conversation_id
|
||||||
|
AND later.created_at > msg.created_at
|
||||||
|
), (
|
||||||
|
SELECT MAX(pd.created_at)
|
||||||
|
FROM process_details pd
|
||||||
|
WHERE pd.message_id = msg.id
|
||||||
|
), msg.updated_at, msg.created_at) AS interrupted_at
|
||||||
|
FROM messages msg
|
||||||
|
WHERE msg.role = 'assistant'
|
||||||
|
AND TRIM(msg.content) IN ('处理中...', 'Processing...')
|
||||||
|
AND (
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM hitl_interrupts hi
|
||||||
|
WHERE hi.message_id = msg.id
|
||||||
|
AND (hi.status IN ('cancelled', 'timeout')
|
||||||
|
OR (hi.status = 'decided' AND hi.decision = 'reject'))
|
||||||
|
)
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1 FROM process_details pd
|
||||||
|
WHERE pd.message_id = msg.id
|
||||||
|
AND pd.event_type IN ('cancelled', 'timeout', 'error')
|
||||||
|
)
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1 FROM messages later
|
||||||
|
WHERE later.conversation_id = msg.conversation_id
|
||||||
|
AND later.created_at > msg.created_at
|
||||||
|
)
|
||||||
|
)`)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
type interruptedMessage struct {
|
||||||
|
messageID string
|
||||||
|
conversationID string
|
||||||
|
terminalEvent string
|
||||||
|
hitlStatus string
|
||||||
|
hitlDecision string
|
||||||
|
decisionComment string
|
||||||
|
interruptedAt string
|
||||||
|
}
|
||||||
|
var interrupted []interruptedMessage
|
||||||
|
for rows.Next() {
|
||||||
|
var item interruptedMessage
|
||||||
|
if err := rows.Scan(&item.messageID, &item.conversationID, &item.terminalEvent,
|
||||||
|
&item.hitlStatus, &item.hitlDecision, &item.decisionComment, &item.interruptedAt); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
interrupted = append(interrupted, item)
|
||||||
|
}
|
||||||
|
if err := rows.Close(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(interrupted) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := m.db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() { _ = tx.Rollback() }()
|
||||||
|
for _, item := range interrupted {
|
||||||
|
eventType := strings.ToLower(strings.TrimSpace(item.terminalEvent))
|
||||||
|
decision := strings.ToLower(strings.TrimSpace(item.hitlDecision))
|
||||||
|
comment := strings.ToLower(strings.TrimSpace(item.decisionComment))
|
||||||
|
if eventType == "" {
|
||||||
|
if strings.EqualFold(strings.TrimSpace(item.hitlStatus), "timeout") || strings.Contains(comment, "timeout") {
|
||||||
|
eventType = "timeout"
|
||||||
|
} else {
|
||||||
|
eventType = "cancelled"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
notice := "任务因服务重启已中断。"
|
||||||
|
reason := "process_restarted"
|
||||||
|
switch eventType {
|
||||||
|
case "timeout":
|
||||||
|
notice = "任务等待审批超时,已自动拒绝。"
|
||||||
|
reason = "hitl_timeout"
|
||||||
|
case "error":
|
||||||
|
notice = "任务执行失败,已停止。"
|
||||||
|
reason = "execution_error"
|
||||||
|
case "cancelled":
|
||||||
|
if decision == "reject" && comment != "process restarted" {
|
||||||
|
notice = "任务审批已拒绝,执行已停止。"
|
||||||
|
reason = "hitl_rejected"
|
||||||
|
} else if comment == "process restarted" {
|
||||||
|
notice = "任务因服务重启已中断,审批已取消。"
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
eventType = "cancelled"
|
||||||
|
}
|
||||||
|
detailData, _ := json.Marshal(map[string]string{"reason": reason, "status": eventType})
|
||||||
|
result, err := tx.Exec(`
|
||||||
|
UPDATE messages
|
||||||
|
SET content = ?, updated_at = ?
|
||||||
|
WHERE id = ? AND TRIM(content) IN ('处理中...', 'Processing...')`,
|
||||||
|
notice, item.interruptedAt, item.messageID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
updated, _ := result.RowsAffected()
|
||||||
|
if updated == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(`
|
||||||
|
INSERT INTO process_details (id, message_id, conversation_id, event_type, message, data, created_at)
|
||||||
|
SELECT ?, ?, ?, ?, ?, ?, ?
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM process_details
|
||||||
|
WHERE message_id = ? AND event_type IN ('cancelled', 'timeout', 'error')
|
||||||
|
)`, uuid.NewString(), item.messageID, item.conversationID, eventType, notice, string(detailData),
|
||||||
|
item.interruptedAt, item.messageID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
func normalizeHitlMode(mode string) string {
|
func normalizeHitlMode(mode string) string {
|
||||||
v := strings.ToLower(strings.TrimSpace(mode))
|
v := strings.ToLower(strings.TrimSpace(mode))
|
||||||
if v == "" {
|
if v == "" {
|
||||||
@@ -234,13 +399,14 @@ func (m *HITLManager) NeedsToolApproval(conversationID, toolName string) bool {
|
|||||||
return need
|
return need
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *HITLManager) CreatePendingInterrupt(conversationID, assistantMessageID, mode, toolName, toolCallID, payload string) (*pendingInterrupt, error) {
|
func (m *HITLManager) CreatePendingInterrupt(conversationID, assistantMessageID, mode, toolName, toolCallID, payload, reviewer string) (*pendingInterrupt, error) {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
id := "hitl_" + strings.ReplaceAll(uuid.New().String(), "-", "")
|
id := "hitl_" + strings.ReplaceAll(uuid.New().String(), "-", "")
|
||||||
|
reviewer = normalizeHitlReviewer(reviewer)
|
||||||
if _, err := m.db.Exec(`INSERT INTO hitl_interrupts
|
if _, err := m.db.Exec(`INSERT INTO hitl_interrupts
|
||||||
(id, conversation_id, message_id, mode, tool_name, tool_call_id, payload, status, created_at)
|
(id, conversation_id, message_id, mode, tool_name, tool_call_id, payload, status, reviewer, created_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, 'pending', ?)`,
|
VALUES (?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?)`,
|
||||||
id, conversationID, assistantMessageID, mode, toolName, toolCallID, payload, now); err != nil {
|
id, conversationID, assistantMessageID, mode, toolName, toolCallID, payload, reviewer, now); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
// 刷新页面后侧栏依赖 DB 配置;若仅内存 Activate 未落库,会导致「有待审批却显示关闭」
|
// 刷新页面后侧栏依赖 DB 配置;若仅内存 Activate 未落库,会导致「有待审批却显示关闭」
|
||||||
@@ -253,9 +419,12 @@ func (m *HITLManager) CreatePendingInterrupt(conversationID, assistantMessageID,
|
|||||||
ToolCallID: toolCallID,
|
ToolCallID: toolCallID,
|
||||||
decideCh: make(chan hitlDecision, 1),
|
decideCh: make(chan hitlDecision, 1),
|
||||||
}
|
}
|
||||||
m.mu.Lock()
|
// Agent 审查不会等待人工决策,也不应进入人工审批的内存待办队列。
|
||||||
m.pending[id] = p
|
if reviewer != "audit_agent" {
|
||||||
m.mu.Unlock()
|
m.mu.Lock()
|
||||||
|
m.pending[id] = p
|
||||||
|
m.mu.Unlock()
|
||||||
|
}
|
||||||
return p, nil
|
return p, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -471,66 +640,107 @@ func (h *AgentHandler) waitHITLApproval(runCtx context.Context, cancelRun contex
|
|||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
h.enrichHitlApprovalPayload(conversationID, assistantMessageID, payload)
|
h.enrichHitlApprovalPayload(conversationID, assistantMessageID, payload)
|
||||||
|
approvalStartedAt := time.Now().UTC()
|
||||||
|
timeoutSeconds := int(cfg.Timeout / time.Second)
|
||||||
|
var approvalExpiresAt *time.Time
|
||||||
|
if timeoutSeconds > 0 {
|
||||||
|
expiresAt := approvalStartedAt.Add(cfg.Timeout)
|
||||||
|
approvalExpiresAt = &expiresAt
|
||||||
|
}
|
||||||
|
payload["hitlApproval"] = map[string]interface{}{
|
||||||
|
"createdAt": approvalStartedAt,
|
||||||
|
"timeoutSeconds": timeoutSeconds,
|
||||||
|
"expiresAt": approvalExpiresAt,
|
||||||
|
}
|
||||||
payloadRaw, _ := json.Marshal(payload)
|
payloadRaw, _ := json.Marshal(payload)
|
||||||
p, err := h.hitlManager.CreatePendingInterrupt(conversationID, assistantMessageID, cfg.Mode, toolName, toolCallID, string(payloadRaw))
|
p, err := h.hitlManager.CreatePendingInterrupt(conversationID, assistantMessageID, cfg.Mode, toolName, toolCallID, string(payloadRaw), cfg.Reviewer)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.logger.Warn("创建 HITL 中断失败", zap.Error(err))
|
h.logger.Warn("创建 HITL 中断失败", zap.Error(err))
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
emitHITL := func(eventType, message string, eventData map[string]interface{}) {
|
||||||
|
clientData := enrichProgressEventData(eventData, conversationID, assistantMessageID)
|
||||||
|
if sendEventFunc != nil {
|
||||||
|
sendEventFunc(eventType, message, clientData)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(assistantMessageID) != "" && h.db != nil {
|
||||||
|
if err := h.db.AddProcessDetail(assistantMessageID, conversationID, eventType, message, clientData); err != nil {
|
||||||
|
h.logger.Warn("保存 HITL 过程详情失败", zap.Error(err), zap.String("eventType", eventType))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if cfg.Reviewer == "audit_agent" {
|
if cfg.Reviewer == "audit_agent" {
|
||||||
|
emitHITL("hitl_audit_agent_started", "审计 Agent 正在审查此请求", map[string]interface{}{
|
||||||
|
"conversationId": conversationID,
|
||||||
|
"interruptId": p.InterruptID,
|
||||||
|
"toolName": toolName,
|
||||||
|
"toolCallId": toolCallID,
|
||||||
|
"mode": cfg.Mode,
|
||||||
|
"reviewer": "audit_agent",
|
||||||
|
"status": "audit_running",
|
||||||
|
"payload": payload,
|
||||||
|
})
|
||||||
ad := h.auditAgentReview(runCtx, cfg.Mode, toolName, payload)
|
ad := h.auditAgentReview(runCtx, cfg.Mode, toolName, payload)
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
_, _ = h.db.Exec(`UPDATE hitl_interrupts SET status='decided', decision=?, decision_comment=?, decided_at=?, decided_by='audit_agent' WHERE id=?`,
|
_, _ = h.db.Exec(`UPDATE hitl_interrupts SET status='decided', decision=?, decision_comment=?, decided_at=?, decided_by='audit_agent' WHERE id=?`,
|
||||||
ad.Decision, ad.Comment, now, p.InterruptID)
|
ad.Decision, ad.Comment, now, p.InterruptID)
|
||||||
if sendEventFunc != nil {
|
emitHITL("hitl_audit_agent", "审计 Agent 已裁决", map[string]interface{}{
|
||||||
sendEventFunc("hitl_audit_agent", "审计 Agent 已裁决", map[string]interface{}{
|
"conversationId": conversationID,
|
||||||
|
"interruptId": p.InterruptID,
|
||||||
|
"toolName": toolName,
|
||||||
|
"toolCallId": toolCallID,
|
||||||
|
"mode": cfg.Mode,
|
||||||
|
"status": "decided",
|
||||||
|
"decision": ad.Decision,
|
||||||
|
"comment": ad.Comment,
|
||||||
|
"editedArgs": ad.EditedArguments,
|
||||||
|
"decidedBy": "audit_agent",
|
||||||
|
"reviewer": "audit_agent",
|
||||||
|
})
|
||||||
|
if ad.Decision == "reject" {
|
||||||
|
emitHITL("hitl_rejected", "审计 Agent 拒绝本次工具调用", map[string]interface{}{
|
||||||
"conversationId": conversationID,
|
"conversationId": conversationID,
|
||||||
"interruptId": p.InterruptID,
|
"interruptId": p.InterruptID,
|
||||||
"toolName": toolName,
|
"toolName": toolName,
|
||||||
|
"toolCallId": toolCallID,
|
||||||
"mode": cfg.Mode,
|
"mode": cfg.Mode,
|
||||||
"decision": ad.Decision,
|
"decision": "reject",
|
||||||
"comment": ad.Comment,
|
"comment": ad.Comment,
|
||||||
"editedArgs": ad.EditedArguments,
|
|
||||||
"decidedBy": "audit_agent",
|
"decidedBy": "audit_agent",
|
||||||
|
"reviewer": "audit_agent",
|
||||||
})
|
})
|
||||||
}
|
|
||||||
if ad.Decision == "reject" {
|
|
||||||
if sendEventFunc != nil {
|
|
||||||
sendEventFunc("hitl_rejected", "审计 Agent 拒绝本次工具调用", map[string]interface{}{
|
|
||||||
"conversationId": conversationID,
|
|
||||||
"interruptId": p.InterruptID,
|
|
||||||
"toolName": toolName,
|
|
||||||
"comment": ad.Comment,
|
|
||||||
"decidedBy": "audit_agent",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return &ad, nil
|
return &ad, nil
|
||||||
}
|
}
|
||||||
if sendEventFunc != nil {
|
emitHITL("hitl_resumed", "审计 Agent 已通过,继续执行", map[string]interface{}{
|
||||||
sendEventFunc("hitl_resumed", "审计 Agent 已通过,继续执行", map[string]interface{}{
|
"conversationId": conversationID,
|
||||||
"conversationId": conversationID,
|
"interruptId": p.InterruptID,
|
||||||
"interruptId": p.InterruptID,
|
"toolName": toolName,
|
||||||
"toolName": toolName,
|
"toolCallId": toolCallID,
|
||||||
"comment": ad.Comment,
|
"mode": cfg.Mode,
|
||||||
"editedArgs": ad.EditedArguments,
|
"decision": "approve",
|
||||||
"decidedBy": "audit_agent",
|
"comment": ad.Comment,
|
||||||
})
|
"editedArgs": ad.EditedArguments,
|
||||||
}
|
"decidedBy": "audit_agent",
|
||||||
|
"reviewer": "audit_agent",
|
||||||
|
})
|
||||||
h.hitlManager.TrackApprovedHitlExecution(p.InterruptID, conversationID, toolName, toolCallID)
|
h.hitlManager.TrackApprovedHitlExecution(p.InterruptID, conversationID, toolName, toolCallID)
|
||||||
return &ad, nil
|
return &ad, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if sendEventFunc != nil {
|
emitHITL("hitl_interrupt", "命中人机协同审批", map[string]interface{}{
|
||||||
sendEventFunc("hitl_interrupt", "命中人机协同审批", map[string]interface{}{
|
"conversationId": conversationID,
|
||||||
"conversationId": conversationID,
|
"interruptId": p.InterruptID,
|
||||||
"interruptId": p.InterruptID,
|
"mode": cfg.Mode,
|
||||||
"mode": cfg.Mode,
|
"toolName": toolName,
|
||||||
"toolName": toolName,
|
"toolCallId": toolCallID,
|
||||||
"toolCallId": toolCallID,
|
"reviewer": "human",
|
||||||
"payload": payload,
|
"status": "pending",
|
||||||
})
|
"createdAt": approvalStartedAt,
|
||||||
}
|
"timeoutSeconds": timeoutSeconds,
|
||||||
|
"expiresAt": approvalExpiresAt,
|
||||||
|
"payload": payload,
|
||||||
|
})
|
||||||
d, waitErr := h.hitlManager.waitDecision(runCtx, p, cfg.Timeout)
|
d, waitErr := h.hitlManager.waitDecision(runCtx, p, cfg.Timeout)
|
||||||
if waitErr != nil {
|
if waitErr != nil {
|
||||||
if cancelRun != nil && (errors.Is(waitErr, context.Canceled) || errors.Is(waitErr, context.DeadlineExceeded)) {
|
if cancelRun != nil && (errors.Is(waitErr, context.Canceled) || errors.Is(waitErr, context.DeadlineExceeded)) {
|
||||||
@@ -550,28 +760,41 @@ func (h *AgentHandler) waitHITLApproval(runCtx context.Context, cancelRun contex
|
|||||||
}
|
}
|
||||||
if d.Decision == "reject" {
|
if d.Decision == "reject" {
|
||||||
rejectMsg := "人工拒绝本次工具调用,模型将基于反馈继续迭代"
|
rejectMsg := "人工拒绝本次工具调用,模型将基于反馈继续迭代"
|
||||||
if strings.Contains(strings.ToLower(strings.TrimSpace(d.Comment)), "timeout") {
|
timedOut := strings.Contains(strings.ToLower(strings.TrimSpace(d.Comment)), "timeout")
|
||||||
|
if timedOut {
|
||||||
rejectMsg = "审批超时,安全起见已自动拒绝,模型将基于反馈继续迭代"
|
rejectMsg = "审批超时,安全起见已自动拒绝,模型将基于反馈继续迭代"
|
||||||
}
|
}
|
||||||
if sendEventFunc != nil {
|
status := "decided"
|
||||||
sendEventFunc("hitl_rejected", rejectMsg, map[string]interface{}{
|
decidedBy := "human"
|
||||||
"conversationId": conversationID,
|
if timedOut {
|
||||||
"interruptId": p.InterruptID,
|
status = "timeout"
|
||||||
"toolName": toolName,
|
decidedBy = "system"
|
||||||
"comment": d.Comment,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
return &d, nil
|
emitHITL("hitl_rejected", rejectMsg, map[string]interface{}{
|
||||||
}
|
|
||||||
if sendEventFunc != nil {
|
|
||||||
sendEventFunc("hitl_resumed", "人工确认通过,继续执行", map[string]interface{}{
|
|
||||||
"conversationId": conversationID,
|
"conversationId": conversationID,
|
||||||
"interruptId": p.InterruptID,
|
"interruptId": p.InterruptID,
|
||||||
"toolName": toolName,
|
"toolName": toolName,
|
||||||
|
"toolCallId": toolCallID,
|
||||||
|
"mode": cfg.Mode,
|
||||||
|
"status": status,
|
||||||
|
"decision": "reject",
|
||||||
"comment": d.Comment,
|
"comment": d.Comment,
|
||||||
"editedArgs": d.EditedArguments,
|
"decidedBy": decidedBy,
|
||||||
|
"reviewer": "human",
|
||||||
})
|
})
|
||||||
|
return &d, nil
|
||||||
}
|
}
|
||||||
|
emitHITL("hitl_resumed", "人工确认通过,继续执行", map[string]interface{}{
|
||||||
|
"conversationId": conversationID,
|
||||||
|
"interruptId": p.InterruptID,
|
||||||
|
"toolName": toolName,
|
||||||
|
"toolCallId": toolCallID,
|
||||||
|
"mode": cfg.Mode,
|
||||||
|
"decision": "approve",
|
||||||
|
"comment": d.Comment,
|
||||||
|
"editedArgs": d.EditedArguments,
|
||||||
|
"reviewer": "human",
|
||||||
|
})
|
||||||
h.hitlManager.TrackApprovedHitlExecution(p.InterruptID, conversationID, toolName, toolCallID)
|
h.hitlManager.TrackApprovedHitlExecution(p.InterruptID, conversationID, toolName, toolCallID)
|
||||||
return &d, nil
|
return &d, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,11 +39,14 @@ func normalizeHitlDecidedBy(v string) string {
|
|||||||
|
|
||||||
func (m *HITLManager) migrateHitlSchemaColumns() {
|
func (m *HITLManager) migrateHitlSchemaColumns() {
|
||||||
_, _ = m.db.Exec(`ALTER TABLE hitl_interrupts ADD COLUMN decided_by TEXT NOT NULL DEFAULT 'human'`)
|
_, _ = m.db.Exec(`ALTER TABLE hitl_interrupts ADD COLUMN decided_by TEXT NOT NULL DEFAULT 'human'`)
|
||||||
|
_, _ = m.db.Exec(`ALTER TABLE hitl_interrupts ADD COLUMN reviewer TEXT NOT NULL DEFAULT 'human'`)
|
||||||
|
_, _ = m.db.Exec(`UPDATE hitl_interrupts SET reviewer='audit_agent'
|
||||||
|
WHERE COALESCE(decided_by, '') IN ('audit_agent', 'agent', 'ai')`)
|
||||||
_, _ = m.db.Exec(`ALTER TABLE hitl_conversation_configs ADD COLUMN reviewer TEXT NOT NULL DEFAULT 'human'`)
|
_, _ = m.db.Exec(`ALTER TABLE hitl_conversation_configs ADD COLUMN reviewer TEXT NOT NULL DEFAULT 'human'`)
|
||||||
}
|
}
|
||||||
|
|
||||||
func hitlInterruptRowToMap(
|
func hitlInterruptRowToMap(
|
||||||
id, cid, mode, toolName, toolCallID, payload, rowStatus, decidedBy string,
|
id, cid, mode, toolName, toolCallID, payload, rowStatus, reviewer, decidedBy string,
|
||||||
messageID sql.NullString,
|
messageID sql.NullString,
|
||||||
decision, comment sql.NullString,
|
decision, comment sql.NullString,
|
||||||
createdAt time.Time,
|
createdAt time.Time,
|
||||||
@@ -62,6 +65,7 @@ func hitlInterruptRowToMap(
|
|||||||
"toolCallId": toolCallID,
|
"toolCallId": toolCallID,
|
||||||
"payload": payload,
|
"payload": payload,
|
||||||
"status": rowStatus,
|
"status": rowStatus,
|
||||||
|
"reviewer": reviewer,
|
||||||
"decision": decision.String,
|
"decision": decision.String,
|
||||||
"comment": comment.String,
|
"comment": comment.String,
|
||||||
"decidedBy": decidedBy,
|
"decidedBy": decidedBy,
|
||||||
@@ -77,7 +81,7 @@ func hitlInterruptRowToMap(
|
|||||||
|
|
||||||
func (h *AgentHandler) buildHitlListQuery(logs bool) (string, []interface{}) {
|
func (h *AgentHandler) buildHitlListQuery(logs bool) (string, []interface{}) {
|
||||||
where, args := h.buildHitlLogsWhere(logs)
|
where, args := h.buildHitlLogsWhere(logs)
|
||||||
q := `SELECT id, conversation_id, message_id, mode, tool_name, tool_call_id, payload, status, decision, decision_comment, COALESCE(decided_by,'human'), created_at, decided_at FROM hitl_interrupts` + where
|
q := `SELECT id, conversation_id, message_id, mode, tool_name, tool_call_id, payload, status, COALESCE(reviewer,'human'), decision, decision_comment, COALESCE(decided_by,'human'), created_at, decided_at FROM hitl_interrupts` + where
|
||||||
return q, args
|
return q, args
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,7 +91,9 @@ func (h *AgentHandler) buildHitlLogsWhere(logs bool) (string, []interface{}) {
|
|||||||
if logs {
|
if logs {
|
||||||
q += " AND status != 'pending'"
|
q += " AND status != 'pending'"
|
||||||
} else {
|
} else {
|
||||||
q += " AND status = 'pending'"
|
// 该接口只返回真正等待用户操作的人工审批。Agent 审查即使正在运行,
|
||||||
|
// 也不应触发弹窗、倒计时或项目待审批计数。
|
||||||
|
q += " AND status = 'pending' AND COALESCE(reviewer,'human') = 'human'"
|
||||||
}
|
}
|
||||||
return q, args
|
return q, args
|
||||||
}
|
}
|
||||||
@@ -131,15 +137,15 @@ func (h *AgentHandler) appendHitlListFilters(q string, args []interface{}, c *gi
|
|||||||
func (h *AgentHandler) scanHitlInterruptRows(rows *sql.Rows) ([]map[string]interface{}, error) {
|
func (h *AgentHandler) scanHitlInterruptRows(rows *sql.Rows) ([]map[string]interface{}, error) {
|
||||||
items := make([]map[string]interface{}, 0)
|
items := make([]map[string]interface{}, 0)
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var id, cid, mode, toolName, toolCallID, payload, rowStatus, decidedBy string
|
var id, cid, mode, toolName, toolCallID, payload, rowStatus, reviewer, decidedBy string
|
||||||
var messageID sql.NullString
|
var messageID sql.NullString
|
||||||
var decision, comment sql.NullString
|
var decision, comment sql.NullString
|
||||||
var createdAt time.Time
|
var createdAt time.Time
|
||||||
var decidedAt sql.NullTime
|
var decidedAt sql.NullTime
|
||||||
if err := rows.Scan(&id, &cid, &messageID, &mode, &toolName, &toolCallID, &payload, &rowStatus, &decision, &comment, &decidedBy, &createdAt, &decidedAt); err != nil {
|
if err := rows.Scan(&id, &cid, &messageID, &mode, &toolName, &toolCallID, &payload, &rowStatus, &reviewer, &decision, &comment, &decidedBy, &createdAt, &decidedAt); err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
items = append(items, hitlInterruptRowToMap(id, cid, mode, toolName, toolCallID, payload, rowStatus, decidedBy, messageID, decision, comment, createdAt, decidedAt))
|
items = append(items, hitlInterruptRowToMap(id, cid, mode, toolName, toolCallID, payload, rowStatus, reviewer, decidedBy, messageID, decision, comment, createdAt, decidedAt))
|
||||||
}
|
}
|
||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
@@ -252,13 +258,13 @@ func (h *AgentHandler) GetHITLLog(c *gin.Context) {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "id is required"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "id is required"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
q := `SELECT id, conversation_id, message_id, mode, tool_name, tool_call_id, payload, status, decision, decision_comment, COALESCE(decided_by,'human'), created_at, decided_at FROM hitl_interrupts WHERE id = ?`
|
q := `SELECT id, conversation_id, message_id, mode, tool_name, tool_call_id, payload, status, COALESCE(reviewer,'human'), decision, decision_comment, COALESCE(decided_by,'human'), created_at, decided_at FROM hitl_interrupts WHERE id = ?`
|
||||||
var rowID, cid, mode, toolName, toolCallID, payload, rowStatus, decidedBy string
|
var rowID, cid, mode, toolName, toolCallID, payload, rowStatus, reviewer, decidedBy string
|
||||||
var messageID sql.NullString
|
var messageID sql.NullString
|
||||||
var decision, comment sql.NullString
|
var decision, comment sql.NullString
|
||||||
var createdAt time.Time
|
var createdAt time.Time
|
||||||
var decidedAt sql.NullTime
|
var decidedAt sql.NullTime
|
||||||
err := h.db.QueryRow(q, id).Scan(&rowID, &cid, &messageID, &mode, &toolName, &toolCallID, &payload, &rowStatus, &decision, &comment, &decidedBy, &createdAt, &decidedAt)
|
err := h.db.QueryRow(q, id).Scan(&rowID, &cid, &messageID, &mode, &toolName, &toolCallID, &payload, &rowStatus, &reviewer, &decision, &comment, &decidedBy, &createdAt, &decidedAt)
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||||
return
|
return
|
||||||
@@ -271,7 +277,7 @@ func (h *AgentHandler) GetHITLLog(c *gin.Context) {
|
|||||||
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"})
|
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, hitlInterruptRowToMap(rowID, cid, mode, toolName, toolCallID, payload, rowStatus, decidedBy, messageID, decision, comment, createdAt, decidedAt))
|
c.JSON(http.StatusOK, hitlInterruptRowToMap(rowID, cid, mode, toolName, toolCallID, payload, rowStatus, reviewer, decidedBy, messageID, decision, comment, createdAt, decidedAt))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *AgentHandler) filterAllowedHitlInterruptIDs(c *gin.Context, ids []string) ([]string, error) {
|
func (h *AgentHandler) filterAllowedHitlInterruptIDs(c *gin.Context, ids []string) ([]string, error) {
|
||||||
|
|||||||
@@ -0,0 +1,236 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"cyberstrike-ai/internal/database"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestEnsureSchemaCancelsPendingInterruptsAfterRestart(t *testing.T) {
|
||||||
|
db, err := database.NewDB(filepath.Join(t.TempDir(), "hitl-restart.db"), zap.NewNop())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open db: %v", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = db.Close() }()
|
||||||
|
manager := NewHITLManager(db, zap.NewNop())
|
||||||
|
if err := manager.EnsureSchema(); err != nil {
|
||||||
|
t.Fatalf("ensure schema: %v", err)
|
||||||
|
}
|
||||||
|
conversation, err := db.CreateConversation("restart interrupted", database.ConversationCreateMeta{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create conversation: %v", err)
|
||||||
|
}
|
||||||
|
message, err := db.AddMessage(conversation.ID, "assistant", "处理中...", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create assistant placeholder: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := db.Exec(`INSERT INTO hitl_interrupts
|
||||||
|
(id, conversation_id, message_id, mode, tool_name, tool_call_id, payload, status, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, 'pending', CURRENT_TIMESTAMP)`,
|
||||||
|
"restart-pending", conversation.ID, message.ID, "approval", "browser", "tool-call-1", `{}`); err != nil {
|
||||||
|
t.Fatalf("insert pending interrupt: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := manager.EnsureSchema(); err != nil {
|
||||||
|
t.Fatalf("reconcile restart: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var status, decision, comment, decidedBy string
|
||||||
|
var decidedAt sql.NullTime
|
||||||
|
if err := db.QueryRow(`SELECT status, decision, decision_comment, decided_by, decided_at
|
||||||
|
FROM hitl_interrupts WHERE id = ?`, "restart-pending").
|
||||||
|
Scan(&status, &decision, &comment, &decidedBy, &decidedAt); err != nil {
|
||||||
|
t.Fatalf("query reconciled interrupt: %v", err)
|
||||||
|
}
|
||||||
|
if status != "cancelled" || decision != "reject" || comment != "process restarted" {
|
||||||
|
t.Fatalf("unexpected restart decision: status=%q decision=%q comment=%q", status, decision, comment)
|
||||||
|
}
|
||||||
|
if decidedBy != "system" {
|
||||||
|
t.Fatalf("decided_by=%q, want system", decidedBy)
|
||||||
|
}
|
||||||
|
if !decidedAt.Valid {
|
||||||
|
t.Fatal("decided_at should be set after restart reconciliation")
|
||||||
|
}
|
||||||
|
|
||||||
|
var content string
|
||||||
|
var updatedAt sql.NullTime
|
||||||
|
if err := db.QueryRow(`SELECT content, updated_at FROM messages WHERE id = ?`, message.ID).
|
||||||
|
Scan(&content, &updatedAt); err != nil {
|
||||||
|
t.Fatalf("query reconciled assistant message: %v", err)
|
||||||
|
}
|
||||||
|
if content != "任务因服务重启已中断,审批已取消。" {
|
||||||
|
t.Fatalf("assistant content=%q, want restart interruption notice", content)
|
||||||
|
}
|
||||||
|
if !updatedAt.Valid {
|
||||||
|
t.Fatal("assistant updated_at should be set to the interruption time")
|
||||||
|
}
|
||||||
|
var eventType, eventMessage string
|
||||||
|
if err := db.QueryRow(`SELECT event_type, message FROM process_details WHERE message_id = ?`, message.ID).
|
||||||
|
Scan(&eventType, &eventMessage); err != nil {
|
||||||
|
t.Fatalf("query restart cancellation process detail: %v", err)
|
||||||
|
}
|
||||||
|
if eventType != "cancelled" || eventMessage != content {
|
||||||
|
t.Fatalf("unexpected terminal detail: type=%q message=%q", eventType, eventMessage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsureSchemaFinalizesOnlyHistoricalPlaceholdersWithTerminalEvidence(t *testing.T) {
|
||||||
|
db, err := database.NewDB(filepath.Join(t.TempDir(), "hitl-history.db"), zap.NewNop())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open db: %v", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = db.Close() }()
|
||||||
|
manager := NewHITLManager(db, zap.NewNop())
|
||||||
|
if err := manager.EnsureSchema(); err != nil {
|
||||||
|
t.Fatalf("ensure schema: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
supersededConversation, err := db.CreateConversation("superseded placeholder", database.ConversationCreateMeta{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create superseded conversation: %v", err)
|
||||||
|
}
|
||||||
|
superseded, err := db.AddMessage(supersededConversation.ID, "assistant", "处理中...", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create superseded placeholder: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := db.AddMessage(supersededConversation.ID, "user", "继续", nil); err != nil {
|
||||||
|
t.Fatalf("create later message: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
timeoutConversation, err := db.CreateConversation("timeout placeholder", database.ConversationCreateMeta{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create timeout conversation: %v", err)
|
||||||
|
}
|
||||||
|
timedOut, err := db.AddMessage(timeoutConversation.ID, "assistant", "处理中...", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create timeout placeholder: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := db.Exec(`INSERT INTO hitl_interrupts
|
||||||
|
(id, conversation_id, message_id, mode, tool_name, status, decision, decision_comment, created_at, decided_at)
|
||||||
|
VALUES (?, ?, ?, 'approval', 'browser', 'timeout', 'reject', 'HITL timeout auto-reject for safety', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`,
|
||||||
|
"timeout-interrupt", timeoutConversation.ID, timedOut.ID); err != nil {
|
||||||
|
t.Fatalf("insert timeout interrupt: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rejectedConversation, err := db.CreateConversation("rejected placeholder", database.ConversationCreateMeta{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create rejected conversation: %v", err)
|
||||||
|
}
|
||||||
|
rejected, err := db.AddMessage(rejectedConversation.ID, "assistant", "处理中...", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create rejected placeholder: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := db.Exec(`INSERT INTO hitl_interrupts
|
||||||
|
(id, conversation_id, message_id, mode, tool_name, status, decision, decision_comment, created_at, decided_at)
|
||||||
|
VALUES (?, ?, ?, 'approval', 'exec', 'decided', 'reject', 'user rejected', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`,
|
||||||
|
"rejected-interrupt", rejectedConversation.ID, rejected.ID); err != nil {
|
||||||
|
t.Fatalf("insert rejected interrupt: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
activeConversation, err := db.CreateConversation("potentially active placeholder", database.ConversationCreateMeta{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create active conversation: %v", err)
|
||||||
|
}
|
||||||
|
potentiallyActive, err := db.AddMessage(activeConversation.ID, "assistant", "处理中...", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create potentially active placeholder: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := manager.EnsureSchema(); err != nil {
|
||||||
|
t.Fatalf("reconcile historical placeholders: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertTerminal := func(messageID, wantContent, wantEvent string) {
|
||||||
|
t.Helper()
|
||||||
|
var content, eventType string
|
||||||
|
if err := db.QueryRow(`SELECT content FROM messages WHERE id = ?`, messageID).Scan(&content); err != nil {
|
||||||
|
t.Fatalf("query message %s: %v", messageID, err)
|
||||||
|
}
|
||||||
|
if content != wantContent {
|
||||||
|
t.Fatalf("message %s content=%q, want %q", messageID, content, wantContent)
|
||||||
|
}
|
||||||
|
if err := db.QueryRow(`SELECT event_type FROM process_details WHERE message_id = ?
|
||||||
|
AND event_type IN ('cancelled', 'timeout', 'error')`, messageID).Scan(&eventType); err != nil {
|
||||||
|
t.Fatalf("query terminal detail %s: %v", messageID, err)
|
||||||
|
}
|
||||||
|
if eventType != wantEvent {
|
||||||
|
t.Fatalf("message %s event=%q, want %q", messageID, eventType, wantEvent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertTerminal(superseded.ID, "任务因服务重启已中断。", "cancelled")
|
||||||
|
assertTerminal(timedOut.ID, "任务等待审批超时,已自动拒绝。", "timeout")
|
||||||
|
assertTerminal(rejected.ID, "任务审批已拒绝,执行已停止。", "cancelled")
|
||||||
|
|
||||||
|
var activeContent string
|
||||||
|
if err := db.QueryRow(`SELECT content FROM messages WHERE id = ?`, potentiallyActive.ID).Scan(&activeContent); err != nil {
|
||||||
|
t.Fatalf("query potentially active message: %v", err)
|
||||||
|
}
|
||||||
|
if activeContent != "处理中..." {
|
||||||
|
t.Fatalf("potentially active message was rewritten to %q", activeContent)
|
||||||
|
}
|
||||||
|
var terminalCount int
|
||||||
|
if err := db.QueryRow(`SELECT COUNT(*) FROM process_details WHERE message_id = ?
|
||||||
|
AND event_type IN ('cancelled', 'timeout', 'error')`, potentiallyActive.ID).Scan(&terminalCount); err != nil {
|
||||||
|
t.Fatalf("count active terminal details: %v", err)
|
||||||
|
}
|
||||||
|
if terminalCount != 0 {
|
||||||
|
t.Fatalf("potentially active message got %d terminal details", terminalCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuditAgentInterruptIsNotHumanPendingWork(t *testing.T) {
|
||||||
|
db, err := database.NewDB(filepath.Join(t.TempDir(), "hitl-reviewer.db"), zap.NewNop())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open db: %v", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = db.Close() }()
|
||||||
|
manager := NewHITLManager(db, zap.NewNop())
|
||||||
|
if err := manager.EnsureSchema(); err != nil {
|
||||||
|
t.Fatalf("ensure schema: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
audit, err := manager.CreatePendingInterrupt("conversation-audit", "message-audit", "review_edit", "exec", "call-audit", `{}`, "audit_agent")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create audit interrupt: %v", err)
|
||||||
|
}
|
||||||
|
human, err := manager.CreatePendingInterrupt("conversation-human", "message-human", "approval", "exec", "call-human", `{}`, "human")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create human interrupt: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
manager.mu.RLock()
|
||||||
|
_, auditWaitsForHuman := manager.pending[audit.InterruptID]
|
||||||
|
_, humanWaitsForHuman := manager.pending[human.InterruptID]
|
||||||
|
manager.mu.RUnlock()
|
||||||
|
if auditWaitsForHuman {
|
||||||
|
t.Fatal("audit-agent interrupt must not enter the human pending queue")
|
||||||
|
}
|
||||||
|
if !humanWaitsForHuman {
|
||||||
|
t.Fatal("human interrupt should enter the human pending queue")
|
||||||
|
}
|
||||||
|
|
||||||
|
query, args := (&AgentHandler{}).buildHitlListQuery(false)
|
||||||
|
if len(args) != 0 {
|
||||||
|
t.Fatalf("unexpected pending query args: %v", args)
|
||||||
|
}
|
||||||
|
if !strings.Contains(query, "COALESCE(reviewer,'human') = 'human'") {
|
||||||
|
t.Fatalf("pending query must filter out audit-agent work: %s", query)
|
||||||
|
}
|
||||||
|
rows, err := db.Query(query)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("query human pending interrupts: %v", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
items, err := (&AgentHandler{}).scanHitlInterruptRows(rows)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("scan human pending interrupts: %v", err)
|
||||||
|
}
|
||||||
|
if len(items) != 1 || items[0]["id"] != human.InterruptID || items[0]["reviewer"] != "human" {
|
||||||
|
t.Fatalf("unexpected human pending result: %#v", items)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestHITLBuiltInWhitelistExemptsWriteFile(t *testing.T) {
|
||||||
|
h := &AgentHandler{}
|
||||||
|
req := h.hitlRequestWithMergedConfigWhitelist(&HITLRequest{
|
||||||
|
Enabled: true,
|
||||||
|
Mode: "approval",
|
||||||
|
})
|
||||||
|
|
||||||
|
manager := NewHITLManager(nil, nil)
|
||||||
|
manager.ActivateConversation("conversation-1", req)
|
||||||
|
|
||||||
|
if manager.NeedsToolApproval("conversation-1", "write_file") {
|
||||||
|
t.Fatal("write_file should use the built-in HITL exemption")
|
||||||
|
}
|
||||||
|
if !manager.NeedsToolApproval("conversation-1", "exec") {
|
||||||
|
t.Fatal("non-exempt tools should still require approval")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -69,7 +69,7 @@ func (h *MonitorHandler) SetAgentHandler(ah *AgentHandler) {
|
|||||||
h.agentHandler = ah
|
h.agentHandler = ah
|
||||||
}
|
}
|
||||||
|
|
||||||
const monitorPageTopTools = 3
|
const monitorPageTopTools = 6
|
||||||
|
|
||||||
// MonitorStatsSummary 工具调用汇总
|
// MonitorStatsSummary 工具调用汇总
|
||||||
type MonitorStatsSummary struct {
|
type MonitorStatsSummary struct {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"cyberstrike-ai/internal/agentfinalizer"
|
||||||
"cyberstrike-ai/internal/config"
|
"cyberstrike-ai/internal/config"
|
||||||
"cyberstrike-ai/internal/mcp"
|
"cyberstrike-ai/internal/mcp"
|
||||||
"cyberstrike-ai/internal/multiagent"
|
"cyberstrike-ai/internal/multiagent"
|
||||||
@@ -197,6 +198,13 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
|
|||||||
// 同一请求内分段续跑时,主代理 iteration 事件按偏移累计,避免 UI 出现「第3轮 → 第1轮」回跳。
|
// 同一请求内分段续跑时,主代理 iteration 事件按偏移累计,避免 UI 出现「第3轮 → 第1轮」回跳。
|
||||||
var mainIterationOffset int
|
var mainIterationOffset int
|
||||||
var emptyResponseContinueAttempt int
|
var emptyResponseContinueAttempt int
|
||||||
|
var finalizationAutoContinueAttempt int
|
||||||
|
effectiveOrch := config.NormalizeMultiAgentOrchestration(h.config.MultiAgent.Orchestration)
|
||||||
|
if o := strings.TrimSpace(req.Orchestration); o != "" {
|
||||||
|
effectiveOrch = config.NormalizeMultiAgentOrchestration(o)
|
||||||
|
}
|
||||||
|
agentMode := "eino_" + effectiveOrch
|
||||||
|
var decision agentfinalizer.Decision
|
||||||
|
|
||||||
for {
|
for {
|
||||||
segmentMainIterationMax := 0
|
segmentMainIterationMax := 0
|
||||||
@@ -232,6 +240,12 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
|
|||||||
taskCtxLoop := mcp.WithMCPConversationID(taskCtx, conversationID)
|
taskCtxLoop := mcp.WithMCPConversationID(taskCtx, conversationID)
|
||||||
taskCtxLoop = mcp.WithToolRunRegistry(taskCtxLoop, h.tasks)
|
taskCtxLoop = mcp.WithToolRunRegistry(taskCtxLoop, h.tasks)
|
||||||
taskCtxLoop = mcp.WithEinoExecuteRunRegistry(taskCtxLoop, h.tasks)
|
taskCtxLoop = mcp.WithEinoExecuteRunRegistry(taskCtxLoop, h.tasks)
|
||||||
|
taskCtxLoop = multiagent.WithAgentRuntimeCancelRegistrar(taskCtxLoop, func(cancel func(error) bool) func() {
|
||||||
|
return h.tasks.BindAgentRuntimeCancel(conversationID, cancel)
|
||||||
|
})
|
||||||
|
taskCtxLoop = multiagent.WithAgentTurnLoopInterruptRegistrar(taskCtxLoop, func(push func(string) bool) func() {
|
||||||
|
return h.tasks.BindAgentTurnLoopInterrupt(conversationID, push)
|
||||||
|
})
|
||||||
taskCtxLoop = multiagent.WithHITLToolInterceptor(taskCtxLoop, func(ctx context.Context, toolName, arguments string) (string, error) {
|
taskCtxLoop = multiagent.WithHITLToolInterceptor(taskCtxLoop, func(ctx context.Context, toolName, arguments string) (string, error) {
|
||||||
return h.interceptHITLForEinoTool(ctx, cancelWithCause, conversationID, assistantMessageID, sendEvent, toolName, arguments)
|
return h.interceptHITLForEinoTool(ctx, cancelWithCause, conversationID, assistantMessageID, sendEvent, toolName, arguments)
|
||||||
})
|
})
|
||||||
@@ -267,11 +281,26 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
|
|||||||
baseCtx, cancelWithCause, taskCtx, timeoutCancel = h.rebindEinoRunningTask(taskCtx, conversationID, timeoutCancel)
|
baseCtx, cancelWithCause, taskCtx, timeoutCancel = h.rebindEinoRunningTask(taskCtx, conversationID, timeoutCancel)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
decision = h.decideAgentRunForDeliveryWithPolicy(conversationID, assistantMessageID, agentMode, result, cumulativeMCPExecutionIDs, requestRequiresExecutionEvidence(&req))
|
||||||
|
if h.tryAutoContinueAfterFinalization(taskCtx, conversationID, result, decision, &finalizationAutoContinueAttempt, &curHistory, &curFinalMessage, progressCallback) {
|
||||||
|
mainIterationOffset += segmentMainIterationMax
|
||||||
|
timeoutCancel()
|
||||||
|
baseCtx, cancelWithCause, taskCtx, timeoutCancel = h.rebindEinoRunningTask(taskCtx, conversationID, timeoutCancel)
|
||||||
|
continue
|
||||||
|
}
|
||||||
timeoutCancel()
|
timeoutCancel()
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
cause := context.Cause(baseCtx)
|
cause := context.Cause(baseCtx)
|
||||||
|
if cause == nil {
|
||||||
|
switch {
|
||||||
|
case errors.Is(runErr, multiagent.ErrInterruptContinue):
|
||||||
|
cause = multiagent.ErrInterruptContinue
|
||||||
|
case errors.Is(runErr, ErrTaskCancelled):
|
||||||
|
cause = ErrTaskCancelled
|
||||||
|
}
|
||||||
|
}
|
||||||
if errors.Is(cause, multiagent.ErrInterruptContinue) {
|
if errors.Is(cause, multiagent.ErrInterruptContinue) {
|
||||||
if shouldPersistEinoAgentTraceAfterRunError(baseCtx) {
|
if shouldPersistEinoAgentTraceAfterRunError(baseCtx) {
|
||||||
h.persistEinoAgentTraceForResume(conversationID, result)
|
h.persistEinoAgentTraceForResume(conversationID, result)
|
||||||
@@ -367,9 +396,10 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
|
|||||||
|
|
||||||
timeoutCancel()
|
timeoutCancel()
|
||||||
|
|
||||||
if assistantMessageID != "" {
|
if decision.CompletionReason == "" {
|
||||||
_ = h.db.UpdateAssistantMessageFinalize(assistantMessageID, result.Response, cumulativeMCPExecutionIDs, multiagent.AggregatedReasoningFromTraceJSON(result.LastAgentTraceInput))
|
decision = h.decideAgentRunForDeliveryWithPolicy(conversationID, assistantMessageID, agentMode, result, cumulativeMCPExecutionIDs, requestRequiresExecutionEvidence(&req))
|
||||||
}
|
}
|
||||||
|
h.persistFinalizationDecision(conversationID, assistantMessageID, agentMode, cumulativeMCPExecutionIDs, multiagent.AggregatedReasoningFromTraceJSON(result.LastAgentTraceInput), decision)
|
||||||
|
|
||||||
if result.LastAgentTraceInput != "" || result.LastAgentTraceOutput != "" {
|
if result.LastAgentTraceInput != "" || result.LastAgentTraceOutput != "" {
|
||||||
if err := h.db.SaveAgentTrace(conversationID, result.LastAgentTraceInput, result.LastAgentTraceOutput); err != nil {
|
if err := h.db.SaveAgentTrace(conversationID, result.LastAgentTraceInput, result.LastAgentTraceOutput); err != nil {
|
||||||
@@ -377,16 +407,19 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
effectiveOrch := config.NormalizeMultiAgentOrchestration(h.config.MultiAgent.Orchestration)
|
responseText := decision.FinalText
|
||||||
if o := strings.TrimSpace(req.Orchestration); o != "" {
|
if !decision.Finalizable {
|
||||||
effectiveOrch = config.NormalizeMultiAgentOrchestration(o)
|
responseText = finalizationBlockedMessage(decision)
|
||||||
|
sendEvent("finalization_check", responseText, decision)
|
||||||
|
taskStatus = decision.Status
|
||||||
|
h.tasks.UpdateTaskStatus(conversationID, taskStatus)
|
||||||
}
|
}
|
||||||
sendEvent("response", result.Response, map[string]interface{}{
|
sendEvent("response", responseText, finalizationResponsePayload(decision, map[string]interface{}{
|
||||||
"mcpExecutionIds": cumulativeMCPExecutionIDs,
|
"mcpExecutionIds": cumulativeMCPExecutionIDs,
|
||||||
"conversationId": conversationID,
|
"conversationId": conversationID,
|
||||||
"messageId": assistantMessageID,
|
"messageId": assistantMessageID,
|
||||||
"agentMode": "eino_" + effectiveOrch,
|
"agentMode": agentMode,
|
||||||
})
|
}))
|
||||||
sendEvent("done", "", map[string]interface{}{"conversationId": conversationID})
|
sendEvent("done", "", map[string]interface{}{"conversationId": conversationID})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -437,6 +470,14 @@ func (h *AgentHandler) MultiAgentLoop(c *gin.Context) {
|
|||||||
curMsg := prep.FinalMessage
|
curMsg := prep.FinalMessage
|
||||||
var result *multiagent.RunResult
|
var result *multiagent.RunResult
|
||||||
var runErr error
|
var runErr error
|
||||||
|
var emptyResponseContinueAttempt int
|
||||||
|
var finalizationAutoContinueAttempt int
|
||||||
|
effectiveOrch := config.NormalizeMultiAgentOrchestration(h.config.MultiAgent.Orchestration)
|
||||||
|
if o := strings.TrimSpace(req.Orchestration); o != "" {
|
||||||
|
effectiveOrch = config.NormalizeMultiAgentOrchestration(o)
|
||||||
|
}
|
||||||
|
agentMode := "eino_" + effectiveOrch
|
||||||
|
var decision agentfinalizer.Decision
|
||||||
for {
|
for {
|
||||||
result, runErr = multiagent.RunDeepAgent(
|
result, runErr = multiagent.RunDeepAgent(
|
||||||
taskCtx,
|
taskCtx,
|
||||||
@@ -456,24 +497,30 @@ func (h *AgentHandler) MultiAgentLoop(c *gin.Context) {
|
|||||||
chatReasoningToClientIntent(req.Reasoning),
|
chatReasoningToClientIntent(req.Reasoning),
|
||||||
h.agentSessionContextBlock(prep.ConversationID),
|
h.agentSessionContextBlock(prep.ConversationID),
|
||||||
)
|
)
|
||||||
if runErr == nil {
|
if runErr != nil {
|
||||||
break
|
if shouldPersistEinoAgentTraceAfterRunError(baseCtx) {
|
||||||
|
h.persistEinoAgentTraceForResume(prep.ConversationID, result)
|
||||||
|
}
|
||||||
|
h.logger.Error("Eino DeepAgent 执行失败", zap.Error(runErr))
|
||||||
|
errMsg := "执行失败: " + runErr.Error()
|
||||||
|
if prep.AssistantMessageID != "" {
|
||||||
|
_, _ = h.db.Exec("UPDATE messages SET content = ?, updated_at = ? WHERE id = ?", errMsg, time.Now(), prep.AssistantMessageID)
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": errMsg})
|
||||||
|
return
|
||||||
}
|
}
|
||||||
if shouldPersistEinoAgentTraceAfterRunError(baseCtx) {
|
mw := &h.config.MultiAgent.EinoMiddleware
|
||||||
h.persistEinoAgentTraceForResume(prep.ConversationID, result)
|
if h.tryContinueOnEinoEmptyResponse(taskCtx, mw, prep.ConversationID, result, &emptyResponseContinueAttempt, &curHist, &curMsg, progressCallback) {
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
h.logger.Error("Eino DeepAgent 执行失败", zap.Error(runErr))
|
decision = h.decideAgentRunForDeliveryWithPolicy(prep.ConversationID, prep.AssistantMessageID, agentMode, result, result.MCPExecutionIDs, requestRequiresExecutionEvidence(&req))
|
||||||
errMsg := "执行失败: " + runErr.Error()
|
if h.tryAutoContinueAfterFinalization(taskCtx, prep.ConversationID, result, decision, &finalizationAutoContinueAttempt, &curHist, &curMsg, progressCallback) {
|
||||||
if prep.AssistantMessageID != "" {
|
continue
|
||||||
_, _ = h.db.Exec("UPDATE messages SET content = ?, updated_at = ? WHERE id = ?", errMsg, time.Now(), prep.AssistantMessageID)
|
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": errMsg})
|
break
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if prep.AssistantMessageID != "" {
|
h.persistFinalizationDecision(prep.ConversationID, prep.AssistantMessageID, agentMode, result.MCPExecutionIDs, multiagent.AggregatedReasoningFromTraceJSON(result.LastAgentTraceInput), decision)
|
||||||
_ = h.db.UpdateAssistantMessageFinalize(prep.AssistantMessageID, result.Response, result.MCPExecutionIDs, multiagent.AggregatedReasoningFromTraceJSON(result.LastAgentTraceInput))
|
|
||||||
}
|
|
||||||
|
|
||||||
if result.LastAgentTraceInput != "" || result.LastAgentTraceOutput != "" {
|
if result.LastAgentTraceInput != "" || result.LastAgentTraceOutput != "" {
|
||||||
if err := h.db.SaveAgentTrace(prep.ConversationID, result.LastAgentTraceInput, result.LastAgentTraceOutput); err != nil {
|
if err := h.db.SaveAgentTrace(prep.ConversationID, result.LastAgentTraceInput, result.LastAgentTraceOutput); err != nil {
|
||||||
@@ -481,11 +528,23 @@ func (h *AgentHandler) MultiAgentLoop(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
responseText := decision.FinalText
|
||||||
|
if !decision.Finalizable {
|
||||||
|
responseText = finalizationBlockedMessage(decision)
|
||||||
|
}
|
||||||
c.JSON(http.StatusOK, ChatResponse{
|
c.JSON(http.StatusOK, ChatResponse{
|
||||||
Response: result.Response,
|
Response: responseText,
|
||||||
MCPExecutionIDs: result.MCPExecutionIDs,
|
MCPExecutionIDs: result.MCPExecutionIDs,
|
||||||
ConversationID: prep.ConversationID,
|
ConversationID: prep.ConversationID,
|
||||||
Time: time.Now(),
|
Time: time.Now(),
|
||||||
|
Finalizable: decision.Finalizable,
|
||||||
|
Finalized: decision.Finalized,
|
||||||
|
Status: decision.Status,
|
||||||
|
CompletionReason: decision.CompletionReason,
|
||||||
|
EvidenceVerified: decision.EvidenceVerified,
|
||||||
|
EvidenceRefs: decision.EvidenceRefs,
|
||||||
|
PendingExecutionIDs: decision.PendingExecutionIDs,
|
||||||
|
MissingChecks: decision.MissingChecks,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
|
|
||||||
"cyberstrike-ai/internal/agent"
|
"cyberstrike-ai/internal/agent"
|
||||||
"cyberstrike-ai/internal/audit"
|
"cyberstrike-ai/internal/audit"
|
||||||
|
"cyberstrike-ai/internal/config"
|
||||||
"cyberstrike-ai/internal/database"
|
"cyberstrike-ai/internal/database"
|
||||||
"cyberstrike-ai/internal/mcp/builtin"
|
"cyberstrike-ai/internal/mcp/builtin"
|
||||||
"cyberstrike-ai/internal/security"
|
"cyberstrike-ai/internal/security"
|
||||||
@@ -25,6 +26,13 @@ type multiAgentPrepared struct {
|
|||||||
UserMessageID string
|
UserMessageID string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func chatRequestAgentMode(req *ChatRequest, source string) string {
|
||||||
|
if strings.HasPrefix(strings.TrimSpace(source), "multi_agent") {
|
||||||
|
return config.NormalizeMultiAgentOrchestration(req.Orchestration)
|
||||||
|
}
|
||||||
|
return "eino_single"
|
||||||
|
}
|
||||||
|
|
||||||
func (h *AgentHandler) prepareMultiAgentSession(req *ChatRequest, c *gin.Context, source string) (*multiAgentPrepared, error) {
|
func (h *AgentHandler) prepareMultiAgentSession(req *ChatRequest, c *gin.Context, source string) (*multiAgentPrepared, error) {
|
||||||
if len(req.Attachments) > maxAttachments {
|
if len(req.Attachments) > maxAttachments {
|
||||||
return nil, fmt.Errorf("附件最多 %d 个", maxAttachments)
|
return nil, fmt.Errorf("附件最多 %d 个", maxAttachments)
|
||||||
@@ -57,6 +65,7 @@ func (h *AgentHandler) prepareMultiAgentSession(req *ChatRequest, c *gin.Context
|
|||||||
meta := audit.ConversationCreateMetaFromGin(c, source)
|
meta := audit.ConversationCreateMetaFromGin(c, source)
|
||||||
meta.ProjectID = projectID
|
meta.ProjectID = projectID
|
||||||
meta.RoleName = req.Role
|
meta.RoleName = req.Role
|
||||||
|
meta.AgentMode = chatRequestAgentMode(req, source)
|
||||||
if webshellID != "" {
|
if webshellID != "" {
|
||||||
meta.Source = source + "_webshell"
|
meta.Source = source + "_webshell"
|
||||||
meta.WebShellConnectionID = webshellID
|
meta.WebShellConnectionID = webshellID
|
||||||
@@ -84,6 +93,9 @@ func (h *AgentHandler) prepareMultiAgentSession(req *ChatRequest, c *gin.Context
|
|||||||
if err := h.db.SetConversationRoleName(conversationID, req.Role); err != nil {
|
if err := h.db.SetConversationRoleName(conversationID, req.Role); err != nil {
|
||||||
h.logger.Warn("更新对话角色失败", zap.String("conversationId", conversationID), zap.String("role", req.Role), zap.Error(err))
|
h.logger.Warn("更新对话角色失败", zap.String("conversationId", conversationID), zap.String("role", req.Role), zap.Error(err))
|
||||||
}
|
}
|
||||||
|
if err := h.db.SetConversationAgentMode(conversationID, chatRequestAgentMode(req, source)); err != nil {
|
||||||
|
h.logger.Warn("更新对话模式失败", zap.String("conversationId", conversationID), zap.String("source", source), zap.String("orchestration", req.Orchestration), zap.Error(err))
|
||||||
|
}
|
||||||
|
|
||||||
agentHistoryMessages, err := h.loadHistoryFromAgentTrace(conversationID)
|
agentHistoryMessages, err := h.loadHistoryFromAgentTrace(conversationID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
+142
-18
@@ -35,6 +35,17 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
|||||||
scheme = "https"
|
scheme = "https"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
finalizationRequestSchema := map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"description": "最终回复交付策略。后端不会从自然语言内容推断执行意图;执行入口应显式声明是否要求 completed 工具证据。",
|
||||||
|
"properties": map[string]interface{}{
|
||||||
|
"requireExecutionEvidence": map[string]interface{}{
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "为 true 时,缺少 completed 工具执行记录会触发无注入续跑或最终阻断;普通聊天可省略或设为 false。",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
spec := map[string]interface{}{
|
spec := map[string]interface{}{
|
||||||
"openapi": "3.0.0",
|
"openapi": "3.0.0",
|
||||||
"info": map[string]interface{}{
|
"info": map[string]interface{}{
|
||||||
@@ -85,6 +96,70 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
|||||||
},
|
},
|
||||||
"required": []string{"projectId"},
|
"required": []string{"projectId"},
|
||||||
},
|
},
|
||||||
|
"AgentChatResponse": map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"description": "Agent 非流式响应。response 只是交付文本;是否为成功最终回复必须以 finalized/finalizable/status 为准。",
|
||||||
|
"properties": map[string]interface{}{
|
||||||
|
"response": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"description": "交付给用户的文本。finalized=false 时为阻断/未完成说明,不是成功结论。",
|
||||||
|
},
|
||||||
|
"conversationId": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"description": "对话 ID",
|
||||||
|
},
|
||||||
|
"assistantMessageId": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"description": "助手消息 ID(部分接口返回)",
|
||||||
|
},
|
||||||
|
"mcpExecutionIds": map[string]interface{}{
|
||||||
|
"type": "array",
|
||||||
|
"description": "本轮关联的 MCP 工具执行 ID",
|
||||||
|
"items": map[string]interface{}{"type": "string"},
|
||||||
|
},
|
||||||
|
"agentMode": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"description": "agent 模式,例如 eino_single、eino_deep、workflow",
|
||||||
|
},
|
||||||
|
"finalized": map[string]interface{}{
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "是否已经通过最终回复检查。只有 true 才能当成功最终回复。",
|
||||||
|
},
|
||||||
|
"finalizable": map[string]interface{}{
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "候选输出是否可提升为最终回复。",
|
||||||
|
},
|
||||||
|
"status": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"description": "最终化状态",
|
||||||
|
"enum": []string{"completed", "in_progress", "blocked", "failed", "cancelled", "awaiting_hitl"},
|
||||||
|
},
|
||||||
|
"completionReason": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"description": "最终化或阻断原因,例如 verified、pending_tool_executions、missing_execution_evidence",
|
||||||
|
},
|
||||||
|
"evidenceVerified": map[string]interface{}{
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "证据是否满足最终化要求",
|
||||||
|
},
|
||||||
|
"evidenceRefs": map[string]interface{}{
|
||||||
|
"type": "array",
|
||||||
|
"description": "证据引用,例如 mcp_execution:<id>",
|
||||||
|
"items": map[string]interface{}{"type": "string"},
|
||||||
|
},
|
||||||
|
"pendingExecutionIds": map[string]interface{}{
|
||||||
|
"type": "array",
|
||||||
|
"description": "仍处于 queued/running 的工具执行 ID",
|
||||||
|
"items": map[string]interface{}{"type": "string"},
|
||||||
|
},
|
||||||
|
"missingChecks": map[string]interface{}{
|
||||||
|
"type": "array",
|
||||||
|
"description": "未通过最终化检查的原因列表",
|
||||||
|
"items": map[string]interface{}{"type": "string"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": []string{"response", "conversationId", "finalized", "finalizable", "status", "evidenceVerified"},
|
||||||
|
},
|
||||||
"Conversation": map[string]interface{}{
|
"Conversation": map[string]interface{}{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": map[string]interface{}{
|
"properties": map[string]interface{}{
|
||||||
@@ -1581,6 +1656,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
|||||||
"conversationId": map[string]interface{}{"type": "string"},
|
"conversationId": map[string]interface{}{"type": "string"},
|
||||||
"role": map[string]interface{}{"type": "string"},
|
"role": map[string]interface{}{"type": "string"},
|
||||||
"webshellConnectionId": map[string]interface{}{"type": "string"},
|
"webshellConnectionId": map[string]interface{}{"type": "string"},
|
||||||
|
"finalization": finalizationRequestSchema,
|
||||||
},
|
},
|
||||||
"required": []string{"message"},
|
"required": []string{"message"},
|
||||||
},
|
},
|
||||||
@@ -1588,7 +1664,14 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
"responses": map[string]interface{}{
|
"responses": map[string]interface{}{
|
||||||
"200": map[string]interface{}{"description": "成功,响应格式同 /api/eino-agent"},
|
"200": map[string]interface{}{
|
||||||
|
"description": "成功。只有 finalized=true 表示成功最终回复;finalized=false 时 response 为未完成/阻断说明。",
|
||||||
|
"content": map[string]interface{}{
|
||||||
|
"application/json": map[string]interface{}{
|
||||||
|
"schema": map[string]interface{}{"$ref": "#/components/schemas/AgentChatResponse"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
"400": map[string]interface{}{"description": "参数错误"},
|
"400": map[string]interface{}{"description": "参数错误"},
|
||||||
"401": map[string]interface{}{"description": "未授权"},
|
"401": map[string]interface{}{"description": "未授权"},
|
||||||
"500": map[string]interface{}{"description": "执行失败"},
|
"500": map[string]interface{}{"description": "执行失败"},
|
||||||
@@ -1599,7 +1682,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
|||||||
"post": map[string]interface{}{
|
"post": map[string]interface{}{
|
||||||
"tags": []string{"对话交互"},
|
"tags": []string{"对话交互"},
|
||||||
"summary": "发送消息并获取 AI 回复(Eino ADK 单代理,SSE)",
|
"summary": "发送消息并获取 AI 回复(Eino ADK 单代理,SSE)",
|
||||||
"description": "向 AI 发送消息并获取流式回复(SSE)。由 Eino **单代理** ADK 执行;事件类型与多代理流式一致(含 `tool_call` / `response_delta` / `thinking` 等)。**不依赖** `multi_agent.enabled`。",
|
"description": "向 AI 发送消息并获取流式回复(SSE)。由 Eino **单代理** ADK 执行;事件类型与多代理流式一致(含 `tool_call` / `response_delta` / `thinking` 等)。`response_start` / `response_delta` 仅为候选/过程输出;只有 `type: response` 且 `data.finalized=true` 才表示成功最终回复。缺 completed 执行证据时可能先发送 `finalization_auto_continue`,表示服务端基于已有 trace 无注入续跑。`data.finalized=false` 时 message 为未完成/阻断说明。**不依赖** `multi_agent.enabled`。",
|
||||||
"operationId": "sendMessageEinoSingleAgentStream",
|
"operationId": "sendMessageEinoSingleAgentStream",
|
||||||
"requestBody": map[string]interface{}{
|
"requestBody": map[string]interface{}{
|
||||||
"required": true,
|
"required": true,
|
||||||
@@ -1612,6 +1695,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
|||||||
"conversationId": map[string]interface{}{"type": "string"},
|
"conversationId": map[string]interface{}{"type": "string"},
|
||||||
"role": map[string]interface{}{"type": "string"},
|
"role": map[string]interface{}{"type": "string"},
|
||||||
"webshellConnectionId": map[string]interface{}{"type": "string"},
|
"webshellConnectionId": map[string]interface{}{"type": "string"},
|
||||||
|
"finalization": finalizationRequestSchema,
|
||||||
},
|
},
|
||||||
"required": []string{"message"},
|
"required": []string{"message"},
|
||||||
},
|
},
|
||||||
@@ -1625,7 +1709,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
|||||||
"text/event-stream": map[string]interface{}{
|
"text/event-stream": map[string]interface{}{
|
||||||
"schema": map[string]interface{}{
|
"schema": map[string]interface{}{
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "SSE 流",
|
"description": "SSE 流。终态 response 事件 data 包含 finalized、finalizable、status、completionReason、evidenceVerified、evidenceRefs、pendingExecutionIds、missingChecks;过程事件可能包含 finalization_auto_continue。",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -1663,6 +1747,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "WebShell 连接 ID(可选,与 Eino 单/多代理流式行为一致)",
|
"description": "WebShell 连接 ID(可选,与 Eino 单/多代理流式行为一致)",
|
||||||
},
|
},
|
||||||
|
"finalization": finalizationRequestSchema,
|
||||||
"orchestration": map[string]interface{}{
|
"orchestration": map[string]interface{}{
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Eino 预置编排:deep | plan_execute | supervisor;缺省 deep",
|
"description": "Eino 预置编排:deep | plan_execute | supervisor;缺省 deep",
|
||||||
@@ -1676,7 +1761,12 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
|||||||
},
|
},
|
||||||
"responses": map[string]interface{}{
|
"responses": map[string]interface{}{
|
||||||
"200": map[string]interface{}{
|
"200": map[string]interface{}{
|
||||||
"description": "成功,响应格式同 /api/eino-agent",
|
"description": "成功。只有 finalized=true 表示成功最终回复;finalized=false 时 response 为未完成/阻断说明。",
|
||||||
|
"content": map[string]interface{}{
|
||||||
|
"application/json": map[string]interface{}{
|
||||||
|
"schema": map[string]interface{}{"$ref": "#/components/schemas/AgentChatResponse"},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
"400": map[string]interface{}{"description": "参数错误"},
|
"400": map[string]interface{}{"description": "参数错误"},
|
||||||
"401": map[string]interface{}{"description": "未授权"},
|
"401": map[string]interface{}{"description": "未授权"},
|
||||||
@@ -1689,7 +1779,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
|||||||
"post": map[string]interface{}{
|
"post": map[string]interface{}{
|
||||||
"tags": []string{"对话交互"},
|
"tags": []string{"对话交互"},
|
||||||
"summary": "发送消息并获取 AI 回复(Eino 多代理,SSE)",
|
"summary": "发送消息并获取 AI 回复(Eino 多代理,SSE)",
|
||||||
"description": "与 `POST /api/eino-agent/stream` 类似;由 Eino 多代理执行。`orchestration` 指定 deep / plan_execute / supervisor,缺省 deep。**前提**:`multi_agent.enabled: true`;未启用时 SSE 内首条为 `type: error` 后接 `done`。支持 `webshellConnectionId`。",
|
"description": "与 `POST /api/eino-agent/stream` 类似;由 Eino 多代理执行。`orchestration` 指定 deep / plan_execute / supervisor,缺省 deep。`response_start` / `response_delta` 仅为候选/过程输出;只有 `type: response` 且 `data.finalized=true` 才表示成功最终回复。缺 completed 执行证据时可能先发送 `finalization_auto_continue`,表示服务端基于已有 trace 无注入续跑。**前提**:`multi_agent.enabled: true`;未启用时 SSE 内首条为 `type: error` 后接 `done`。支持 `webshellConnectionId`。",
|
||||||
"operationId": "sendMessageMultiAgentStream",
|
"operationId": "sendMessageMultiAgentStream",
|
||||||
"requestBody": map[string]interface{}{
|
"requestBody": map[string]interface{}{
|
||||||
"required": true,
|
"required": true,
|
||||||
@@ -1702,6 +1792,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
|||||||
"conversationId": map[string]interface{}{"type": "string"},
|
"conversationId": map[string]interface{}{"type": "string"},
|
||||||
"role": map[string]interface{}{"type": "string"},
|
"role": map[string]interface{}{"type": "string"},
|
||||||
"webshellConnectionId": map[string]interface{}{"type": "string"},
|
"webshellConnectionId": map[string]interface{}{"type": "string"},
|
||||||
|
"finalization": finalizationRequestSchema,
|
||||||
"orchestration": map[string]interface{}{
|
"orchestration": map[string]interface{}{
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "deep | plan_execute | supervisor;缺省 deep",
|
"description": "deep | plan_execute | supervisor;缺省 deep",
|
||||||
@@ -1720,7 +1811,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
|||||||
"text/event-stream": map[string]interface{}{
|
"text/event-stream": map[string]interface{}{
|
||||||
"schema": map[string]interface{}{
|
"schema": map[string]interface{}{
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "SSE 流",
|
"description": "SSE 流。终态 response 事件 data 包含 finalized、finalizable、status、completionReason、evidenceVerified、evidenceRefs、pendingExecutionIds、missingChecks;过程事件可能包含 finalization_auto_continue。",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -5803,7 +5894,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
|||||||
"parameters": []map[string]interface{}{
|
"parameters": []map[string]interface{}{
|
||||||
{"name": "conversation", "in": "query", "required": false, "description": "按对话ID过滤", "schema": map[string]interface{}{"type": "string"}},
|
{"name": "conversation", "in": "query", "required": false, "description": "按对话ID过滤", "schema": map[string]interface{}{"type": "string"}},
|
||||||
{"name": "project", "in": "query", "required": false, "description": "按项目ID过滤", "schema": map[string]interface{}{"type": "string"}},
|
{"name": "project", "in": "query", "required": false, "description": "按项目ID过滤", "schema": map[string]interface{}{"type": "string"}},
|
||||||
{"name": "source", "in": "query", "required": false, "description": "按来源过滤:upload/reduction/conversation_artifact/all", "schema": map[string]interface{}{"type": "string", "enum": []string{"all", "upload", "reduction", "conversation_artifact"}}},
|
{"name": "source", "in": "query", "required": false, "description": "按来源过滤:upload/reduction/workspace/conversation_artifact/all", "schema": map[string]interface{}{"type": "string", "enum": []string{"all", "upload", "reduction", "workspace", "conversation_artifact"}}},
|
||||||
{"name": "search", "in": "query", "required": false, "description": "按文件名或子路径搜索", "schema": map[string]interface{}{"type": "string"}},
|
{"name": "search", "in": "query", "required": false, "description": "按文件名或子路径搜索", "schema": map[string]interface{}{"type": "string"}},
|
||||||
{"name": "page", "in": "query", "required": false, "description": "页码,从1开始", "schema": map[string]interface{}{"type": "integer", "default": 1}},
|
{"name": "page", "in": "query", "required": false, "description": "页码,从1开始", "schema": map[string]interface{}{"type": "integer", "default": 1}},
|
||||||
{"name": "pageSize", "in": "query", "required": false, "description": "每页数量,传 all 返回全部", "schema": map[string]interface{}{"oneOf": []map[string]interface{}{{"type": "integer"}, {"type": "string", "enum": []string{"all"}}}}},
|
{"name": "pageSize", "in": "query", "required": false, "description": "每页数量,传 all 返回全部", "schema": map[string]interface{}{"oneOf": []map[string]interface{}{{"type": "integer"}, {"type": "string", "enum": []string{"all"}}}}},
|
||||||
@@ -5821,16 +5912,18 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
|||||||
"items": map[string]interface{}{
|
"items": map[string]interface{}{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": map[string]interface{}{
|
"properties": map[string]interface{}{
|
||||||
"relativePath": map[string]interface{}{"type": "string"},
|
"relativePath": map[string]interface{}{"type": "string"},
|
||||||
"absolutePath": map[string]interface{}{"type": "string"},
|
"absolutePath": map[string]interface{}{"type": "string"},
|
||||||
"name": map[string]interface{}{"type": "string"},
|
"name": map[string]interface{}{"type": "string"},
|
||||||
"size": map[string]interface{}{"type": "integer"},
|
"size": map[string]interface{}{"type": "integer"},
|
||||||
"modifiedUnix": map[string]interface{}{"type": "integer"},
|
"modifiedUnix": map[string]interface{}{"type": "integer"},
|
||||||
"date": map[string]interface{}{"type": "string"},
|
"date": map[string]interface{}{"type": "string"},
|
||||||
"conversationId": map[string]interface{}{"type": "string"},
|
"conversationId": map[string]interface{}{"type": "string"},
|
||||||
"projectId": map[string]interface{}{"type": "string"},
|
"conversationTitle": map[string]interface{}{"type": "string"},
|
||||||
"subPath": map[string]interface{}{"type": "string"},
|
"projectId": map[string]interface{}{"type": "string"},
|
||||||
"source": map[string]interface{}{"type": "string", "description": "upload/reduction/conversation_artifact"},
|
"projectName": map[string]interface{}{"type": "string"},
|
||||||
|
"subPath": map[string]interface{}{"type": "string"},
|
||||||
|
"source": map[string]interface{}{"type": "string", "description": "upload/reduction/workspace/conversation_artifact"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -5923,7 +6016,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
|||||||
"parameters": []map[string]interface{}{
|
"parameters": []map[string]interface{}{
|
||||||
{"name": "conversation", "in": "query", "required": false, "description": "按对话ID过滤", "schema": map[string]interface{}{"type": "string"}},
|
{"name": "conversation", "in": "query", "required": false, "description": "按对话ID过滤", "schema": map[string]interface{}{"type": "string"}},
|
||||||
{"name": "project", "in": "query", "required": false, "description": "按项目ID过滤", "schema": map[string]interface{}{"type": "string"}},
|
{"name": "project", "in": "query", "required": false, "description": "按项目ID过滤", "schema": map[string]interface{}{"type": "string"}},
|
||||||
{"name": "source", "in": "query", "required": false, "description": "按来源过滤:upload/reduction/conversation_artifact/all", "schema": map[string]interface{}{"type": "string", "enum": []string{"all", "upload", "reduction", "conversation_artifact"}}},
|
{"name": "source", "in": "query", "required": false, "description": "按来源过滤:upload/reduction/workspace/conversation_artifact/all", "schema": map[string]interface{}{"type": "string", "enum": []string{"all", "upload", "reduction", "workspace", "conversation_artifact"}}},
|
||||||
{"name": "search", "in": "query", "required": false, "description": "按文件名或子路径搜索", "schema": map[string]interface{}{"type": "string"}},
|
{"name": "search", "in": "query", "required": false, "description": "按文件名或子路径搜索", "schema": map[string]interface{}{"type": "string"}},
|
||||||
},
|
},
|
||||||
"responses": map[string]interface{}{
|
"responses": map[string]interface{}{
|
||||||
@@ -5962,6 +6055,37 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
"/api/chat-uploads/path": map[string]interface{}{
|
||||||
|
"get": map[string]interface{}{
|
||||||
|
"tags": []string{"对话附件"},
|
||||||
|
"summary": "解析附件路径",
|
||||||
|
"description": "将文件管理中的相对路径或内部虚拟路径解析为服务器绝对路径,用于复制文件/目录路径。",
|
||||||
|
"operationId": "resolveChatUploadPath",
|
||||||
|
"parameters": []map[string]interface{}{
|
||||||
|
{"name": "path", "in": "query", "required": true, "description": "相对路径或虚拟路径(如 __workspace__/projects/<id>/csv)", "schema": map[string]interface{}{"type": "string"}},
|
||||||
|
{"name": "kind", "in": "query", "required": false, "description": "路径类型:file/directory,默认 file", "schema": map[string]interface{}{"type": "string", "enum": []string{"file", "directory"}}},
|
||||||
|
},
|
||||||
|
"responses": map[string]interface{}{
|
||||||
|
"200": map[string]interface{}{
|
||||||
|
"description": "解析成功",
|
||||||
|
"content": map[string]interface{}{
|
||||||
|
"application/json": map[string]interface{}{
|
||||||
|
"schema": map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]interface{}{
|
||||||
|
"absolutePath": map[string]interface{}{"type": "string"},
|
||||||
|
"isDir": map[string]interface{}{"type": "boolean"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"401": map[string]interface{}{"description": "未授权"},
|
||||||
|
"403": map[string]interface{}{"description": "无权访问"},
|
||||||
|
"404": map[string]interface{}{"description": "路径不存在"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
"/api/chat-uploads/content": map[string]interface{}{
|
"/api/chat-uploads/content": map[string]interface{}{
|
||||||
"get": map[string]interface{}{
|
"get": map[string]interface{}{
|
||||||
"tags": []string{"对话附件"},
|
"tags": []string{"对话附件"},
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -19,6 +21,22 @@ import (
|
|||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func withTempWorkingDir(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
old, err := os.Getwd()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
dir := t.TempDir()
|
||||||
|
if err := os.Chdir(dir); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_ = os.Chdir(old)
|
||||||
|
})
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
|
||||||
func TestDetachedAgentContextRetainsPrincipalWithoutParentCancellation(t *testing.T) {
|
func TestDetachedAgentContextRetainsPrincipalWithoutParentCancellation(t *testing.T) {
|
||||||
parent, cancel := context.WithCancel(context.Background())
|
parent, cancel := context.WithCancel(context.Background())
|
||||||
parent = authctx.WithPrincipal(parent, authctx.NewPrincipal("u1", "user", database.RBACScopeAssigned, map[string]bool{"agent:execute": true}))
|
parent = authctx.WithPrincipal(parent, authctx.NewPrincipal("u1", "user", database.RBACScopeAssigned, map[string]bool{"agent:execute": true}))
|
||||||
@@ -146,6 +164,132 @@ func TestChatUploadPathAuthorizationFollowsConversationAccess(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestChatUploadsListIncludesAuthorizedProjectWorkspaceFiles(t *testing.T) {
|
||||||
|
withTempWorkingDir(t)
|
||||||
|
db, user := setupConversationRBACTest(t)
|
||||||
|
fsBase := t.TempDir()
|
||||||
|
workspaceBase := filepath.Join(fsBase, "workspace")
|
||||||
|
reductionBase := filepath.Join(fsBase, "reduction")
|
||||||
|
db.SetEinoConversationDirs("", "", reductionBase, workspaceBase)
|
||||||
|
allowedProject, _ := db.CreateProject(&database.Project{Name: "allowed"})
|
||||||
|
hiddenProject, _ := db.CreateProject(&database.Project{Name: "hidden"})
|
||||||
|
conversation, _ := db.CreateConversation("project conversation", database.ConversationCreateMeta{ProjectID: allowedProject.ID})
|
||||||
|
if err := db.AssignResourceToUser(user.ID, "project", allowedProject.ID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
allowedFile := filepath.Join(workspaceBase, "projects", allowedProject.ID, "csv", "assets.csv")
|
||||||
|
hiddenFile := filepath.Join(workspaceBase, "projects", hiddenProject.ID, "csv", "secret.csv")
|
||||||
|
for _, path := range []string{allowedFile, hiddenFile} {
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, []byte("name\nexample\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
h := NewChatUploadsHandler(zap.NewNop(), db)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(w)
|
||||||
|
c.Request = httptest.NewRequest(http.MethodGet, "/api/chat-uploads?source=workspace&pageSize=all&conversation="+conversation.ID, nil)
|
||||||
|
c.Set(security.ContextSessionKey, security.Session{UserID: user.ID, Scope: database.RBACScopeAssigned})
|
||||||
|
h.List(c)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
var response struct {
|
||||||
|
Files []ChatUploadFileItem `json:"files"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if response.Total != 1 || len(response.Files) != 1 {
|
||||||
|
t.Fatalf("files = %#v, total = %d, want only authorized workspace file", response.Files, response.Total)
|
||||||
|
}
|
||||||
|
got := response.Files[0]
|
||||||
|
if got.Source != chatUploadSourceWorkspace || got.Name != "assets.csv" || got.ProjectID != allowedProject.ID {
|
||||||
|
t.Fatalf("workspace file = %#v", got)
|
||||||
|
}
|
||||||
|
if got.ProjectName != allowedProject.Name {
|
||||||
|
t.Fatalf("projectName = %q, want %q", got.ProjectName, allowedProject.Name)
|
||||||
|
}
|
||||||
|
if got.ConversationID != conversation.ID {
|
||||||
|
t.Fatalf("conversationId = %q, want %q", got.ConversationID, conversation.ID)
|
||||||
|
}
|
||||||
|
if got.ConversationTitle != conversation.Title {
|
||||||
|
t.Fatalf("conversationTitle = %q, want %q", got.ConversationTitle, conversation.Title)
|
||||||
|
}
|
||||||
|
if got.AbsolutePath != allowedFile {
|
||||||
|
t.Fatalf("absolutePath = %q, want %q", got.AbsolutePath, allowedFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
w = httptest.NewRecorder()
|
||||||
|
c, _ = gin.CreateTestContext(w)
|
||||||
|
resolveURL := "/api/chat-uploads/path?kind=directory&path=__workspace__%2Fprojects%2F" + allowedProject.ID
|
||||||
|
c.Request = httptest.NewRequest(http.MethodGet, resolveURL, nil)
|
||||||
|
c.Set(security.ContextSessionKey, security.Session{UserID: user.ID, Scope: database.RBACScopeAssigned})
|
||||||
|
h.ResolvePath(c)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("resolve status = %d, want 200: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
var resolved struct {
|
||||||
|
AbsolutePath string `json:"absolutePath"`
|
||||||
|
IsDir bool `json:"isDir"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &resolved); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
wantDir := filepath.Join(workspaceBase, "projects", allowedProject.ID)
|
||||||
|
if !resolved.IsDir || resolved.AbsolutePath != wantDir {
|
||||||
|
t.Fatalf("resolved = %#v, want dir %q", resolved, wantDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
w = httptest.NewRecorder()
|
||||||
|
c, _ = gin.CreateTestContext(w)
|
||||||
|
c.Request = httptest.NewRequest(http.MethodGet, "/api/chat-uploads/path?kind=directory&path=__workspace__%2Fprojects", nil)
|
||||||
|
c.Set(security.ContextSessionKey, security.Session{UserID: user.ID, Scope: database.RBACScopeAssigned})
|
||||||
|
h.ResolvePath(c)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("resolve projects container status = %d, want 200: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &resolved); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
wantContainer := filepath.Join(workspaceBase, "projects")
|
||||||
|
if !resolved.IsDir || resolved.AbsolutePath != wantContainer {
|
||||||
|
t.Fatalf("resolved container = %#v, want dir %q", resolved, wantContainer)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
path string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"__workspace__/", workspaceBase},
|
||||||
|
{"__reduction__/", reductionBase},
|
||||||
|
{"__conversation_artifact__/", db.ConversationArtifactsBaseDir()},
|
||||||
|
} {
|
||||||
|
if err := os.MkdirAll(tc.want, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
w = httptest.NewRecorder()
|
||||||
|
c, _ = gin.CreateTestContext(w)
|
||||||
|
c.Request = httptest.NewRequest(http.MethodGet, "/api/chat-uploads/path?kind=directory&path="+url.QueryEscape(tc.path), nil)
|
||||||
|
c.Set(security.ContextSessionKey, security.Session{UserID: user.ID, Scope: database.RBACScopeAssigned})
|
||||||
|
h.ResolvePath(c)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("resolve root %q status = %d, want 200: %s", tc.path, w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &resolved); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
wantAbs, _ := filepath.Abs(tc.want)
|
||||||
|
if !resolved.IsDir || resolved.AbsolutePath != wantAbs {
|
||||||
|
t.Fatalf("resolved root %q = %#v, want dir %q", tc.path, resolved, wantAbs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestPrepareMultiAgentSessionRejectsForeignConversation(t *testing.T) {
|
func TestPrepareMultiAgentSessionRejectsForeignConversation(t *testing.T) {
|
||||||
db, user := setupConversationRBACTest(t)
|
db, user := setupConversationRBACTest(t)
|
||||||
hidden, _ := db.CreateConversation("hidden", database.ConversationCreateMeta{})
|
hidden, _ := db.CreateConversation("hidden", database.ConversationCreateMeta{})
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package handler
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -46,6 +47,14 @@ type AgentTask struct {
|
|||||||
// hitlCognition 本轮运行中供 HITL/审计 Agent 读取的上下文(用户原话 + 思考,不含会话历史)
|
// hitlCognition 本轮运行中供 HITL/审计 Agent 读取的上下文(用户原话 + 思考,不含会话历史)
|
||||||
hitlCognition *hitlCognitionState
|
hitlCognition *hitlCognitionState
|
||||||
|
|
||||||
|
// agentRuntimeCancel 当前 Eino ADK 原生 AgentCancelFunc 包装;取消任务时先触发它,再走 context 兜底。
|
||||||
|
agentRuntimeCancel func(error) bool
|
||||||
|
agentRuntimeCancelVersion uint64
|
||||||
|
|
||||||
|
// agentTurnLoopInterrupt 当前 Eino TurnLoop 用户补充 push hook;中断并继续时优先将补充作为新 turn item 入队。
|
||||||
|
agentTurnLoopInterrupt func(string) bool
|
||||||
|
agentTurnLoopInterruptVersion uint64
|
||||||
|
|
||||||
cancel func(error)
|
cancel func(error)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -220,6 +229,58 @@ func (m *AgentTaskManager) BindTaskCancel(conversationID string, cancel context.
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BindAgentRuntimeCancel 登记当前运行段的 Eino 原生 cancel hook。
|
||||||
|
func (m *AgentTaskManager) BindAgentRuntimeCancel(conversationID string, cancel func(error) bool) func() {
|
||||||
|
conversationID = strings.TrimSpace(conversationID)
|
||||||
|
if conversationID == "" || cancel == nil {
|
||||||
|
return func() {}
|
||||||
|
}
|
||||||
|
m.mu.Lock()
|
||||||
|
t, ok := m.tasks[conversationID]
|
||||||
|
if !ok || t == nil {
|
||||||
|
m.mu.Unlock()
|
||||||
|
return func() {}
|
||||||
|
}
|
||||||
|
t.agentRuntimeCancelVersion++
|
||||||
|
version := t.agentRuntimeCancelVersion
|
||||||
|
t.agentRuntimeCancel = cancel
|
||||||
|
m.mu.Unlock()
|
||||||
|
|
||||||
|
return func() {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
if cur, exists := m.tasks[conversationID]; exists && cur != nil && cur.agentRuntimeCancelVersion == version {
|
||||||
|
cur.agentRuntimeCancel = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BindAgentTurnLoopInterrupt 登记当前运行任务的 Eino TurnLoop 用户补充入队 hook。
|
||||||
|
func (m *AgentTaskManager) BindAgentTurnLoopInterrupt(conversationID string, push func(string) bool) func() {
|
||||||
|
conversationID = strings.TrimSpace(conversationID)
|
||||||
|
if conversationID == "" || push == nil {
|
||||||
|
return func() {}
|
||||||
|
}
|
||||||
|
m.mu.Lock()
|
||||||
|
t, ok := m.tasks[conversationID]
|
||||||
|
if !ok || t == nil {
|
||||||
|
m.mu.Unlock()
|
||||||
|
return func() {}
|
||||||
|
}
|
||||||
|
t.agentTurnLoopInterruptVersion++
|
||||||
|
version := t.agentTurnLoopInterruptVersion
|
||||||
|
t.agentTurnLoopInterrupt = push
|
||||||
|
m.mu.Unlock()
|
||||||
|
|
||||||
|
return func() {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
if cur, exists := m.tasks[conversationID]; exists && cur != nil && cur.agentTurnLoopInterruptVersion == version {
|
||||||
|
cur.agentTurnLoopInterrupt = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ActiveMCPExecutionID 返回当前会话进行中的工具 executionId,无则空串。
|
// ActiveMCPExecutionID 返回当前会话进行中的工具 executionId,无则空串。
|
||||||
func (m *AgentTaskManager) ActiveMCPExecutionID(conversationID string) string {
|
func (m *AgentTaskManager) ActiveMCPExecutionID(conversationID string) string {
|
||||||
conversationID = strings.TrimSpace(conversationID)
|
conversationID = strings.TrimSpace(conversationID)
|
||||||
@@ -402,13 +463,32 @@ func (m *AgentTaskManager) CancelTask(conversationID string, cause error) (bool,
|
|||||||
if cause == nil {
|
if cause == nil {
|
||||||
cause = ErrTaskCancelled
|
cause = ErrTaskCancelled
|
||||||
}
|
}
|
||||||
|
interruptPush := task.agentTurnLoopInterrupt
|
||||||
|
interruptNote := task.InterruptContinueNote
|
||||||
|
runtimeCancel := task.agentRuntimeCancel
|
||||||
var toolCanceler func(string)
|
var toolCanceler func(string)
|
||||||
if errors.Is(cause, ErrTaskCancelled) {
|
if errors.Is(cause, ErrTaskCancelled) {
|
||||||
toolCanceler = m.toolCanceler
|
toolCanceler = m.toolCanceler
|
||||||
}
|
}
|
||||||
m.mu.Unlock()
|
m.mu.Unlock()
|
||||||
|
|
||||||
if cancel != nil {
|
if errors.Is(cause, multiagent.ErrInterruptContinue) && interruptPush != nil && interruptPush(interruptNote) {
|
||||||
|
m.mu.Lock()
|
||||||
|
if cur, exists := m.tasks[conversationID]; exists && cur != nil {
|
||||||
|
cur.InterruptContinueNote = ""
|
||||||
|
}
|
||||||
|
m.mu.Unlock()
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
runtimeHandled := false
|
||||||
|
if runtimeCancel != nil {
|
||||||
|
runtimeHandled = runtimeCancel(cause)
|
||||||
|
}
|
||||||
|
// 「彻底停止」必须同时取消宿主 context:原生 Agent Cancel 即使已受理,
|
||||||
|
// 也可能只在安全点返回或报告超时,不能据此让整条任务继续存活。
|
||||||
|
// 中断并继续仍保留原语义:原生取消已处理时由运行时负责恢复。
|
||||||
|
if cancel != nil && (!runtimeHandled || errors.Is(cause, ErrTaskCancelled)) {
|
||||||
cancel(cause)
|
cancel(cause)
|
||||||
}
|
}
|
||||||
if toolCanceler != nil {
|
if toolCanceler != nil {
|
||||||
@@ -515,6 +595,12 @@ func (m *AgentTaskManager) GetActiveTasks() []*AgentTask {
|
|||||||
Status: task.Status,
|
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
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGetActiveTasksUsesStableCreationOrder(t *testing.T) {
|
||||||
|
m := NewAgentTaskManager()
|
||||||
|
started := time.Date(2026, 8, 19, 10, 0, 0, 0, time.UTC)
|
||||||
|
m.mu.Lock()
|
||||||
|
m.tasks = map[string]*AgentTask{
|
||||||
|
"conversation-z": {ConversationID: "conversation-z", StartedAt: started, Status: "running"},
|
||||||
|
"conversation-late": {ConversationID: "conversation-late", StartedAt: started.Add(time.Minute), Status: "running"},
|
||||||
|
"conversation-a": {ConversationID: "conversation-a", StartedAt: started, Status: "running"},
|
||||||
|
}
|
||||||
|
m.mu.Unlock()
|
||||||
|
|
||||||
|
want := []string{"conversation-a", "conversation-z", "conversation-late"}
|
||||||
|
for attempt := 0; attempt < 20; attempt++ {
|
||||||
|
gotTasks := m.GetActiveTasks()
|
||||||
|
if len(gotTasks) != len(want) {
|
||||||
|
t.Fatalf("GetActiveTasks() length = %d, want %d", len(gotTasks), len(want))
|
||||||
|
}
|
||||||
|
for i, task := range gotTasks {
|
||||||
|
if task.ConversationID != want[i] {
|
||||||
|
t.Fatalf("attempt %d order[%d] = %q, want %q", attempt, i, task.ConversationID, want[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,6 +32,101 @@ func TestCancelTaskInvokesToolCancelerOnFullStop(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCancelTaskFullStopCancelsRuntimeAndParentContext(t *testing.T) {
|
||||||
|
tm := NewAgentTaskManager()
|
||||||
|
var order []string
|
||||||
|
tm.SetToolCanceler(func(conversationID string) {
|
||||||
|
if conversationID == "conv-native" {
|
||||||
|
order = append(order, "tool")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
_, cancel := context.WithCancelCause(context.Background())
|
||||||
|
if _, err := tm.StartTask("conv-native", "hello", func(err error) {
|
||||||
|
order = append(order, "context")
|
||||||
|
cancel(err)
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("StartTask: %v", err)
|
||||||
|
}
|
||||||
|
unregister := tm.BindAgentRuntimeCancel("conv-native", func(err error) bool {
|
||||||
|
if !errors.Is(err, ErrTaskCancelled) {
|
||||||
|
t.Fatalf("runtime cancel got %v", err)
|
||||||
|
}
|
||||||
|
order = append(order, "runtime")
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
defer unregister()
|
||||||
|
|
||||||
|
ok, err := tm.CancelTask("conv-native", ErrTaskCancelled)
|
||||||
|
if err != nil || !ok {
|
||||||
|
t.Fatalf("CancelTask: ok=%v err=%v", ok, err)
|
||||||
|
}
|
||||||
|
want := []string{"runtime", "context", "tool"}
|
||||||
|
if len(order) != len(want) {
|
||||||
|
t.Fatalf("order length got %d want %d: %#v", len(order), len(want), order)
|
||||||
|
}
|
||||||
|
for i := range want {
|
||||||
|
if order[i] != want[i] {
|
||||||
|
t.Fatalf("order[%d] got %q want %q; full=%#v", i, order[i], want[i], order)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCancelTaskInterruptContinueKeepsParentWhenRuntimeHandlesIt(t *testing.T) {
|
||||||
|
tm := NewAgentTaskManager()
|
||||||
|
ctx, cancel := context.WithCancelCause(context.Background())
|
||||||
|
if _, err := tm.StartTask("conv-interrupt-native", "hello", cancel); err != nil {
|
||||||
|
t.Fatalf("StartTask: %v", err)
|
||||||
|
}
|
||||||
|
unregister := tm.BindAgentRuntimeCancel("conv-interrupt-native", func(err error) bool {
|
||||||
|
if !errors.Is(err, multiagent.ErrInterruptContinue) {
|
||||||
|
t.Fatalf("runtime cancel got %v", err)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
defer unregister()
|
||||||
|
|
||||||
|
ok, err := tm.CancelTask("conv-interrupt-native", multiagent.ErrInterruptContinue)
|
||||||
|
if err != nil || !ok {
|
||||||
|
t.Fatalf("CancelTask: ok=%v err=%v", ok, err)
|
||||||
|
}
|
||||||
|
if cause := context.Cause(ctx); cause != nil {
|
||||||
|
t.Fatalf("interrupt-continue parent context cause = %v, want nil", cause)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCancelTaskFallsBackToContextWhenAgentRuntimeCancelMisses(t *testing.T) {
|
||||||
|
tm := NewAgentTaskManager()
|
||||||
|
var order []string
|
||||||
|
|
||||||
|
_, cancel := context.WithCancelCause(context.Background())
|
||||||
|
if _, err := tm.StartTask("conv-fallback", "hello", func(err error) {
|
||||||
|
order = append(order, "context")
|
||||||
|
cancel(err)
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("StartTask: %v", err)
|
||||||
|
}
|
||||||
|
unregister := tm.BindAgentRuntimeCancel("conv-fallback", func(err error) bool {
|
||||||
|
order = append(order, "runtime")
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
defer unregister()
|
||||||
|
|
||||||
|
ok, err := tm.CancelTask("conv-fallback", ErrTaskCancelled)
|
||||||
|
if err != nil || !ok {
|
||||||
|
t.Fatalf("CancelTask: ok=%v err=%v", ok, err)
|
||||||
|
}
|
||||||
|
want := []string{"runtime", "context"}
|
||||||
|
if len(order) != len(want) {
|
||||||
|
t.Fatalf("order length got %d want %d: %#v", len(order), len(want), order)
|
||||||
|
}
|
||||||
|
for i := range want {
|
||||||
|
if order[i] != want[i] {
|
||||||
|
t.Fatalf("order[%d] got %q want %q; full=%#v", i, order[i], want[i], order)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestCancelTaskSkipsToolCancelerOnInterruptContinue(t *testing.T) {
|
func TestCancelTaskSkipsToolCancelerOnInterruptContinue(t *testing.T) {
|
||||||
tm := NewAgentTaskManager()
|
tm := NewAgentTaskManager()
|
||||||
called := false
|
called := false
|
||||||
@@ -54,6 +149,80 @@ func TestCancelTaskSkipsToolCancelerOnInterruptContinue(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCancelTaskPushesInterruptContinueToTurnLoopFirst(t *testing.T) {
|
||||||
|
tm := NewAgentTaskManager()
|
||||||
|
ctx, cancel := context.WithCancelCause(context.Background())
|
||||||
|
if _, err := tm.StartTask("conv-turn", "hello", cancel); err != nil {
|
||||||
|
t.Fatalf("StartTask: %v", err)
|
||||||
|
}
|
||||||
|
tm.SetInterruptContinueNote("conv-turn", "focus ssh")
|
||||||
|
|
||||||
|
var gotNote string
|
||||||
|
unregister := tm.BindAgentTurnLoopInterrupt("conv-turn", func(note string) bool {
|
||||||
|
gotNote = note
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
defer unregister()
|
||||||
|
|
||||||
|
ok, err := tm.CancelTask("conv-turn", multiagent.ErrInterruptContinue)
|
||||||
|
if err != nil || !ok {
|
||||||
|
t.Fatalf("CancelTask: ok=%v err=%v", ok, err)
|
||||||
|
}
|
||||||
|
if gotNote != "focus ssh" {
|
||||||
|
t.Fatalf("turn loop note = %q, want focus ssh", gotNote)
|
||||||
|
}
|
||||||
|
if cause := context.Cause(ctx); cause != nil {
|
||||||
|
t.Fatalf("context should not be cancelled when turn loop accepted interrupt, got %v", cause)
|
||||||
|
}
|
||||||
|
if note := tm.TakeInterruptContinueNote("conv-turn"); note != "" {
|
||||||
|
t.Fatalf("interrupt note should be consumed after turn loop push, got %q", note)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCancelTaskFallsBackWhenTurnLoopInterruptRejects(t *testing.T) {
|
||||||
|
tm := NewAgentTaskManager()
|
||||||
|
var order []string
|
||||||
|
|
||||||
|
_, cancel := context.WithCancelCause(context.Background())
|
||||||
|
if _, err := tm.StartTask("conv-turn-fallback", "hello", func(err error) {
|
||||||
|
order = append(order, "context")
|
||||||
|
cancel(err)
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("StartTask: %v", err)
|
||||||
|
}
|
||||||
|
tm.SetInterruptContinueNote("conv-turn-fallback", "fallback note")
|
||||||
|
unregisterTurn := tm.BindAgentTurnLoopInterrupt("conv-turn-fallback", func(note string) bool {
|
||||||
|
order = append(order, "turn")
|
||||||
|
if note != "fallback note" {
|
||||||
|
t.Fatalf("turn loop note = %q, want fallback note", note)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
defer unregisterTurn()
|
||||||
|
unregisterRuntime := tm.BindAgentRuntimeCancel("conv-turn-fallback", func(err error) bool {
|
||||||
|
order = append(order, "runtime")
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
defer unregisterRuntime()
|
||||||
|
|
||||||
|
ok, err := tm.CancelTask("conv-turn-fallback", multiagent.ErrInterruptContinue)
|
||||||
|
if err != nil || !ok {
|
||||||
|
t.Fatalf("CancelTask: ok=%v err=%v", ok, err)
|
||||||
|
}
|
||||||
|
want := []string{"turn", "runtime", "context"}
|
||||||
|
if len(order) != len(want) {
|
||||||
|
t.Fatalf("order length got %d want %d: %#v", len(order), len(want), order)
|
||||||
|
}
|
||||||
|
for i := range want {
|
||||||
|
if order[i] != want[i] {
|
||||||
|
t.Fatalf("order[%d] got %q want %q; full=%#v", i, order[i], want[i], order)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if note := tm.TakeInterruptContinueNote("conv-turn-fallback"); note != "fallback note" {
|
||||||
|
t.Fatalf("interrupt note should remain for fallback rerun, got %q", note)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestCancelTaskDefaultCauseIsTaskCancelled(t *testing.T) {
|
func TestCancelTaskDefaultCauseIsTaskCancelled(t *testing.T) {
|
||||||
tm := NewAgentTaskManager()
|
tm := NewAgentTaskManager()
|
||||||
var gotCause error
|
var gotCause error
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package handler
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -47,6 +48,12 @@ type workflowDryRunRequest struct {
|
|||||||
Inputs map[string]interface{} `json:"inputs,omitempty"`
|
Inputs map[string]interface{} `json:"inputs,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type workflowGenerateDraftRequest struct {
|
||||||
|
Prompt string `json:"prompt"`
|
||||||
|
Options workflowrunner.DraftOptions `json:"options"`
|
||||||
|
AvailableTools []workflowrunner.DraftTool `json:"available_tools,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
func (h *WorkflowHandler) List(c *gin.Context) {
|
func (h *WorkflowHandler) List(c *gin.Context) {
|
||||||
includeDisabled := strings.EqualFold(c.Query("includeDisabled"), "true") || c.Query("include_disabled") == "1"
|
includeDisabled := strings.EqualFold(c.Query("includeDisabled"), "true") || c.Query("include_disabled") == "1"
|
||||||
items, err := h.db.ListWorkflowDefinitions(includeDisabled)
|
items, err := h.db.ListWorkflowDefinitions(includeDisabled)
|
||||||
@@ -126,6 +133,44 @@ func (h *WorkflowHandler) DryRun(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"result": result})
|
c.JSON(http.StatusOK, gin.H{"result": result})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *WorkflowHandler) GenerateDraft(c *gin.Context) {
|
||||||
|
var req workflowGenerateDraftRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的请求参数: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
draftReq := workflowrunner.DraftRequest{
|
||||||
|
Prompt: req.Prompt,
|
||||||
|
Options: req.Options,
|
||||||
|
AvailableTools: req.AvailableTools,
|
||||||
|
}
|
||||||
|
var result *workflowrunner.DraftResult
|
||||||
|
var llmErr error
|
||||||
|
if h.cfg != nil {
|
||||||
|
if llmCfg, _, ok := h.cfg.ResolveAIChannel(""); ok && strings.TrimSpace(llmCfg.APIKey) != "" && strings.TrimSpace(llmCfg.Model) != "" {
|
||||||
|
result, llmErr = workflowrunner.GenerateDraftFromLLM(c.Request.Context(), draftReq, llmCfg, h.logger)
|
||||||
|
} else {
|
||||||
|
llmErr = errors.New("AI 通道未配置 api_key 或 model")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
llmErr = errors.New("工作流生成器未加载平台 AI 配置")
|
||||||
|
}
|
||||||
|
if llmErr != nil {
|
||||||
|
c.JSON(http.StatusBadGateway, gin.H{"error": "大模型生成失败: " + llmErr.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if h.audit != nil {
|
||||||
|
h.audit.RecordOK(c, "workflow", "generate_draft", "自然语言生成工作流草稿", "", "", map[string]interface{}{
|
||||||
|
"generator": result.Generator,
|
||||||
|
"nodes": result.Stats["nodes"],
|
||||||
|
"edges": result.Stats["edges"],
|
||||||
|
"high_risk": result.Audit.HighRisk,
|
||||||
|
"savable": result.Audit.Savable,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"result": result})
|
||||||
|
}
|
||||||
|
|
||||||
func (h *WorkflowHandler) Update(c *gin.Context) {
|
func (h *WorkflowHandler) Update(c *gin.Context) {
|
||||||
h.save(c, c.Param("id"))
|
h.save(c, c.Param("id"))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -152,20 +152,37 @@ func (h *AgentHandler) runRoleWorkflowStreamIfBound(
|
|||||||
sendEvent("done", "", map[string]interface{}{"conversationId": conversationID})
|
sendEvent("done", "", map[string]interface{}{"conversationId": conversationID})
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
if prep.AssistantMessageID != "" {
|
decision := h.finalizeCandidateForDeliveryWithPolicy(
|
||||||
_ = h.db.UpdateAssistantMessageFinalize(prep.AssistantMessageID, result.Response, nil, "")
|
prep.ConversationID,
|
||||||
|
prep.AssistantMessageID,
|
||||||
|
"workflow",
|
||||||
|
result.Response,
|
||||||
|
nil,
|
||||||
|
result.AwaitingHITL,
|
||||||
|
"",
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
responseText := decision.FinalText
|
||||||
|
if !decision.Finalizable {
|
||||||
|
responseText = finalizationBlockedMessage(decision)
|
||||||
|
taskStatus = decision.Status
|
||||||
|
h.tasks.UpdateTaskStatus(conversationID, taskStatus)
|
||||||
|
sendEvent("finalization_check", responseText, decision)
|
||||||
}
|
}
|
||||||
payload := map[string]interface{}{
|
payload := finalizationResponsePayload(decision, map[string]interface{}{
|
||||||
"conversationId": prep.ConversationID,
|
"conversationId": prep.ConversationID,
|
||||||
"messageId": prep.AssistantMessageID,
|
"messageId": prep.AssistantMessageID,
|
||||||
"agentMode": "workflow",
|
"agentMode": "workflow",
|
||||||
"workflowRunId": result.RunID,
|
"workflowRunId": result.RunID,
|
||||||
}
|
})
|
||||||
if result.AwaitingHITL {
|
if result.AwaitingHITL {
|
||||||
payload["workflowStatus"] = "awaiting_hitl"
|
payload["workflowStatus"] = "awaiting_hitl"
|
||||||
payload["awaitingHitl"] = true
|
payload["awaitingHitl"] = true
|
||||||
|
} else {
|
||||||
|
payload["workflowStatus"] = result.Status
|
||||||
|
payload["awaitingHitl"] = false
|
||||||
}
|
}
|
||||||
sendEvent("response", result.Response, payload)
|
sendEvent("response", responseText, payload)
|
||||||
sendEvent("done", "", map[string]interface{}{"conversationId": prep.ConversationID})
|
sendEvent("done", "", map[string]interface{}{"conversationId": prep.ConversationID})
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -251,17 +268,37 @@ func (h *AgentHandler) runRoleWorkflowJSONIfBound(c *gin.Context, req *ChatReque
|
|||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": errMsg, "conversationId": conversationID})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": errMsg, "conversationId": conversationID})
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
if prep.AssistantMessageID != "" {
|
decision := h.finalizeCandidateForDeliveryWithPolicy(
|
||||||
_ = h.db.UpdateAssistantMessageFinalize(prep.AssistantMessageID, result.Response, nil, "")
|
prep.ConversationID,
|
||||||
|
prep.AssistantMessageID,
|
||||||
|
"workflow",
|
||||||
|
result.Response,
|
||||||
|
nil,
|
||||||
|
result.AwaitingHITL,
|
||||||
|
"",
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
responseText := decision.FinalText
|
||||||
|
if !decision.Finalizable {
|
||||||
|
responseText = finalizationBlockedMessage(decision)
|
||||||
|
taskStatus = decision.Status
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"response": result.Response,
|
"response": responseText,
|
||||||
"conversationId": prep.ConversationID,
|
"conversationId": prep.ConversationID,
|
||||||
"assistantMessageId": prep.AssistantMessageID,
|
"assistantMessageId": prep.AssistantMessageID,
|
||||||
"agentMode": "workflow",
|
"agentMode": "workflow",
|
||||||
"workflowRunId": result.RunID,
|
"workflowRunId": result.RunID,
|
||||||
"workflowStatus": result.Status,
|
"workflowStatus": result.Status,
|
||||||
"awaitingHitl": result.AwaitingHITL,
|
"awaitingHitl": result.AwaitingHITL,
|
||||||
|
"finalized": decision.Finalized,
|
||||||
|
"finalizable": decision.Finalizable,
|
||||||
|
"status": decision.Status,
|
||||||
|
"completionReason": decision.CompletionReason,
|
||||||
|
"evidenceVerified": decision.EvidenceVerified,
|
||||||
|
"evidenceRefs": decision.EvidenceRefs,
|
||||||
|
"pendingExecutionIds": decision.PendingExecutionIDs,
|
||||||
|
"missingChecks": decision.MissingChecks,
|
||||||
})
|
})
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,11 +7,13 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"cyberstrike-ai/internal/database"
|
"cyberstrike-ai/internal/database"
|
||||||
"cyberstrike-ai/internal/security"
|
"cyberstrike-ai/internal/security"
|
||||||
|
workflowrunner "cyberstrike-ai/internal/workflow"
|
||||||
workflowpkg "cyberstrike-ai/internal/workflow/package"
|
workflowpkg "cyberstrike-ai/internal/workflow/package"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -21,6 +23,7 @@ import (
|
|||||||
|
|
||||||
func TestWorkflowPackageHandlerInspectionAndCreateImport(t *testing.T) {
|
func TestWorkflowPackageHandlerInspectionAndCreateImport(t *testing.T) {
|
||||||
gin.SetMode(gin.TestMode)
|
gin.SetMode(gin.TestMode)
|
||||||
|
workflowrunner.SetCheckpointDir(filepath.Join(t.TempDir(), "workflow-checkpoints"))
|
||||||
db, err := database.NewDB(filepath.Join(t.TempDir(), "workflow-package-handler.db"), zap.NewNop())
|
db, err := database.NewDB(filepath.Join(t.TempDir(), "workflow-package-handler.db"), zap.NewNop())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -76,3 +79,26 @@ func TestWorkflowPackageHandlerInspectionAndCreateImport(t *testing.T) {
|
|||||||
t.Fatalf("saved=%#v", saved)
|
t.Fatalf("saved=%#v", saved)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestWorkflowHandlerGenerateDraft(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
h := NewWorkflowHandler(nil, zap.NewNop())
|
||||||
|
body := bytes.NewBufferString(`{"prompt":"对目标资产做端口扫描,如果发现高危端口就执行加固脚本,最后输出报告","options":{"include_objective":true},"available_tools":[{"key":"nmap","name":"nmap","enabled":true}]}`)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(w)
|
||||||
|
c.Request = httptest.NewRequest(http.MethodPost, "/api/workflows/generate-draft", body)
|
||||||
|
c.Request.Header.Set("Content-Type", "application/json")
|
||||||
|
h.GenerateDraft(c)
|
||||||
|
if w.Code != http.StatusBadGateway {
|
||||||
|
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
var resp struct {
|
||||||
|
Error string `json:"error"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(resp.Error, "大模型生成失败") {
|
||||||
|
t.Fatalf("unexpected error: %#v", resp.Error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,9 +8,11 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"cyberstrike-ai/internal/config"
|
"cyberstrike-ai/internal/config"
|
||||||
|
"cyberstrike-ai/internal/llm"
|
||||||
"cyberstrike-ai/internal/openai"
|
"cyberstrike-ai/internal/openai"
|
||||||
|
|
||||||
einoopenai "github.com/cloudwego/eino-ext/components/model/openai"
|
einoopenai "github.com/cloudwego/eino-ext/components/model/openai"
|
||||||
|
"github.com/cloudwego/eino/components/model"
|
||||||
"github.com/cloudwego/eino/flow/retriever/multiquery"
|
"github.com/cloudwego/eino/flow/retriever/multiquery"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
@@ -29,21 +31,38 @@ func WireRetrieverPipeline(ctx context.Context, r *Retriever, openAI *config.Ope
|
|||||||
}
|
}
|
||||||
r.wireOpenAI = openAI
|
r.wireOpenAI = openAI
|
||||||
|
|
||||||
httpClient := openai.NewEinoHTTPClient(openAI, &http.Client{Timeout: 120 * time.Second})
|
baseHTTPClient := &http.Client{Timeout: 120 * time.Second}
|
||||||
maxCompletionTokens := openAI.MaxCompletionTokensEffective()
|
var rewriteLLM model.ChatModel
|
||||||
chatCfg := &einoopenai.ChatModelConfig{
|
if llm.IsClaudeProvider(openAI.Provider) {
|
||||||
APIKey: strings.TrimSpace(openAI.APIKey),
|
nativeModel, err := llm.NewClaudeAgenticModel(
|
||||||
BaseURL: strings.TrimSuffix(strings.TrimSpace(openAI.BaseURL), "/"),
|
ctx,
|
||||||
Model: strings.TrimSpace(openAI.Model),
|
*openAI,
|
||||||
HTTPClient: httpClient,
|
baseHTTPClient,
|
||||||
MaxCompletionTokens: &maxCompletionTokens,
|
openAI.MaxCompletionTokensEffective(),
|
||||||
}
|
nil,
|
||||||
if chatCfg.Model == "" {
|
)
|
||||||
chatCfg.Model = "gpt-4o"
|
if err != nil {
|
||||||
}
|
return fmt.Errorf("multi_query native Claude rewrite model: %w", err)
|
||||||
rewriteLLM, err := einoopenai.NewChatModel(ctx, chatCfg)
|
}
|
||||||
if err != nil {
|
rewriteLLM = llm.NewAgenticChatModelAdapter(nativeModel)
|
||||||
return fmt.Errorf("multi_query rewrite model: %w", err)
|
} 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)
|
reranker, err := NewHTTPReranker(&r.config.Rerank, openAI, r.logger)
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
package llm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/cloudwego/eino/components/model"
|
||||||
|
"github.com/cloudwego/eino/schema"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AgenticChatModelAdapter exposes a text-oriented AgenticModel as a classic
|
||||||
|
// BaseChatModel for Eino components that have not adopted AgenticMessage yet.
|
||||||
|
// It adapts only Eino's in-memory message shape; no HTTP protocol is translated.
|
||||||
|
type AgenticChatModelAdapter struct {
|
||||||
|
model model.AgenticModel
|
||||||
|
tools []*schema.ToolInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAgenticChatModelAdapter(agenticModel model.AgenticModel) model.ChatModel {
|
||||||
|
return &AgenticChatModelAdapter{model: agenticModel}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *AgenticChatModelAdapter) BindTools(tools []*schema.ToolInfo) error {
|
||||||
|
if a == nil || a.model == nil {
|
||||||
|
return fmt.Errorf("agentic chat adapter: model is nil")
|
||||||
|
}
|
||||||
|
a.tools = append([]*schema.ToolInfo(nil), tools...)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *AgenticChatModelAdapter) Generate(
|
||||||
|
ctx context.Context,
|
||||||
|
input []*schema.Message,
|
||||||
|
opts ...model.Option,
|
||||||
|
) (*schema.Message, error) {
|
||||||
|
if a == nil || a.model == nil {
|
||||||
|
return nil, fmt.Errorf("agentic chat adapter: model is nil")
|
||||||
|
}
|
||||||
|
out, err := a.model.Generate(ctx, classicMessagesToAgentic(input), commonAgenticOptions(a.tools, opts...)...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return agenticMessageToClassic(out), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *AgenticChatModelAdapter) Stream(
|
||||||
|
ctx context.Context,
|
||||||
|
input []*schema.Message,
|
||||||
|
opts ...model.Option,
|
||||||
|
) (*schema.StreamReader[*schema.Message], error) {
|
||||||
|
if a == nil || a.model == nil {
|
||||||
|
return nil, fmt.Errorf("agentic chat adapter: model is nil")
|
||||||
|
}
|
||||||
|
stream, err := a.model.Stream(ctx, classicMessagesToAgentic(input), commonAgenticOptions(a.tools, opts...)...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return schema.StreamReaderWithConvert(stream, func(msg *schema.AgenticMessage) (*schema.Message, error) {
|
||||||
|
return agenticMessageToClassic(msg), nil
|
||||||
|
}), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func classicMessagesToAgentic(input []*schema.Message) []*schema.AgenticMessage {
|
||||||
|
out := make([]*schema.AgenticMessage, 0, len(input))
|
||||||
|
for _, msg := range input {
|
||||||
|
if msg == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
role := schema.AgenticRoleTypeUser
|
||||||
|
switch msg.Role {
|
||||||
|
case schema.System:
|
||||||
|
role = schema.AgenticRoleTypeSystem
|
||||||
|
case schema.Assistant:
|
||||||
|
role = schema.AgenticRoleTypeAssistant
|
||||||
|
}
|
||||||
|
agentic := &schema.AgenticMessage{Role: role}
|
||||||
|
if msg.Role == schema.Assistant {
|
||||||
|
if msg.Content != "" {
|
||||||
|
agentic.ContentBlocks = append(agentic.ContentBlocks, schema.NewContentBlock(&schema.AssistantGenText{Text: msg.Content}))
|
||||||
|
}
|
||||||
|
} else if msg.Content != "" {
|
||||||
|
agentic.ContentBlocks = append(agentic.ContentBlocks, schema.NewContentBlock(&schema.UserInputText{Text: msg.Content}))
|
||||||
|
}
|
||||||
|
out = append(out, agentic)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func agenticMessageToClassic(msg *schema.AgenticMessage) *schema.Message {
|
||||||
|
if msg == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
content, reasoning := AgenticText(msg)
|
||||||
|
return &schema.Message{
|
||||||
|
Role: schema.Assistant,
|
||||||
|
Content: content,
|
||||||
|
ReasoningContent: reasoning,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func commonAgenticOptions(boundTools []*schema.ToolInfo, opts ...model.Option) []model.Option {
|
||||||
|
common := model.GetCommonOptions(&model.Options{
|
||||||
|
Tools: append([]*schema.ToolInfo(nil), boundTools...),
|
||||||
|
}, opts...)
|
||||||
|
out := make([]model.Option, 0, 6)
|
||||||
|
if common.Temperature != nil {
|
||||||
|
out = append(out, model.WithTemperature(*common.Temperature))
|
||||||
|
}
|
||||||
|
if common.Model != nil {
|
||||||
|
out = append(out, model.WithModel(*common.Model))
|
||||||
|
}
|
||||||
|
if common.TopP != nil {
|
||||||
|
out = append(out, model.WithTopP(*common.TopP))
|
||||||
|
}
|
||||||
|
if common.MaxTokens != nil {
|
||||||
|
out = append(out, model.WithMaxTokens(*common.MaxTokens))
|
||||||
|
}
|
||||||
|
if len(common.Stop) > 0 {
|
||||||
|
out = append(out, model.WithStop(common.Stop))
|
||||||
|
}
|
||||||
|
if common.Tools != nil {
|
||||||
|
out = append(out, model.WithTools(common.Tools))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package llm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"cyberstrike-ai/internal/config"
|
||||||
|
|
||||||
|
agenticclaude "github.com/cloudwego/eino-ext/components/model/agenticclaude"
|
||||||
|
"github.com/cloudwego/eino/components/model"
|
||||||
|
"github.com/cloudwego/eino/schema"
|
||||||
|
)
|
||||||
|
|
||||||
|
func IsClaudeProvider(provider string) bool {
|
||||||
|
provider = strings.ToLower(strings.TrimSpace(provider))
|
||||||
|
return provider == "claude" || provider == "anthropic"
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewClaudeAgenticModel(
|
||||||
|
ctx context.Context,
|
||||||
|
cfg config.OpenAIConfig,
|
||||||
|
httpClient *http.Client,
|
||||||
|
maxTokens int,
|
||||||
|
extraFields map[string]any,
|
||||||
|
) (model.AgenticModel, error) {
|
||||||
|
if maxTokens <= 0 {
|
||||||
|
maxTokens = cfg.MaxCompletionTokensEffective()
|
||||||
|
}
|
||||||
|
if cfg.IsDeepSeekEndpointOrModel() {
|
||||||
|
httpClient = newDeepSeekAnthropicCompatibleClient(httpClient)
|
||||||
|
}
|
||||||
|
return agenticclaude.New(ctx, &agenticclaude.Config{
|
||||||
|
APIKey: strings.TrimSpace(cfg.APIKey),
|
||||||
|
BaseURL: strings.TrimSuffix(strings.TrimSpace(cfg.BaseURL), "/"),
|
||||||
|
Model: strings.TrimSpace(cfg.Model),
|
||||||
|
MaxTokens: maxTokens,
|
||||||
|
HTTPClient: httpClient,
|
||||||
|
ExtraFields: extraFields,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func AgenticText(msg *schema.AgenticMessage) (content, reasoning string) {
|
||||||
|
if msg == nil {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
var contentParts, reasoningParts []string
|
||||||
|
for _, block := range msg.ContentBlocks {
|
||||||
|
if block == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case block.AssistantGenText != nil:
|
||||||
|
contentParts = append(contentParts, block.AssistantGenText.Text)
|
||||||
|
case block.Reasoning != nil:
|
||||||
|
reasoningParts = append(reasoningParts, block.Reasoning.Text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(contentParts, ""), strings.Join(reasoningParts, "")
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package llm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newDeepSeekAnthropicCompatibleClient compensates for DeepSeek's Anthropic
|
||||||
|
// endpoint lagging behind the current Anthropic SDK. The SDK emits
|
||||||
|
// {"type":"custom"} for function tools, while DeepSeek expects the older
|
||||||
|
// name/input_schema/description shape without that discriminator.
|
||||||
|
//
|
||||||
|
// This is a field-level compatibility fix; requests still originate from
|
||||||
|
// Eino's native agenticclaude model and remain Anthropic Messages API requests.
|
||||||
|
func newDeepSeekAnthropicCompatibleClient(base *http.Client) *http.Client {
|
||||||
|
if base == nil {
|
||||||
|
base = http.DefaultClient
|
||||||
|
}
|
||||||
|
cloned := *base
|
||||||
|
transport := base.Transport
|
||||||
|
if transport == nil {
|
||||||
|
transport = http.DefaultTransport
|
||||||
|
}
|
||||||
|
cloned.Transport = &deepSeekAnthropicCompatRoundTripper{base: transport}
|
||||||
|
return &cloned
|
||||||
|
}
|
||||||
|
|
||||||
|
type deepSeekAnthropicCompatRoundTripper struct {
|
||||||
|
base http.RoundTripper
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rt *deepSeekAnthropicCompatRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||||
|
if req == nil || req.Body == nil || req.Method != http.MethodPost {
|
||||||
|
return rt.base.RoundTrip(req)
|
||||||
|
}
|
||||||
|
body, err := io.ReadAll(req.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read DeepSeek Anthropic request: %w", err)
|
||||||
|
}
|
||||||
|
_ = req.Body.Close()
|
||||||
|
|
||||||
|
var payload map[string]any
|
||||||
|
if err := json.Unmarshal(body, &payload); err != nil {
|
||||||
|
req.Body = io.NopCloser(bytes.NewReader(body))
|
||||||
|
return rt.base.RoundTrip(req)
|
||||||
|
}
|
||||||
|
tools, ok := payload["tools"].([]any)
|
||||||
|
if !ok {
|
||||||
|
req.Body = io.NopCloser(bytes.NewReader(body))
|
||||||
|
return rt.base.RoundTrip(req)
|
||||||
|
}
|
||||||
|
changed := false
|
||||||
|
for _, rawTool := range tools {
|
||||||
|
tool, ok := rawTool.(map[string]any)
|
||||||
|
if !ok || tool["type"] != "custom" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
delete(tool, "type")
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
if changed {
|
||||||
|
body, err = json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("marshal DeepSeek Anthropic request: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
req.Body = io.NopCloser(bytes.NewReader(body))
|
||||||
|
req.ContentLength = int64(len(body))
|
||||||
|
req.GetBody = func() (io.ReadCloser, error) {
|
||||||
|
return io.NopCloser(bytes.NewReader(body)), nil
|
||||||
|
}
|
||||||
|
return rt.base.RoundTrip(req)
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package llm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
type captureRoundTripper struct {
|
||||||
|
body string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rt *captureRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||||
|
body, err := io.ReadAll(req.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rt.body = string(body)
|
||||||
|
return &http.Response{
|
||||||
|
StatusCode: http.StatusOK,
|
||||||
|
Header: make(http.Header),
|
||||||
|
Body: io.NopCloser(bytes.NewReader(nil)),
|
||||||
|
Request: req,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeepSeekAnthropicCompatStripsOnlyCustomToolType(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
capture := &captureRoundTripper{}
|
||||||
|
client := newDeepSeekAnthropicCompatibleClient(&http.Client{Transport: capture})
|
||||||
|
req, err := http.NewRequest(
|
||||||
|
http.MethodPost,
|
||||||
|
"https://api.deepseek.com/anthropic/v1/messages",
|
||||||
|
strings.NewReader(`{"tools":[{"type":"custom","name":"mcp_tool","input_schema":{"type":"object"}},{"type":"web_search_20260209","name":"web_search"}]}`),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewRequest: %v", err)
|
||||||
|
}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Do: %v", err)
|
||||||
|
}
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
if strings.Contains(capture.body, `"type":"custom"`) {
|
||||||
|
t.Fatalf("custom discriminator was not removed: %s", capture.body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(capture.body, `"type":"web_search_20260209"`) {
|
||||||
|
t.Fatalf("server tool discriminator was removed: %s", capture.body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(capture.body, `"name":"mcp_tool"`) {
|
||||||
|
t.Fatalf("custom tool definition was removed: %s", capture.body)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -674,48 +674,6 @@ func (m *ExternalMCPManager) updateToolCache(name string, tools []Tool) {
|
|||||||
|
|
||||||
// CallTool 调用外部MCP工具(返回执行ID)
|
// CallTool 调用外部MCP工具(返回执行ID)
|
||||||
func (m *ExternalMCPManager) CallTool(ctx context.Context, toolName string, args map[string]interface{}) (*ToolResult, string, error) {
|
func (m *ExternalMCPManager) CallTool(ctx context.Context, toolName string, args map[string]interface{}) (*ToolResult, string, error) {
|
||||||
_, authenticated := authctx.PrincipalFromContext(ctx)
|
|
||||||
m.mu.RLock()
|
|
||||||
authorizer := m.toolAuthorizer
|
|
||||||
m.mu.RUnlock()
|
|
||||||
if authorizer != nil {
|
|
||||||
if err := authorizer(ctx, toolName, args); err != nil {
|
|
||||||
return nil, "", fmt.Errorf("external tool authorization denied: %w", err)
|
|
||||||
}
|
|
||||||
} else if authenticated {
|
|
||||||
return nil, "", fmt.Errorf("external tool authorization policy is not configured")
|
|
||||||
}
|
|
||||||
// 解析工具名称:name::toolName
|
|
||||||
var mcpName, actualToolName string
|
|
||||||
if idx := findSubstring(toolName, "::"); idx > 0 {
|
|
||||||
mcpName = toolName[:idx]
|
|
||||||
actualToolName = toolName[idx+2:]
|
|
||||||
} else {
|
|
||||||
return nil, "", fmt.Errorf("无效的工具名称格式: %s", toolName)
|
|
||||||
}
|
|
||||||
|
|
||||||
client, exists := m.GetClient(mcpName)
|
|
||||||
if !exists {
|
|
||||||
return nil, "", fmt.Errorf("外部MCP客户端不存在: %s", mcpName)
|
|
||||||
}
|
|
||||||
if err := m.checkExternalMCPCircuit(mcpName); err != nil {
|
|
||||||
return nil, "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查连接状态,如果未连接或状态为error,不允许调用
|
|
||||||
if !client.IsConnected() {
|
|
||||||
status := client.GetStatus()
|
|
||||||
if status == "error" {
|
|
||||||
// 获取错误信息(如果有)
|
|
||||||
errorMsg := m.GetError(mcpName)
|
|
||||||
if errorMsg != "" {
|
|
||||||
return nil, "", fmt.Errorf("外部MCP连接失败: %s (错误: %s)", mcpName, errorMsg)
|
|
||||||
}
|
|
||||||
return nil, "", fmt.Errorf("外部MCP连接失败: %s", mcpName)
|
|
||||||
}
|
|
||||||
return nil, "", fmt.Errorf("外部MCP客户端未连接: %s (状态: %s)", mcpName, status)
|
|
||||||
}
|
|
||||||
|
|
||||||
if m.executionService == nil {
|
if m.executionService == nil {
|
||||||
m.executionService = NewExecutionService(m.storage, m.logger)
|
m.executionService = NewExecutionService(m.storage, m.logger)
|
||||||
m.executionService.ConfigureToolResultMaxBytes(m.toolResultMaxBytes)
|
m.executionService.ConfigureToolResultMaxBytes(m.toolResultMaxBytes)
|
||||||
@@ -725,12 +683,57 @@ func (m *ExternalMCPManager) CallTool(ctx context.Context, toolName string, args
|
|||||||
if principal, ok := authctx.PrincipalFromContext(ctx); ok {
|
if principal, ok := authctx.PrincipalFromContext(ctx); ok {
|
||||||
ownerUserID = principal.UserID
|
ownerUserID = principal.UserID
|
||||||
}
|
}
|
||||||
|
var mcpName, actualToolName string
|
||||||
|
var client ExternalMCPClient
|
||||||
handle, err := m.executionService.Submit(ctx, ExecutionRequest{
|
handle, err := m.executionService.Submit(ctx, ExecutionRequest{
|
||||||
ToolName: toolName,
|
ToolName: toolName,
|
||||||
Arguments: args,
|
Arguments: args,
|
||||||
ConversationID: MCPConversationIDFromContext(ctx),
|
ConversationID: MCPConversationIDFromContext(ctx),
|
||||||
OwnerUserID: ownerUserID,
|
OwnerUserID: ownerUserID,
|
||||||
PreRun: func(runCtx context.Context, exec *ToolExecution) (func(), error) {
|
PreRun: func(runCtx context.Context, exec *ToolExecution) (func(), error) {
|
||||||
|
_, authenticated := authctx.PrincipalFromContext(runCtx)
|
||||||
|
m.mu.RLock()
|
||||||
|
authorizer := m.toolAuthorizer
|
||||||
|
m.mu.RUnlock()
|
||||||
|
if authorizer != nil {
|
||||||
|
if err := authorizer(runCtx, toolName, args); err != nil {
|
||||||
|
return nil, fmt.Errorf("external tool authorization denied: %w", err)
|
||||||
|
}
|
||||||
|
} else if authenticated {
|
||||||
|
return nil, fmt.Errorf("external tool authorization policy is not configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析工具名称:name::toolName
|
||||||
|
if idx := findSubstring(toolName, "::"); idx > 0 {
|
||||||
|
mcpName = toolName[:idx]
|
||||||
|
actualToolName = toolName[idx+2:]
|
||||||
|
} else {
|
||||||
|
return nil, fmt.Errorf("无效的工具名称格式: %s", toolName)
|
||||||
|
}
|
||||||
|
|
||||||
|
var exists bool
|
||||||
|
client, exists = m.GetClient(mcpName)
|
||||||
|
if !exists {
|
||||||
|
return nil, fmt.Errorf("外部MCP客户端不存在: %s", mcpName)
|
||||||
|
}
|
||||||
|
if err := m.checkExternalMCPCircuit(mcpName); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查连接状态,如果未连接或状态为error,不允许调用
|
||||||
|
if !client.IsConnected() {
|
||||||
|
status := client.GetStatus()
|
||||||
|
if status == "error" {
|
||||||
|
// 获取错误信息(如果有)
|
||||||
|
errorMsg := m.GetError(mcpName)
|
||||||
|
if errorMsg != "" {
|
||||||
|
return nil, fmt.Errorf("外部MCP连接失败: %s (错误: %s)", mcpName, errorMsg)
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("外部MCP连接失败: %s", mcpName)
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("外部MCP客户端未连接: %s (状态: %s)", mcpName, status)
|
||||||
|
}
|
||||||
|
|
||||||
release, acquireErr := m.acquireExternalMCPCallSlot(runCtx, mcpName)
|
release, acquireErr := m.acquireExternalMCPCallSlot(runCtx, mcpName)
|
||||||
if acquireErr != nil {
|
if acquireErr != nil {
|
||||||
return nil, acquireErr
|
return nil, acquireErr
|
||||||
@@ -746,7 +749,9 @@ func (m *ExternalMCPManager) CallTool(ctx context.Context, toolName string, args
|
|||||||
},
|
},
|
||||||
OnDone: func(exec *ToolExecution) {
|
OnDone: func(exec *ToolExecution) {
|
||||||
failed := exec != nil && exec.Status != ToolExecutionStatusCompleted && exec.Status != ToolExecutionStatusCancelled
|
failed := exec != nil && exec.Status != ToolExecutionStatusCompleted && exec.Status != ToolExecutionStatusCancelled
|
||||||
m.recordExternalMCPResult(mcpName, failed)
|
if mcpName != "" {
|
||||||
|
m.recordExternalMCPResult(mcpName, failed)
|
||||||
|
}
|
||||||
m.updateStats(toolName, failed)
|
m.updateStats(toolName, failed)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -20,10 +20,20 @@ func TestExternalManagerEnforcesConfiguredAuthorizer(t *testing.T) {
|
|||||||
return errors.New("denied by policy")
|
return errors.New("denied by policy")
|
||||||
})
|
})
|
||||||
ctx := authctx.WithPrincipal(context.Background(), authctx.NewPrincipal("u1", "user", "assigned", map[string]bool{"agent:execute": true}))
|
ctx := authctx.WithPrincipal(context.Background(), authctx.NewPrincipal("u1", "user", "assigned", map[string]bool{"agent:execute": true}))
|
||||||
_, _, err := manager.CallTool(ctx, "server::tool", map[string]interface{}{})
|
_, executionID, err := manager.CallTool(ctx, "server::tool", map[string]interface{}{})
|
||||||
if err == nil || !strings.Contains(err.Error(), "authorization denied") {
|
if err == nil || !strings.Contains(err.Error(), "authorization denied") {
|
||||||
t.Fatalf("external call bypassed authorizer: %v", err)
|
t.Fatalf("external call bypassed authorizer: %v", err)
|
||||||
}
|
}
|
||||||
|
if executionID == "" {
|
||||||
|
t.Fatal("denied external call should still return an execution id")
|
||||||
|
}
|
||||||
|
execution, ok := manager.GetExecution(executionID)
|
||||||
|
if !ok || execution == nil {
|
||||||
|
t.Fatalf("missing denied external execution %q", executionID)
|
||||||
|
}
|
||||||
|
if execution.Status != ToolExecutionStatusFailed || !strings.Contains(execution.Error, "denied by policy") {
|
||||||
|
t.Fatalf("denied external execution = %#v, want failed with policy error", execution)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestExternalMCPManager_AddOrUpdateConfig(t *testing.T) {
|
func TestExternalMCPManager_AddOrUpdateConfig(t *testing.T) {
|
||||||
|
|||||||
+15
-19
@@ -895,25 +895,6 @@ func (s *Server) GetAllTools() []Tool {
|
|||||||
|
|
||||||
// CallTool 直接调用工具(用于内部调用)
|
// CallTool 直接调用工具(用于内部调用)
|
||||||
func (s *Server) CallTool(ctx context.Context, toolName string, args map[string]interface{}) (*ToolResult, string, error) {
|
func (s *Server) CallTool(ctx context.Context, toolName string, args map[string]interface{}) (*ToolResult, string, error) {
|
||||||
_, authenticated := authctx.PrincipalFromContext(ctx)
|
|
||||||
s.mu.RLock()
|
|
||||||
authorizer := s.toolAuthorizer
|
|
||||||
s.mu.RUnlock()
|
|
||||||
if authorizer != nil {
|
|
||||||
if err := authorizer(ctx, toolName, args); err != nil {
|
|
||||||
return nil, "", fmt.Errorf("tool authorization denied: %w", err)
|
|
||||||
}
|
|
||||||
} else if authenticated {
|
|
||||||
return nil, "", errors.New("tool authorization policy is not configured")
|
|
||||||
}
|
|
||||||
s.mu.RLock()
|
|
||||||
handler, exists := s.tools[toolName]
|
|
||||||
s.mu.RUnlock()
|
|
||||||
|
|
||||||
if !exists {
|
|
||||||
return nil, "", fmt.Errorf("工具 %s 未找到", toolName)
|
|
||||||
}
|
|
||||||
|
|
||||||
if s.executionService == nil {
|
if s.executionService == nil {
|
||||||
s.executionService = NewExecutionService(s.storage, s.logger)
|
s.executionService = NewExecutionService(s.storage, s.logger)
|
||||||
s.executionService.ConfigureToolResultMaxBytes(s.toolResultMaxBytes)
|
s.executionService.ConfigureToolResultMaxBytes(s.toolResultMaxBytes)
|
||||||
@@ -929,6 +910,21 @@ func (s *Server) CallTool(ctx context.Context, toolName string, args map[string]
|
|||||||
ConversationID: MCPConversationIDFromContext(ctx),
|
ConversationID: MCPConversationIDFromContext(ctx),
|
||||||
OwnerUserID: ownerUserID,
|
OwnerUserID: ownerUserID,
|
||||||
Run: func(runCtx context.Context) (*ToolResult, error) {
|
Run: func(runCtx context.Context) (*ToolResult, error) {
|
||||||
|
_, authenticated := authctx.PrincipalFromContext(runCtx)
|
||||||
|
s.mu.RLock()
|
||||||
|
authorizer := s.toolAuthorizer
|
||||||
|
handler, exists := s.tools[toolName]
|
||||||
|
s.mu.RUnlock()
|
||||||
|
if authorizer != nil {
|
||||||
|
if err := authorizer(runCtx, toolName, args); err != nil {
|
||||||
|
return nil, fmt.Errorf("tool authorization denied: %w", err)
|
||||||
|
}
|
||||||
|
} else if authenticated {
|
||||||
|
return nil, errors.New("tool authorization policy is not configured")
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
return nil, fmt.Errorf("工具 %s 未找到", toolName)
|
||||||
|
}
|
||||||
return handler(runCtx, args)
|
return handler(runCtx, args)
|
||||||
},
|
},
|
||||||
OnDone: func(exec *ToolExecution) {
|
OnDone: func(exec *ToolExecution) {
|
||||||
|
|||||||
@@ -23,9 +23,20 @@ func TestToolAuthorizerIsUniversalAndExecutionKeepsOwner(t *testing.T) {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
if _, _, err := server.CallTool(context.Background(), "echo", nil); err == nil {
|
_, deniedExecutionID, err := server.CallTool(context.Background(), "echo", nil)
|
||||||
|
if err == nil {
|
||||||
t.Fatal("tool call without principal was allowed")
|
t.Fatal("tool call without principal was allowed")
|
||||||
}
|
}
|
||||||
|
if deniedExecutionID == "" {
|
||||||
|
t.Fatal("denied tool call should still return an execution id")
|
||||||
|
}
|
||||||
|
deniedExecution, ok := server.GetExecution(deniedExecutionID)
|
||||||
|
if !ok || deniedExecution == nil {
|
||||||
|
t.Fatalf("missing denied execution %q", deniedExecutionID)
|
||||||
|
}
|
||||||
|
if deniedExecution.Status != ToolExecutionStatusFailed || !strings.Contains(deniedExecution.Error, "principal required") {
|
||||||
|
t.Fatalf("denied execution = %#v, want failed with authorization error", deniedExecution)
|
||||||
|
}
|
||||||
ctx := authctx.WithPrincipal(context.Background(), authctx.NewPrincipal("u1", "user", "assigned", map[string]bool{"mcp:execute": true}))
|
ctx := authctx.WithPrincipal(context.Background(), authctx.NewPrincipal("u1", "user", "assigned", map[string]bool{"mcp:execute": true}))
|
||||||
_, executionID, err := server.CallTool(ctx, "echo", nil)
|
_, executionID, err := server.CallTool(ctx, "echo", nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
package multiagent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/cloudwego/eino/adk"
|
||||||
|
"github.com/cloudwego/eino/schema"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// agenticOrphanToolPrunerMiddleware is the AgenticMessage equivalent of
|
||||||
|
// orphanToolPrunerMiddleware. It removes user-role messages whose content
|
||||||
|
// blocks are exclusively FunctionToolResult entries with CallIDs that do not
|
||||||
|
// match any FunctionToolCall in the history.
|
||||||
|
//
|
||||||
|
// This is a defense-in-depth layer after agenticToolPairReconcilerMiddleware;
|
||||||
|
// the reconciler handles the common case (assistant followed by its results)
|
||||||
|
// while this pruner catches stray results that appear before their assistant
|
||||||
|
// or in non-adjacent positions (e.g. after summarization rewriting).
|
||||||
|
type agenticOrphanToolPrunerMiddleware struct {
|
||||||
|
*adk.TypedBaseChatModelAgentMiddleware[*schema.AgenticMessage]
|
||||||
|
logger *zap.Logger
|
||||||
|
phase string
|
||||||
|
}
|
||||||
|
|
||||||
|
func newAgenticOrphanToolPrunerMiddleware(logger *zap.Logger, phase string) adk.TypedChatModelAgentMiddleware[*schema.AgenticMessage] {
|
||||||
|
return &agenticOrphanToolPrunerMiddleware{
|
||||||
|
TypedBaseChatModelAgentMiddleware: &adk.TypedBaseChatModelAgentMiddleware[*schema.AgenticMessage]{},
|
||||||
|
logger: logger,
|
||||||
|
phase: phase,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *agenticOrphanToolPrunerMiddleware) BeforeModelRewriteState(
|
||||||
|
ctx context.Context,
|
||||||
|
state *adk.TypedChatModelAgentState[*schema.AgenticMessage],
|
||||||
|
mc *adk.TypedModelContext[*schema.AgenticMessage],
|
||||||
|
) (context.Context, *adk.TypedChatModelAgentState[*schema.AgenticMessage], error) {
|
||||||
|
_ = mc
|
||||||
|
if m == nil || state == nil || len(state.Messages) == 0 {
|
||||||
|
return ctx, state, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pass 1: collect all provided CallIDs from assistant FunctionToolCall blocks.
|
||||||
|
provided := make(map[string]struct{}, 8)
|
||||||
|
for _, msg := range state.Messages {
|
||||||
|
if msg == nil || msg.Role != schema.AgenticRoleTypeAssistant {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, block := range msg.ContentBlocks {
|
||||||
|
if block != nil && block.FunctionToolCall != nil && block.FunctionToolCall.CallID != "" {
|
||||||
|
provided[block.FunctionToolCall.CallID] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fast path: check if any orphan exists.
|
||||||
|
hasOrphan := false
|
||||||
|
for _, msg := range state.Messages {
|
||||||
|
if msg == nil || !isPureAgenticToolResult(msg) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, id := range agenticToolResultCallIDs(msg) {
|
||||||
|
if _, ok := provided[id]; !ok {
|
||||||
|
hasOrphan = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if hasOrphan {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !hasOrphan {
|
||||||
|
return ctx, state, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pass 2: build pruned list.
|
||||||
|
pruned := make([]*schema.AgenticMessage, 0, len(state.Messages))
|
||||||
|
var droppedIDs []string
|
||||||
|
var droppedNames []string
|
||||||
|
for _, msg := range state.Messages {
|
||||||
|
if msg == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !isPureAgenticToolResult(msg) {
|
||||||
|
pruned = append(pruned, msg)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Check if ALL result call IDs are orphans. If any is matched, keep the
|
||||||
|
// message (the reconciler already handled partial mismatches).
|
||||||
|
allOrphan := true
|
||||||
|
for _, id := range agenticToolResultCallIDs(msg) {
|
||||||
|
if _, ok := provided[id]; ok {
|
||||||
|
allOrphan = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if allOrphan {
|
||||||
|
for _, block := range msg.ContentBlocks {
|
||||||
|
if block != nil && block.FunctionToolResult != nil {
|
||||||
|
droppedIDs = append(droppedIDs, block.FunctionToolResult.CallID)
|
||||||
|
droppedNames = append(droppedNames, block.FunctionToolResult.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pruned = append(pruned, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(droppedIDs) == 0 {
|
||||||
|
return ctx, state, nil
|
||||||
|
}
|
||||||
|
if m.logger != nil {
|
||||||
|
m.logger.Warn("agentic orphan tool messages pruned before model call",
|
||||||
|
zap.String("phase", m.phase),
|
||||||
|
zap.Int("dropped_count", len(droppedIDs)),
|
||||||
|
zap.Strings("dropped_tool_call_ids", droppedIDs),
|
||||||
|
zap.Strings("dropped_tool_names", droppedNames),
|
||||||
|
zap.Int("messages_before", len(state.Messages)),
|
||||||
|
zap.Int("messages_after", len(pruned)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
ns := *state
|
||||||
|
ns.Messages = pruned
|
||||||
|
return ctx, &ns, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
package multiagent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/cloudwego/eino/adk"
|
||||||
|
"github.com/cloudwego/eino/schema"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// agenticToolPairReconcilerMiddleware is the AgenticMessage equivalent of
|
||||||
|
// toolPairReconcilerMiddleware. It ensures every assistant FunctionToolCall
|
||||||
|
// block is followed by a matching FunctionToolResult message, patching or
|
||||||
|
// dropping as needed so the downstream model never receives an unpaired
|
||||||
|
// tool-call history.
|
||||||
|
//
|
||||||
|
// In the AgenticMessage protocol:
|
||||||
|
// - Assistant tool calls: Role=AgenticRoleTypeAssistant with FunctionToolCall content blocks.
|
||||||
|
// - Tool results: Role=AgenticRoleTypeUser with FunctionToolResult content blocks.
|
||||||
|
//
|
||||||
|
// This middleware runs after summarization which may truncate history and
|
||||||
|
// break pairings.
|
||||||
|
type agenticToolPairReconcilerMiddleware struct {
|
||||||
|
*adk.TypedBaseChatModelAgentMiddleware[*schema.AgenticMessage]
|
||||||
|
logger *zap.Logger
|
||||||
|
phase string
|
||||||
|
}
|
||||||
|
|
||||||
|
func newAgenticToolPairReconcilerMiddleware(logger *zap.Logger, phase string) adk.TypedChatModelAgentMiddleware[*schema.AgenticMessage] {
|
||||||
|
return &agenticToolPairReconcilerMiddleware{
|
||||||
|
TypedBaseChatModelAgentMiddleware: &adk.TypedBaseChatModelAgentMiddleware[*schema.AgenticMessage]{},
|
||||||
|
logger: logger,
|
||||||
|
phase: phase,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *agenticToolPairReconcilerMiddleware) BeforeModelRewriteState(
|
||||||
|
ctx context.Context,
|
||||||
|
state *adk.TypedChatModelAgentState[*schema.AgenticMessage],
|
||||||
|
mc *adk.TypedModelContext[*schema.AgenticMessage],
|
||||||
|
) (context.Context, *adk.TypedChatModelAgentState[*schema.AgenticMessage], error) {
|
||||||
|
_ = mc
|
||||||
|
if m == nil || state == nil || len(state.Messages) == 0 {
|
||||||
|
return ctx, state, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
usedIDs := make(map[string]struct{}, 16)
|
||||||
|
changed := false
|
||||||
|
patched := 0
|
||||||
|
dropped := 0
|
||||||
|
out := make([]*schema.AgenticMessage, 0, len(state.Messages))
|
||||||
|
|
||||||
|
for i := 0; i < len(state.Messages); {
|
||||||
|
msg := state.Messages[i]
|
||||||
|
if msg == nil {
|
||||||
|
changed = true
|
||||||
|
i++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
calls := agenticFunctionToolCalls(msg)
|
||||||
|
|
||||||
|
// Non-assistant or assistant without tool calls — but check for orphan
|
||||||
|
// tool-result messages (user role with only FunctionToolResult blocks).
|
||||||
|
if len(calls) == 0 {
|
||||||
|
if isPureAgenticToolResult(msg) {
|
||||||
|
// Orphan tool result not preceded by its assistant; drop it.
|
||||||
|
changed = true
|
||||||
|
dropped++
|
||||||
|
i++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, msg)
|
||||||
|
i++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deduplicate / fix empty call IDs.
|
||||||
|
idsChanged := false
|
||||||
|
for ci := range calls {
|
||||||
|
id := calls[ci].CallID
|
||||||
|
_, duplicate := usedIDs[id]
|
||||||
|
if id == "" || duplicate {
|
||||||
|
base := fmt.Sprintf("patched_agentic_call_%d_%d", i, ci)
|
||||||
|
id = base
|
||||||
|
for suffix := 1; ; suffix++ {
|
||||||
|
if _, exists := usedIDs[id]; !exists {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
id = fmt.Sprintf("%s_%d", base, suffix)
|
||||||
|
}
|
||||||
|
calls[ci].CallID = id
|
||||||
|
idsChanged = true
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
usedIDs[id] = struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
assistant := msg
|
||||||
|
if idsChanged {
|
||||||
|
assistant = cloneAgenticMessageWithCalls(msg, calls)
|
||||||
|
}
|
||||||
|
out = append(out, assistant)
|
||||||
|
|
||||||
|
// Build expected set.
|
||||||
|
expected := make(map[string]*schema.FunctionToolCall, len(calls))
|
||||||
|
for ci := range calls {
|
||||||
|
expected[calls[ci].CallID] = calls[ci]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Consume following tool-result messages.
|
||||||
|
results := make(map[string]*schema.AgenticMessage, len(calls))
|
||||||
|
j := i + 1
|
||||||
|
for j < len(state.Messages) {
|
||||||
|
next := state.Messages[j]
|
||||||
|
if next == nil {
|
||||||
|
changed = true
|
||||||
|
j++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !isPureAgenticToolResult(next) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
resultCallIDs := agenticToolResultCallIDs(next)
|
||||||
|
consumed := false
|
||||||
|
for _, rid := range resultCallIDs {
|
||||||
|
if _, wanted := expected[rid]; !wanted {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, dup := results[rid]; dup {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
results[rid] = next
|
||||||
|
consumed = true
|
||||||
|
}
|
||||||
|
if !consumed {
|
||||||
|
changed = true
|
||||||
|
dropped++
|
||||||
|
}
|
||||||
|
j++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emit results in call order, patching missing ones.
|
||||||
|
for _, tc := range calls {
|
||||||
|
if result, ok := results[tc.CallID]; ok {
|
||||||
|
out = append(out, result)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, makeAgenticPatchedToolResult(tc.CallID, tc.Name))
|
||||||
|
changed = true
|
||||||
|
patched++
|
||||||
|
}
|
||||||
|
i = j
|
||||||
|
}
|
||||||
|
|
||||||
|
if !changed {
|
||||||
|
return ctx, state, nil
|
||||||
|
}
|
||||||
|
if m.logger != nil {
|
||||||
|
m.logger.Warn("agentic tool-call/result pairs reconciled before model call",
|
||||||
|
zap.String("phase", m.phase),
|
||||||
|
zap.Int("patched_results", patched),
|
||||||
|
zap.Int("dropped_results", dropped),
|
||||||
|
zap.Int("messages_before", len(state.Messages)),
|
||||||
|
zap.Int("messages_after", len(out)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
ns := *state
|
||||||
|
ns.Messages = out
|
||||||
|
return ctx, &ns, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// agenticFunctionToolCalls extracts FunctionToolCall pointers from an
|
||||||
|
// assistant message's content blocks. Returns nil for non-assistant messages.
|
||||||
|
func agenticFunctionToolCalls(msg *schema.AgenticMessage) []*schema.FunctionToolCall {
|
||||||
|
if msg == nil || msg.Role != schema.AgenticRoleTypeAssistant {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var out []*schema.FunctionToolCall
|
||||||
|
for _, block := range msg.ContentBlocks {
|
||||||
|
if block != nil && block.FunctionToolCall != nil {
|
||||||
|
out = append(out, block.FunctionToolCall)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// isPureAgenticToolResult returns true when the message is a user-role
|
||||||
|
// message whose content blocks are exclusively FunctionToolResult entries.
|
||||||
|
func isPureAgenticToolResult(msg *schema.AgenticMessage) bool {
|
||||||
|
if msg == nil || msg.Role != schema.AgenticRoleTypeUser || len(msg.ContentBlocks) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, block := range msg.ContentBlocks {
|
||||||
|
if block == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if block.FunctionToolResult == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// agenticToolResultCallIDs extracts all CallIDs from FunctionToolResult blocks.
|
||||||
|
func agenticToolResultCallIDs(msg *schema.AgenticMessage) []string {
|
||||||
|
if msg == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var ids []string
|
||||||
|
for _, block := range msg.ContentBlocks {
|
||||||
|
if block != nil && block.FunctionToolResult != nil && block.FunctionToolResult.CallID != "" {
|
||||||
|
ids = append(ids, block.FunctionToolResult.CallID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneAgenticMessageWithCalls(msg *schema.AgenticMessage, calls []*schema.FunctionToolCall) *schema.AgenticMessage {
|
||||||
|
cloned := *msg
|
||||||
|
cloned.ContentBlocks = make([]*schema.ContentBlock, 0, len(msg.ContentBlocks))
|
||||||
|
callIdx := 0
|
||||||
|
for _, block := range msg.ContentBlocks {
|
||||||
|
if block != nil && block.FunctionToolCall != nil && callIdx < len(calls) {
|
||||||
|
cloned.ContentBlocks = append(cloned.ContentBlocks, schema.NewContentBlock(calls[callIdx]))
|
||||||
|
callIdx++
|
||||||
|
} else {
|
||||||
|
cloned.ContentBlocks = append(cloned.ContentBlocks, block)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &cloned
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeAgenticPatchedToolResult(callID, name string) *schema.AgenticMessage {
|
||||||
|
return &schema.AgenticMessage{
|
||||||
|
Role: schema.AgenticRoleTypeUser,
|
||||||
|
ContentBlocks: []*schema.ContentBlock{schema.NewContentBlock(&schema.FunctionToolResult{
|
||||||
|
CallID: callID,
|
||||||
|
Name: name,
|
||||||
|
Content: []*schema.FunctionToolResultContentBlock{{
|
||||||
|
Type: schema.FunctionToolResultContentBlockTypeText,
|
||||||
|
Text: &schema.UserInputText{Text: patchedMissingToolResult},
|
||||||
|
}},
|
||||||
|
})},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
package multiagent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/cloudwego/eino/adk"
|
||||||
|
"github.com/cloudwego/eino/schema"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAgenticToolPairReconcilerPatchesMissing(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
mw := newAgenticToolPairReconcilerMiddleware(nil, "test")
|
||||||
|
state := &adk.TypedChatModelAgentState[*schema.AgenticMessage]{
|
||||||
|
Messages: []*schema.AgenticMessage{
|
||||||
|
agenticAssistantToolCall("c1", "search", `{"q":"x"}`),
|
||||||
|
agenticAssistantToolCall("c2", "execute", `{"cmd":"ls"}`),
|
||||||
|
// c1 result present, c2 missing
|
||||||
|
agenticToolResult("c1", "search", "found it"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
_, out, err := mw.BeforeModelRewriteState(context.Background(), state, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Expected: assistant(c1) -> result(c1) -> assistant(c2) -> patched_result(c2)
|
||||||
|
if len(out.Messages) != 4 {
|
||||||
|
t.Fatalf("messages = %d, want 4", len(out.Messages))
|
||||||
|
}
|
||||||
|
// c1 assistant
|
||||||
|
if calls := agenticFunctionToolCalls(out.Messages[0]); len(calls) != 1 || calls[0].CallID != "c1" {
|
||||||
|
t.Fatal("msg[0] should be assistant(c1)")
|
||||||
|
}
|
||||||
|
// c1 result
|
||||||
|
if ids := agenticToolResultCallIDs(out.Messages[1]); len(ids) != 1 || ids[0] != "c1" {
|
||||||
|
t.Fatal("msg[1] should be result(c1)")
|
||||||
|
}
|
||||||
|
// c2 assistant
|
||||||
|
if calls := agenticFunctionToolCalls(out.Messages[2]); len(calls) != 1 || calls[0].CallID != "c2" {
|
||||||
|
t.Fatal("msg[2] should be assistant(c2)")
|
||||||
|
}
|
||||||
|
// c2 patched result
|
||||||
|
if ids := agenticToolResultCallIDs(out.Messages[3]); len(ids) != 1 || ids[0] != "c2" {
|
||||||
|
t.Fatal("msg[3] should be patched result(c2)")
|
||||||
|
}
|
||||||
|
resultText := out.Messages[3].ContentBlocks[0].FunctionToolResult.Content[0].Text.Text
|
||||||
|
if resultText != patchedMissingToolResult {
|
||||||
|
t.Fatalf("patched text = %q", resultText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgenticToolPairReconcilerDropsOrphan(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
mw := newAgenticToolPairReconcilerMiddleware(nil, "test")
|
||||||
|
state := &adk.TypedChatModelAgentState[*schema.AgenticMessage]{
|
||||||
|
Messages: []*schema.AgenticMessage{
|
||||||
|
// Orphan tool result with no preceding assistant
|
||||||
|
agenticToolResult("orphan", "deleted_tool", "stale data"),
|
||||||
|
{Role: schema.AgenticRoleTypeUser, ContentBlocks: []*schema.ContentBlock{
|
||||||
|
schema.NewContentBlock(&schema.UserInputText{Text: "hello"}),
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
_, out, err := mw.BeforeModelRewriteState(context.Background(), state, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(out.Messages) != 1 {
|
||||||
|
t.Fatalf("messages = %d, want 1 (orphan dropped)", len(out.Messages))
|
||||||
|
}
|
||||||
|
if out.Messages[0].ContentBlocks[0].UserInputText == nil {
|
||||||
|
t.Fatal("remaining message should be the user text")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgenticToolPairReconcilerNoopWhenPaired(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
mw := newAgenticToolPairReconcilerMiddleware(nil, "test")
|
||||||
|
state := &adk.TypedChatModelAgentState[*schema.AgenticMessage]{
|
||||||
|
Messages: []*schema.AgenticMessage{
|
||||||
|
agenticAssistantToolCall("c1", "search", `{}`),
|
||||||
|
agenticToolResult("c1", "search", "ok"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
_, out, err := mw.BeforeModelRewriteState(context.Background(), state, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Should return original state unchanged
|
||||||
|
if &out.Messages[0] == &state.Messages[0] {
|
||||||
|
// pointer equality on slice — state not cloned
|
||||||
|
}
|
||||||
|
if len(out.Messages) != 2 {
|
||||||
|
t.Fatalf("messages = %d, want 2", len(out.Messages))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgenticToolPairReconcilerFixesEmptyCallID(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
mw := newAgenticToolPairReconcilerMiddleware(nil, "test")
|
||||||
|
state := &adk.TypedChatModelAgentState[*schema.AgenticMessage]{
|
||||||
|
Messages: []*schema.AgenticMessage{
|
||||||
|
{
|
||||||
|
Role: schema.AgenticRoleTypeAssistant,
|
||||||
|
ContentBlocks: []*schema.ContentBlock{schema.NewContentBlock(&schema.FunctionToolCall{
|
||||||
|
CallID: "", Name: "search", Arguments: `{}`,
|
||||||
|
})},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
_, out, err := mw.BeforeModelRewriteState(context.Background(), state, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
calls := agenticFunctionToolCalls(out.Messages[0])
|
||||||
|
if len(calls) != 1 || calls[0].CallID == "" {
|
||||||
|
t.Fatalf("empty call ID should be patched, got %q", calls[0].CallID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgenticOrphanToolPrunerRemovesOrphan(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
mw := newAgenticOrphanToolPrunerMiddleware(nil, "test")
|
||||||
|
state := &adk.TypedChatModelAgentState[*schema.AgenticMessage]{
|
||||||
|
Messages: []*schema.AgenticMessage{
|
||||||
|
agenticAssistantToolCall("c1", "search", `{}`),
|
||||||
|
agenticToolResult("c1", "search", "ok"),
|
||||||
|
// Orphan: no assistant has call_id "c_orphan"
|
||||||
|
agenticToolResult("c_orphan", "deleted", "stale"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
_, out, err := mw.BeforeModelRewriteState(context.Background(), state, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(out.Messages) != 2 {
|
||||||
|
t.Fatalf("messages = %d, want 2 (orphan pruned)", len(out.Messages))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgenticOrphanToolPrunerNoopWhenClean(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
mw := newAgenticOrphanToolPrunerMiddleware(nil, "test")
|
||||||
|
state := &adk.TypedChatModelAgentState[*schema.AgenticMessage]{
|
||||||
|
Messages: []*schema.AgenticMessage{
|
||||||
|
agenticAssistantToolCall("c1", "search", `{}`),
|
||||||
|
agenticToolResult("c1", "search", "ok"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
_, out, err := mw.BeforeModelRewriteState(context.Background(), state, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(out.Messages) != 2 {
|
||||||
|
t.Fatalf("messages = %d, want 2", len(out.Messages))
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -15,7 +15,7 @@ func TestRecvSchemaMessageStream_EOF(t *testing.T) {
|
|||||||
_ = sw.Send(schema.ToolMessage("hello", "tc-1"), nil)
|
_ = sw.Send(schema.ToolMessage("hello", "tc-1"), nil)
|
||||||
sw.Close()
|
sw.Close()
|
||||||
|
|
||||||
content, tid, err := recvSchemaMessageStream(context.Background(), sr)
|
content, tid, toolName, err := recvSchemaMessageStream(context.Background(), sr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("unexpected err: %v", err)
|
t.Fatalf("unexpected err: %v", err)
|
||||||
}
|
}
|
||||||
@@ -25,6 +25,46 @@ func TestRecvSchemaMessageStream_EOF(t *testing.T) {
|
|||||||
if tid != "tc-1" {
|
if tid != "tc-1" {
|
||||||
t.Fatalf("toolCallID=%q want tc-1", tid)
|
t.Fatalf("toolCallID=%q want tc-1", tid)
|
||||||
}
|
}
|
||||||
|
if toolName != "" {
|
||||||
|
t.Fatalf("toolName=%q want empty", toolName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecvSchemaToolResultMessages_SplitsParallelIDs(t *testing.T) {
|
||||||
|
sr, sw := schema.Pipe[*schema.Message](8)
|
||||||
|
_ = sw.Send(schema.ToolMessage("one-", "tc-1", schema.WithToolName("nmap")), nil)
|
||||||
|
_ = sw.Send(schema.ToolMessage("two-", "tc-2", schema.WithToolName("nmap")), nil)
|
||||||
|
_ = sw.Send(schema.ToolMessage("a", "tc-1", schema.WithToolName("nmap")), nil)
|
||||||
|
_ = sw.Send(schema.ToolMessage("b", "tc-2", schema.WithToolName("nmap")), nil)
|
||||||
|
sw.Close()
|
||||||
|
|
||||||
|
msgs, err := recvSchemaToolResultMessages(context.Background(), sr)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected err: %v", err)
|
||||||
|
}
|
||||||
|
if len(msgs) != 2 {
|
||||||
|
t.Fatalf("msgs = %#v, want 2", msgs)
|
||||||
|
}
|
||||||
|
if msgs[0].ToolCallID != "tc-1" || msgs[0].Content != "one-a" {
|
||||||
|
t.Fatalf("msg 0 = %#v", msgs[0])
|
||||||
|
}
|
||||||
|
if msgs[1].ToolCallID != "tc-2" || msgs[1].Content != "two-b" {
|
||||||
|
t.Fatalf("msg 1 = %#v", msgs[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecvSchemaMessageStream_CapturesToolName(t *testing.T) {
|
||||||
|
sr, sw := schema.Pipe[*schema.Message](4)
|
||||||
|
_ = sw.Send(schema.ToolMessage("hello", "tc-1", schema.WithToolName("execute")), nil)
|
||||||
|
sw.Close()
|
||||||
|
|
||||||
|
content, tid, toolName, err := recvSchemaMessageStream(context.Background(), sr)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected err: %v", err)
|
||||||
|
}
|
||||||
|
if content != "hello" || tid != "tc-1" || toolName != "execute" {
|
||||||
|
t.Fatalf("content=%q tid=%q toolName=%q", content, tid, toolName)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRecvSchemaMessageStream_ContextCancel(t *testing.T) {
|
func TestRecvSchemaMessageStream_ContextCancel(t *testing.T) {
|
||||||
@@ -37,7 +77,7 @@ func TestRecvSchemaMessageStream_ContextCancel(t *testing.T) {
|
|||||||
cancel()
|
cancel()
|
||||||
}()
|
}()
|
||||||
|
|
||||||
content, _, err := recvSchemaMessageStream(ctx, sr)
|
content, _, _, err := recvSchemaMessageStream(ctx, sr)
|
||||||
if !errors.Is(err, context.Canceled) {
|
if !errors.Is(err, context.Canceled) {
|
||||||
t.Fatalf("want context.Canceled, got %v content=%q", err, content)
|
t.Fatalf("want context.Canceled, got %v content=%q", err, content)
|
||||||
}
|
}
|
||||||
@@ -49,16 +89,16 @@ func TestRecvSchemaMessageStream_RecvError(t *testing.T) {
|
|||||||
_ = sw.Send(nil, want)
|
_ = sw.Send(nil, want)
|
||||||
sw.Close()
|
sw.Close()
|
||||||
|
|
||||||
_, _, err := recvSchemaMessageStream(context.Background(), sr)
|
_, _, _, err := recvSchemaMessageStream(context.Background(), sr)
|
||||||
if !errors.Is(err, want) {
|
if !errors.Is(err, want) {
|
||||||
t.Fatalf("want %v, got %v", want, err)
|
t.Fatalf("want %v, got %v", want, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRecvSchemaMessageStream_NilStream(t *testing.T) {
|
func TestRecvSchemaMessageStream_NilStream(t *testing.T) {
|
||||||
content, tid, err := recvSchemaMessageStream(context.Background(), nil)
|
content, tid, toolName, err := recvSchemaMessageStream(context.Background(), nil)
|
||||||
if err != nil || content != "" || tid != "" {
|
if err != nil || content != "" || tid != "" || toolName != "" {
|
||||||
t.Fatalf("nil stream: content=%q tid=%q err=%v", content, tid, err)
|
t.Fatalf("nil stream: content=%q tid=%q toolName=%q err=%v", content, tid, toolName, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,8 +107,39 @@ func TestRecvSchemaMessageStream_EOFViaEmptyRead(t *testing.T) {
|
|||||||
_ = sw.Send(nil, io.EOF)
|
_ = sw.Send(nil, io.EOF)
|
||||||
sw.Close()
|
sw.Close()
|
||||||
|
|
||||||
_, _, err := recvSchemaMessageStream(context.Background(), sr)
|
_, _, _, err := recvSchemaMessageStream(context.Background(), sr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("EOF should not surface as error, got %v", err)
|
t.Fatalf("EOF should not surface as error, got %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRecvEinoSchemaMessageStreamWithContext_SkipsNilChunks(t *testing.T) {
|
||||||
|
sr, sw := schema.Pipe[*schema.Message](4)
|
||||||
|
_ = sw.Send(nil, nil)
|
||||||
|
_ = sw.Send(schema.AssistantMessage("hello", nil), nil)
|
||||||
|
sw.Close()
|
||||||
|
|
||||||
|
var got []string
|
||||||
|
err := recvEinoSchemaMessageStreamWithContext(context.Background(), sr, 1, func(chunk *schema.Message) {
|
||||||
|
got = append(got, chunk.Content)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected err: %v", err)
|
||||||
|
}
|
||||||
|
if len(got) != 1 || got[0] != "hello" {
|
||||||
|
t.Fatalf("chunks = %#v, want [hello]", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecvEinoSchemaMessageStreamWithContext_NilStream(t *testing.T) {
|
||||||
|
called := false
|
||||||
|
err := recvEinoSchemaMessageStreamWithContext(context.Background(), nil, 0, func(*schema.Message) {
|
||||||
|
called = true
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("nil stream should not error, got %v", err)
|
||||||
|
}
|
||||||
|
if called {
|
||||||
|
t.Fatal("nil stream should not call handler")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package multiagent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/cloudwego/eino/adk"
|
||||||
|
"github.com/cloudwego/eino/schema"
|
||||||
|
)
|
||||||
|
|
||||||
|
type einoAgenticMessageAgentAdapter struct {
|
||||||
|
inner adk.TypedAgent[*schema.AgenticMessage]
|
||||||
|
}
|
||||||
|
|
||||||
|
func newEinoAgenticMessageAgentAdapter(inner adk.TypedAgent[*schema.AgenticMessage]) adk.Agent {
|
||||||
|
if inner == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &einoAgenticMessageAgentAdapter{inner: inner}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *einoAgenticMessageAgentAdapter) Name(ctx context.Context) string {
|
||||||
|
if a == nil || a.inner == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return a.inner.Name(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *einoAgenticMessageAgentAdapter) Description(ctx context.Context) string {
|
||||||
|
if a == nil || a.inner == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return a.inner.Description(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *einoAgenticMessageAgentAdapter) Run(ctx context.Context, input *adk.AgentInput, opts ...adk.AgentRunOption) *adk.AsyncIterator[*adk.AgentEvent] {
|
||||||
|
return a.runTyped(ctx, input, nil, opts...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *einoAgenticMessageAgentAdapter) Resume(ctx context.Context, info *adk.ResumeInfo, opts ...adk.AgentRunOption) *adk.AsyncIterator[*adk.AgentEvent] {
|
||||||
|
return a.runTyped(ctx, nil, info, opts...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *einoAgenticMessageAgentAdapter) runTyped(ctx context.Context, input *adk.AgentInput, resumeInfo *adk.ResumeInfo, opts ...adk.AgentRunOption) *adk.AsyncIterator[*adk.AgentEvent] {
|
||||||
|
iter, gen := adk.NewAsyncIteratorPair[*adk.AgentEvent]()
|
||||||
|
go func() {
|
||||||
|
defer gen.Close()
|
||||||
|
if a == nil || a.inner == nil {
|
||||||
|
gen.Send(&adk.AgentEvent{Err: fmt.Errorf("agentic adapter: inner agent is nil")})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var agenticIter *adk.AsyncIterator[*adk.TypedAgentEvent[*schema.AgenticMessage]]
|
||||||
|
if resumeInfo != nil {
|
||||||
|
resumable, ok := a.inner.(adk.TypedResumableAgent[*schema.AgenticMessage])
|
||||||
|
if !ok {
|
||||||
|
gen.Send(&adk.AgentEvent{Err: fmt.Errorf("agentic adapter: inner agent does not support resume")})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
agenticIter = resumable.Resume(ctx, resumeInfo, opts...)
|
||||||
|
} else {
|
||||||
|
agenticInput := &adk.TypedAgentInput[*schema.AgenticMessage]{}
|
||||||
|
if input != nil {
|
||||||
|
agenticInput.EnableStreaming = input.EnableStreaming
|
||||||
|
agenticInput.Messages = EinoMessagesToAgentic(input.Messages)
|
||||||
|
}
|
||||||
|
agenticIter = a.inner.Run(ctx, agenticInput, opts...)
|
||||||
|
}
|
||||||
|
for {
|
||||||
|
ev, ok := agenticIter.Next()
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, adapted := range adaptAgenticEventToEinoEvents(ev) {
|
||||||
|
if adapted != nil {
|
||||||
|
gen.Send(adapted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return iter
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
package multiagent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/cloudwego/eino/adk"
|
||||||
|
"github.com/cloudwego/eino/schema"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fakeAgenticMessageAgent struct {
|
||||||
|
name string
|
||||||
|
description string
|
||||||
|
captured *adk.TypedAgentInput[*schema.AgenticMessage]
|
||||||
|
resumeInfo *adk.ResumeInfo
|
||||||
|
events []*adk.TypedAgentEvent[*schema.AgenticMessage]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeAgenticMessageAgent) Name(context.Context) string {
|
||||||
|
return f.name
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeAgenticMessageAgent) Description(context.Context) string {
|
||||||
|
return f.description
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeAgenticMessageAgent) Run(_ context.Context, input *adk.TypedAgentInput[*schema.AgenticMessage], _ ...adk.AgentRunOption) *adk.AsyncIterator[*adk.TypedAgentEvent[*schema.AgenticMessage]] {
|
||||||
|
f.captured = input
|
||||||
|
iter, gen := adk.NewAsyncIteratorPair[*adk.TypedAgentEvent[*schema.AgenticMessage]]()
|
||||||
|
go func() {
|
||||||
|
defer gen.Close()
|
||||||
|
for _, ev := range f.events {
|
||||||
|
gen.Send(ev)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return iter
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeAgenticMessageAgent) Resume(_ context.Context, info *adk.ResumeInfo, _ ...adk.AgentRunOption) *adk.AsyncIterator[*adk.TypedAgentEvent[*schema.AgenticMessage]] {
|
||||||
|
f.resumeInfo = info
|
||||||
|
iter, gen := adk.NewAsyncIteratorPair[*adk.TypedAgentEvent[*schema.AgenticMessage]]()
|
||||||
|
go func() {
|
||||||
|
defer gen.Close()
|
||||||
|
for _, ev := range f.events {
|
||||||
|
gen.Send(ev)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return iter
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEinoAgenticMessageAgentAdapterConvertsInputAndEvents(t *testing.T) {
|
||||||
|
inner := &fakeAgenticMessageAgent{
|
||||||
|
name: "agentic",
|
||||||
|
description: "typed agent",
|
||||||
|
events: []*adk.TypedAgentEvent[*schema.AgenticMessage]{
|
||||||
|
{
|
||||||
|
AgentName: "agentic",
|
||||||
|
Output: &adk.TypedAgentOutput[*schema.AgenticMessage]{
|
||||||
|
MessageOutput: &adk.TypedMessageVariant[*schema.AgenticMessage]{
|
||||||
|
Message: &schema.AgenticMessage{
|
||||||
|
Role: schema.AgenticRoleTypeAssistant,
|
||||||
|
ContentBlocks: []*schema.ContentBlock{
|
||||||
|
schema.NewContentBlock(&schema.AssistantGenText{Text: "hello"}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
agent := newEinoAgenticMessageAgentAdapter(inner)
|
||||||
|
|
||||||
|
if agent.Name(context.Background()) != "agentic" || agent.Description(context.Background()) != "typed agent" {
|
||||||
|
t.Fatalf("adapter metadata name=%q desc=%q", agent.Name(context.Background()), agent.Description(context.Background()))
|
||||||
|
}
|
||||||
|
iter := agent.Run(context.Background(), &adk.AgentInput{
|
||||||
|
EnableStreaming: true,
|
||||||
|
Messages: []*schema.Message{
|
||||||
|
schema.UserMessage("hi"),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
ev, ok := iter.Next()
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected adapted event")
|
||||||
|
}
|
||||||
|
if inner.captured == nil || !inner.captured.EnableStreaming || len(inner.captured.Messages) != 1 {
|
||||||
|
t.Fatalf("captured input = %#v", inner.captured)
|
||||||
|
}
|
||||||
|
if inner.captured.Messages[0].Role != schema.AgenticRoleTypeUser || inner.captured.Messages[0].ContentBlocks[0].UserInputText.Text != "hi" {
|
||||||
|
t.Fatalf("captured message = %#v", inner.captured.Messages[0])
|
||||||
|
}
|
||||||
|
if ev.AgentName != "agentic" || ev.Output == nil || ev.Output.MessageOutput == nil {
|
||||||
|
t.Fatalf("event = %#v", ev)
|
||||||
|
}
|
||||||
|
if ev.Output.MessageOutput.Role != schema.Assistant || ev.Output.MessageOutput.Message.Content != "hello" {
|
||||||
|
t.Fatalf("message output = %#v", ev.Output.MessageOutput)
|
||||||
|
}
|
||||||
|
if _, ok := iter.Next(); ok {
|
||||||
|
t.Fatal("expected iterator to close")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEinoAgenticMessageAgentAdapterNilInnerReturnsNil(t *testing.T) {
|
||||||
|
if got := newEinoAgenticMessageAgentAdapter(nil); got != nil {
|
||||||
|
t.Fatalf("adapter = %#v, want nil", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEinoAgenticMessageAgentAdapterResumeConvertsEvents(t *testing.T) {
|
||||||
|
inner := &fakeAgenticMessageAgent{
|
||||||
|
name: "agentic",
|
||||||
|
events: []*adk.TypedAgentEvent[*schema.AgenticMessage]{
|
||||||
|
{
|
||||||
|
AgentName: "agentic",
|
||||||
|
Output: &adk.TypedAgentOutput[*schema.AgenticMessage]{
|
||||||
|
MessageOutput: &adk.TypedMessageVariant[*schema.AgenticMessage]{
|
||||||
|
Message: &schema.AgenticMessage{
|
||||||
|
Role: schema.AgenticRoleTypeAssistant,
|
||||||
|
ContentBlocks: []*schema.ContentBlock{
|
||||||
|
schema.NewContentBlock(&schema.AssistantGenText{Text: "resumed"}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
agent, ok := newEinoAgenticMessageAgentAdapter(inner).(adk.ResumableAgent)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("adapter must implement adk.ResumableAgent")
|
||||||
|
}
|
||||||
|
info := &adk.ResumeInfo{WasInterrupted: true}
|
||||||
|
iter := agent.Resume(context.Background(), info)
|
||||||
|
ev, ok := iter.Next()
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected adapted resume event")
|
||||||
|
}
|
||||||
|
if inner.resumeInfo != info {
|
||||||
|
t.Fatalf("resume info = %#v, want original pointer", inner.resumeInfo)
|
||||||
|
}
|
||||||
|
if ev.Output == nil || ev.Output.MessageOutput == nil || ev.Output.MessageOutput.Message.Content != "resumed" {
|
||||||
|
t.Fatalf("resume event = %#v", ev)
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user