Compare commits

..

5 Commits

Author SHA1 Message Date
公明 0183d9f15f Add files via upload 2026-04-08 18:14:22 +08:00
公明 7d7207c12f Update config.yaml 2026-04-08 16:58:20 +08:00
公明 9eb47d96f5 Add files via upload 2026-04-08 00:18:07 +08:00
公明 cf1c9c199c Update server.go 2026-04-07 11:51:35 +08:00
公明 ce5f20c11e Add files via upload 2026-04-04 15:05:38 +08:00
8 changed files with 288 additions and 28 deletions
+1 -1
View File
@@ -10,7 +10,7 @@
# ============================================
# 前端显示的版本号(可选,不填则显示默认版本)
version: "v1.4.9"
version: "v1.4.10"
# 服务器配置
server:
host: 0.0.0.0 # 监听地址,0.0.0.0 表示监听所有网络接口
Binary file not shown.

Before

Width:  |  Height:  |  Size: 178 KiB

After

Width:  |  Height:  |  Size: 182 KiB

+7 -1
View File
@@ -108,7 +108,13 @@ func runMCPToolInvocation(
var args map[string]interface{}
if argumentsInJSON != "" && argumentsInJSON != "null" {
if err := json.Unmarshal([]byte(argumentsInJSON), &args); err != nil {
return "", fmt.Errorf("invalid tool arguments JSON: %w", err)
// Return soft error (nil error) so the eino graph continues and the LLM can self-correct,
// instead of a hard error that terminates the iteration loop.
return ToolErrorPrefix + fmt.Sprintf(
"Invalid tool arguments JSON: %s\n\nPlease ensure the arguments are a valid JSON object "+
"(double-quoted keys, matched braces, no trailing commas) and retry.\n\n"+
"(工具参数 JSON 解析失败:%s。请确保 arguments 是合法的 JSON 对象并重试。)",
err.Error(), err.Error()), nil
}
}
if args == nil {
+1 -1
View File
@@ -444,7 +444,7 @@ func (s *Server) handleCallTool(msg *Message) *Message {
}
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
defer cancel()
s.logger.Info("开始执行工具",
+44 -22
View File
@@ -302,34 +302,20 @@ func RunDeepAgent(
var lastRunMsgs []adk.Message
var lastAssistant string
// retryHints tracks the corrective hint to append for each retry attempt.
// Index i corresponds to the hint that will be appended on attempt i+1.
var retryHints []adk.Message
attemptLoop:
for attempt := 0; attempt < maxToolCallArgumentsJSONAttempts; attempt++ {
msgs := make([]adk.Message, 0, len(baseMsgs)+attempt)
for attempt := 0; attempt < maxToolCallRecoveryAttempts; attempt++ {
msgs := make([]adk.Message, 0, len(baseMsgs)+len(retryHints))
msgs = append(msgs, baseMsgs...)
for i := 0; i < attempt; i++ {
msgs = append(msgs, toolCallArgumentsJSONRetryHint())
}
msgs = append(msgs, retryHints...)
if attempt > 0 {
mcpIDsMu.Lock()
mcpIDs = mcpIDs[:0]
mcpIDsMu.Unlock()
if logger != nil {
logger.Warn("eino DeepAgent: 工具参数 JSON 被接口拒绝,追加提示后重试",
zap.Int("attempt", attempt),
zap.Int("maxAttempts", maxToolCallArgumentsJSONAttempts))
}
if progress != nil {
// 使用专用事件类型 eino_recovery,便于前端时间线展示(progress 仅改标题,不进时间线)
progress("eino_recovery", toolCallArgumentsJSONRecoveryTimelineMessage(attempt), map[string]interface{}{
"conversationId": conversationID,
"source": "eino",
"einoRetry": attempt,
"runIndex": attempt + 1, // 第几轮完整运行(1 为首次,重试后递增)
"maxRuns": maxToolCallArgumentsJSONAttempts,
"reason": "invalid_tool_arguments_json",
})
}
}
// 仅保留主代理最后一次 assistant 输出;每轮重试重置,避免拼接失败轮次的片段。
@@ -357,12 +343,48 @@ attemptLoop:
continue
}
if ev.Err != nil {
if isRecoverableToolCallArgumentsJSONError(ev.Err) && attempt+1 < maxToolCallArgumentsJSONAttempts {
canRetry := attempt+1 < maxToolCallRecoveryAttempts
// Recoverable: API-level JSON argument validation error.
if canRetry && isRecoverableToolCallArgumentsJSONError(ev.Err) {
if logger != nil {
logger.Warn("eino: recoverable tool-call JSON error from model/API", zap.Error(ev.Err), zap.Int("attempt", attempt))
}
retryHints = append(retryHints, toolCallArgumentsJSONRetryHint())
if progress != nil {
progress("eino_recovery", toolCallArgumentsJSONRecoveryTimelineMessage(attempt), map[string]interface{}{
"conversationId": conversationID,
"source": "eino",
"einoRetry": attempt,
"runIndex": attempt + 1,
"maxRuns": maxToolCallRecoveryAttempts,
"reason": "invalid_tool_arguments_json",
})
}
continue attemptLoop
}
// Recoverable: tool execution error (unknown sub-agent, tool not found, bad JSON in args, etc.).
if canRetry && isRecoverableToolExecutionError(ev.Err) {
if logger != nil {
logger.Warn("eino: recoverable tool execution error, will retry with corrective hint",
zap.Error(ev.Err), zap.Int("attempt", attempt))
}
retryHints = append(retryHints, toolExecutionRetryHint())
if progress != nil {
progress("eino_recovery", toolExecutionRecoveryTimelineMessage(attempt), map[string]interface{}{
"conversationId": conversationID,
"source": "eino",
"einoRetry": attempt,
"runIndex": attempt + 1,
"maxRuns": maxToolCallRecoveryAttempts,
"reason": "tool_execution_error",
})
}
continue attemptLoop
}
// Non-recoverable error.
if progress != nil {
progress("error", ev.Err.Error(), map[string]interface{}{
"conversationId": conversationID,
+4 -3
View File
@@ -7,9 +7,10 @@ import (
"github.com/cloudwego/eino/schema"
)
// maxToolCallArgumentsJSONAttempts 含首次运行:首次 + 自动重试次数。
// maxToolCallRecoveryAttempts 含首次运行:首次 + 自动重试次数。
// 例如为 3 表示最多共 3 次完整 DeepAgent 运行(2 次失败后各追加一条纠错提示)。
const maxToolCallArgumentsJSONAttempts = 3
// 该常量同时用于 JSON 参数错误和工具执行错误(如子代理名称不存在)的恢复重试。
const maxToolCallRecoveryAttempts = 3
// toolCallArgumentsJSONRetryHint 追加在用户消息后,提示模型输出合法 JSON 工具参数(部分云厂商会在流式阶段校验 arguments)。
func toolCallArgumentsJSONRetryHint() *schema.Message {
@@ -24,7 +25,7 @@ func toolCallArgumentsJSONRecoveryTimelineMessage(attempt int) string {
"接口拒绝了无效的工具参数 JSON。已向对话追加系统提示并要求模型重新生成合法的 function.arguments。"+
"当前为第 %d/%d 轮完整运行。\n\n"+
"The API rejected invalid JSON in tool arguments. A system hint was appended. This is full run %d of %d.",
attempt+1, maxToolCallArgumentsJSONAttempts, attempt+1, maxToolCallArgumentsJSONAttempts,
attempt+1, maxToolCallRecoveryAttempts, attempt+1, maxToolCallRecoveryAttempts,
)
}
@@ -0,0 +1,76 @@
package multiagent
import (
"fmt"
"strings"
"github.com/cloudwego/eino/schema"
)
// isRecoverableToolExecutionError detects tool-level execution errors that can be
// recovered by retrying with a corrective hint. These errors originate from eino
// framework internals (e.g. task_tool.go, tool_node.go) when the LLM produces
// invalid tool calls such as non-existent sub-agent types, malformed JSON arguments,
// or unregistered tool names.
func isRecoverableToolExecutionError(err error) bool {
if err == nil {
return false
}
s := strings.ToLower(err.Error())
// Sub-agent type not found (from deep/task_tool.go)
if strings.Contains(s, "subagent type") && strings.Contains(s, "not found") {
return true
}
// Tool not found in toolsNode indexes (from compose/tool_node.go, when UnknownToolsHandler is nil)
if strings.Contains(s, "tool") && strings.Contains(s, "not found") {
return true
}
// Invalid tool arguments JSON (from einomcp/mcp_tools.go or eino internals)
if strings.Contains(s, "invalid tool arguments json") {
return true
}
// Failed to unmarshal task tool input json (from deep/task_tool.go)
if strings.Contains(s, "failed to unmarshal") && strings.Contains(s, "json") {
return true
}
// Generic tool call stream/invoke failure wrapping the above
if (strings.Contains(s, "failed to stream tool call") || strings.Contains(s, "failed to invoke tool")) &&
(strings.Contains(s, "not found") || strings.Contains(s, "json") || strings.Contains(s, "unmarshal")) {
return true
}
return false
}
// toolExecutionRetryHint returns a user message appended to the conversation to prompt
// the LLM to correct its tool call after a tool execution error.
func toolExecutionRetryHint() *schema.Message {
return schema.UserMessage(`[System] Your previous tool call failed because:
- The tool or sub-agent name you used does not exist, OR
- The tool call arguments were not valid JSON.
Please carefully review the available tools and sub-agents listed in your context, use only exact registered names (case-sensitive), and ensure all arguments are well-formed JSON objects. Then retry your action.
[系统提示] 上一次工具调用失败可能原因
- 你使用的工具名或子代理名称不存在
- 工具调用参数不是合法 JSON
请仔细检查上下文中列出的可用工具和子代理名称须完全匹配区分大小写确保所有参数均为合法的 JSON 对象然后重新执行`)
}
// toolExecutionRecoveryTimelineMessage returns a message for the eino_recovery event
// displayed in the UI timeline when a tool execution error triggers a retry.
func toolExecutionRecoveryTimelineMessage(attempt int) string {
return fmt.Sprintf(
"工具调用执行失败(工具/子代理名称不存在或参数 JSON 无效)。已向对话追加纠错提示并要求模型重新生成。"+
"当前为第 %d/%d 轮完整运行。\n\n"+
"Tool call execution failed (unknown tool/sub-agent name or invalid JSON arguments). "+
"A corrective hint was appended. This is full run %d of %d.",
attempt+1, maxToolCallRecoveryAttempts, attempt+1, maxToolCallRecoveryAttempts,
)
}
+155
View File
@@ -6,7 +6,9 @@ import (
"encoding/json"
"fmt"
"io"
"os"
"os/exec"
"runtime"
"strconv"
"strings"
"sync"
@@ -16,6 +18,7 @@ import (
"cyberstrike-ai/internal/mcp"
"cyberstrike-ai/internal/storage"
"github.com/creack/pty"
"go.uber.org/zap"
)
@@ -149,6 +152,7 @@ func (e *Executor) ExecuteTool(ctx context.Context, toolName string, args map[st
// 执行命令
cmd := exec.CommandContext(ctx, toolConfig.Command, cmdArgs...)
applyDefaultTerminalEnv(cmd)
e.logger.Info("执行安全工具",
zap.String("tool", toolName),
@@ -160,10 +164,26 @@ func (e *Executor) ExecuteTool(ctx context.Context, toolName string, args map[st
// 如果上层提供了 stdout/stderr 增量回调,则边执行边读取并回调。
if cb, ok := ctx.Value(ToolOutputCallbackCtxKey).(ToolOutputCallback); ok && cb != nil {
output, err = streamCommandOutput(cmd, cb)
if err != nil && shouldRetryWithPTY(output) {
e.logger.Info("检测到工具需要 TTY,使用 PTY 重试",
zap.String("tool", toolName),
)
cmd2 := exec.CommandContext(ctx, toolConfig.Command, cmdArgs...)
applyDefaultTerminalEnv(cmd2)
output, err = runCommandWithPTY(ctx, cmd2, cb)
}
} else {
outputBytes, err2 := cmd.CombinedOutput()
output = string(outputBytes)
err = err2
if err != nil && shouldRetryWithPTY(output) {
e.logger.Info("检测到工具需要 TTY,使用 PTY 重试",
zap.String("tool", toolName),
)
cmd2 := exec.CommandContext(ctx, toolConfig.Command, cmdArgs...)
applyDefaultTerminalEnv(cmd2)
output, err = runCommandWithPTY(ctx, cmd2, nil)
}
}
if err != nil {
// 检查退出码是否在允许列表中
@@ -956,10 +976,28 @@ func (e *Executor) executeSystemCommand(ctx context.Context, args map[string]int
// 若上层提供工具输出增量回调,则边执行边流式读取。
if cb, ok := ctx.Value(ToolOutputCallbackCtxKey).(ToolOutputCallback); ok && cb != nil {
output, err = streamCommandOutput(cmd, cb)
if err != nil && shouldRetryWithPTY(output) {
e.logger.Info("检测到系统命令需要 TTY,使用 PTY 重试")
cmd2 := exec.CommandContext(ctx, shell, "-c", command)
if workDir != "" {
cmd2.Dir = workDir
}
applyDefaultTerminalEnv(cmd2)
output, err = runCommandWithPTY(ctx, cmd2, cb)
}
} else {
outputBytes, err2 := cmd.CombinedOutput()
output = string(outputBytes)
err = err2
if err != nil && shouldRetryWithPTY(output) {
e.logger.Info("检测到系统命令需要 TTY,使用 PTY 重试")
cmd2 := exec.CommandContext(ctx, shell, "-c", command)
if workDir != "" {
cmd2.Dir = workDir
}
applyDefaultTerminalEnv(cmd2)
output, err = runCommandWithPTY(ctx, cmd2, nil)
}
}
if err != nil {
e.logger.Error("系统命令执行失败",
@@ -1066,6 +1104,123 @@ func streamCommandOutput(cmd *exec.Cmd, cb ToolOutputCallback) (string, error) {
return outBuilder.String(), waitErr
}
// applyDefaultTerminalEnv 为外部工具补齐常见的终端环境变量。
// 注意:这不会创建 TTY,只是减少某些工具在非交互环境下的“奇怪排版/检测失败”。
func applyDefaultTerminalEnv(cmd *exec.Cmd) {
if cmd == nil {
return
}
// 仅在未显式设置 Env 时,继承当前进程环境
if cmd.Env == nil {
cmd.Env = os.Environ()
}
// 如果用户已设置 TERM/COLUMNS/LINES,则不覆盖
has := func(k string) bool {
prefix := k + "="
for _, e := range cmd.Env {
if strings.HasPrefix(e, prefix) {
return true
}
}
return false
}
if !has("TERM") {
cmd.Env = append(cmd.Env, "TERM=xterm-256color")
}
if !has("COLUMNS") {
cmd.Env = append(cmd.Env, "COLUMNS=256")
}
if !has("LINES") {
cmd.Env = append(cmd.Env, "LINES=40")
}
}
func shouldRetryWithPTY(output string) bool {
o := strings.ToLower(output)
// autorecon / python termios 常见报错
if strings.Contains(o, "inappropriate ioctl for device") {
return true
}
if strings.Contains(o, "termios.error") {
return true
}
// 兜底:stdin 不是 tty
if strings.Contains(o, "not a tty") {
return true
}
return false
}
// runCommandWithPTY 为子进程分配 PTY,适配需要交互式终端的工具(如 autorecon)。
// 若 cb != nil,将持续回调增量输出(用于 SSE)。
func runCommandWithPTY(ctx context.Context, cmd *exec.Cmd, cb ToolOutputCallback) (string, error) {
if runtime.GOOS == "windows" {
// PTY 方案为类 UnixWindows 走原逻辑
if cb != nil {
return streamCommandOutput(cmd, cb)
}
out, err := cmd.CombinedOutput()
return string(out), err
}
ptmx, err := pty.Start(cmd)
if err != nil {
return "", err
}
defer func() { _ = ptmx.Close() }()
// ctx 取消时尽快终止子进程
done := make(chan struct{})
go func() {
select {
case <-ctx.Done():
_ = ptmx.Close() // 触发读退出
if cmd.Process != nil {
_ = cmd.Process.Kill()
}
case <-done:
}
}()
defer close(done)
var outBuilder strings.Builder
var deltaBuilder strings.Builder
lastFlush := time.Now()
flush := func() {
if cb == nil || deltaBuilder.Len() == 0 {
deltaBuilder.Reset()
lastFlush = time.Now()
return
}
cb(deltaBuilder.String())
deltaBuilder.Reset()
lastFlush = time.Now()
}
buf := make([]byte, 4096)
for {
n, readErr := ptmx.Read(buf)
if n > 0 {
chunk := string(buf[:n])
// 统一换行为 \n,避免前端错位
chunk = strings.ReplaceAll(chunk, "\r\n", "\n")
chunk = strings.ReplaceAll(chunk, "\r", "\n")
outBuilder.WriteString(chunk)
deltaBuilder.WriteString(chunk)
if deltaBuilder.Len() >= 2048 || time.Since(lastFlush) >= 200*time.Millisecond {
flush()
}
}
if readErr != nil {
break
}
}
flush()
waitErr := cmd.Wait()
return outBuilder.String(), waitErr
}
// executeInternalTool 执行内部工具(不执行外部命令)
func (e *Executor) executeInternalTool(ctx context.Context, toolName string, command string, args map[string]interface{}) (*mcp.ToolResult, error) {
// 提取内部工具类型(去掉 "internal:" 前缀)