first commit

This commit is contained in:
2026-08-14 23:41:57 +08:00
commit 086803a8dd
471 changed files with 91938 additions and 0 deletions
+559
View File
@@ -0,0 +1,559 @@
package agent
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"time"
)
type Toolset struct {
Workspace string
Timeout time.Duration
MaxOutput int
shell string
shellArgs []string
python string
dockerSocket func() string
apiCfg *APIConfigStore
coop *CoopManager
// sessionRoot 是各会话独立工作目录的根(data/workspaces),
// 每个会话在其下拥有 <sessionRoot>/<sessionID> 目录,互不干扰。
sessionRoot string
}
func NewToolset(cfg Config, dockerCfg *DockerConfigStore, apiCfg *APIConfigStore) *Toolset {
toolset := &Toolset{
Workspace: cfg.Workspace,
Timeout: cfg.ToolTimeout,
MaxOutput: cfg.MaxToolResultChars,
apiCfg: apiCfg,
sessionRoot: filepath.Join(cfg.DataDir, "workspaces"),
}
if dockerCfg != nil {
toolset.dockerSocket = dockerCfg.Socket
} else {
toolset.dockerSocket = defaultDockerSocket
}
if shell, err := exec.LookPath("bash"); err == nil {
toolset.shell = shell
toolset.shellArgs = []string{"-lc"}
} else if shell, err := exec.LookPath("pwsh"); err == nil {
toolset.shell = shell
toolset.shellArgs = []string{"-NoProfile", "-NonInteractive", "-Command"}
} else if shell, err := exec.LookPath("powershell"); err == nil {
toolset.shell = shell
toolset.shellArgs = []string{"-NoProfile", "-NonInteractive", "-Command"}
} else {
toolset.shell = "cmd.exe"
toolset.shellArgs = []string{"/C"}
}
if python, err := exec.LookPath("python"); err == nil {
toolset.python = python
} else if python, err := exec.LookPath("python3"); err == nil {
toolset.python = python
} else {
toolset.python = "python"
}
return toolset
}
func (t *Toolset) Definitions() []ToolDefinition {
return []ToolDefinition{
{
Type: "function",
Function: ToolDefinitionFunction{
Name: "run_bash",
Description: "在用户工作区目录中执行一条 Bash 命令。Windows 上没有 Bash 时自动回退到 PowerShell 或 cmd。适合查看文件、运行构建、安装依赖、搜索代码、启动程序等。",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"command": map[string]any{
"type": "string",
"description": "要执行的完整命令,例如 `ls -la` 或 `go test ./...`。",
},
"timeout_seconds": map[string]any{
"type": "integer",
"description": "命令超时秒数,默认 120 秒。",
},
},
"required": []string{"command"},
},
},
},
{
Type: "function",
Function: ToolDefinitionFunction{
Name: "read_file",
Description: "读取工作区内的文本文件并返回内容。超长文件会被截断。",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"path": map[string]any{
"type": "string",
"description": "文件路径,可以是相对路径或绝对路径。必须位于工作区内。",
},
},
"required": []string{"path"},
},
},
},
{
Type: "function",
Function: ToolDefinitionFunction{
Name: "write_file",
Description: "写入或覆盖工作区内的文件。父目录不存在时自动创建。",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"path": map[string]any{
"type": "string",
"description": "目标文件路径,可以是相对路径或绝对路径。必须位于工作区内。",
},
"content": map[string]any{
"type": "string",
"description": "要写入的完整文件内容。",
},
},
"required": []string{"path", "content"},
},
},
},
{
Type: "function",
Function: ToolDefinitionFunction{
Name: "run_python",
Description: "在工作区目录中执行一段 Python 代码。适合数据处理、批量修改、生成脚本和自动化任务。",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"code": map[string]any{
"type": "string",
"description": "要执行的完整 Python 代码。",
},
},
"required": []string{"code"},
},
},
},
{
Type: "function",
Function: ToolDefinitionFunction{
Name: "list_directory",
Description: "列出工作区内目录的内容,包括文件大小和修改时间。",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"path": map[string]any{
"type": "string",
"description": "目录路径,默认为工作区根目录。",
},
},
},
},
},
{
Type: "function",
Function: ToolDefinitionFunction{
Name: "docker_ps",
Description: "列出 Docker 容器。默认连接本地 Docker,可通过 Web 设置中的 Docker Socket 切换到远程 Docker 服务器。",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"all": map[string]any{
"type": "boolean",
"description": "是否列出所有容器(包括已停止的),默认 false 只显示运行中。",
},
},
},
},
},
{
Type: "function",
Function: ToolDefinitionFunction{
Name: "docker_images",
Description: "列出 Docker 镜像列表。",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{},
},
},
},
{
Type: "function",
Function: ToolDefinitionFunction{
Name: "docker_logs",
Description: "查看 Docker 容器的最近日志。",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"container": map[string]any{
"type": "string",
"description": "容器名称或 ID。",
},
"tail": map[string]any{
"type": "integer",
"description": "返回最近的日志行数,默认 100。",
},
},
"required": []string{"container"},
},
},
},
{
Type: "function",
Function: ToolDefinitionFunction{
Name: "docker_inspect",
Description: "查看 Docker 容器的详细配置和状态信息。",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"container": map[string]any{
"type": "string",
"description": "容器名称或 ID。",
},
},
"required": []string{"container"},
},
},
},
{
Type: "function",
Function: ToolDefinitionFunction{
Name: "docker_exec",
Description: "在 Docker 容器内执行一条命令(使用 sh -c),返回执行输出。",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"container": map[string]any{
"type": "string",
"description": "容器名称或 ID。",
},
"command": map[string]any{
"type": "string",
"description": "要在容器内执行的命令,例如 `ls -la` 或 `cat /etc/os-release`。",
},
},
"required": []string{"container", "command"},
},
},
},
{
Type: "function",
Function: ToolDefinitionFunction{
Name: "docker_start",
Description: "启动一个 Docker 容器。",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"container": map[string]any{
"type": "string",
"description": "容器名称或 ID。",
},
},
"required": []string{"container"},
},
},
},
{
Type: "function",
Function: ToolDefinitionFunction{
Name: "docker_stop",
Description: "停止一个 Docker 容器。",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"container": map[string]any{
"type": "string",
"description": "容器名称或 ID。",
},
"timeout": map[string]any{
"type": "integer",
"description": "等待优雅停止的秒数,默认由 Docker 决定。",
},
},
"required": []string{"container"},
},
},
},
{
Type: "function",
Function: ToolDefinitionFunction{
Name: "run_coop",
Description: "调用协作容器异步完成一个任务(容器内单个 agent 独立执行,自动注入当前 Agent 已配置的模型、Base URL、API Key 与接口协议,无需重复填写)。支持三种引擎:engine=pi(默认,pi-coding-agent,支持 openai+anthropic 协议)、engine=pigopigo,支持 openai+anthropic 协议)或 engine=claudeClaude Code,仅支持 anthropic 协议)。需先构建对应镜像(pi-coop / pigo-coop / claude-coop)。该工具是异步的:调用后立即返回任务 ID,容器在后台运行;任务完成(成功 / 失败 / 超时)后系统会自动注入一条【协作任务完成通知】消息(含结构化结果摘要),由你确认结果、必要时提交 flag 并向用户汇报,无需在此等待。",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"task": map[string]any{
"type": "string",
"description": "要交给协作容器完成的任务描述,例如“请为 XX 编写设计文档并实现原型”。解题类任务需写明目标地址、平台提交规则(BENCHMARK_TOKEN / unique_code / submit API)。",
},
"engine": map[string]any{
"type": "string",
"description": "协作引擎:pi(默认,pi-coding-agent)、pigopigo)或 claudeClaude Code,仅支持 anthropic 协议)。pi 与 pigo 支持 openai+anthropic 协议;claude 仅支持 anthropic 协议。",
"enum": []string{"pi", "pigo", "claude"},
},
"round_max": map[string]any{
"type": "integer",
"description": "最大轮次,默认 1(单 agent 一次运行完成,未完成则失败并重新下发)。",
},
"timeout": map[string]any{
"type": "integer",
"description": "单轮超时秒数,默认 1800(30 分钟)。单 agent 需在此时限内完成全部工作并创建 DONE。",
},
"blackboard": map[string]any{
"type": "string",
"description": "可选:主机上的目录路径,挂载到容器 /blackboard 保留黑板产物与会话;Windows 路径会自动转换为 WSL 挂载路径(/mnt/盘符/...)。留空则自动使用本会话工作区下的独立子目录 blackboard/<任务ID>(同一会话多次协作互不污染)。",
},
},
"required": []string{"task"},
},
},
},
}
}
func (t *Toolset) Execute(ctx context.Context, sessionID, name, arguments string) ToolResult {
var params map[string]any
if err := json.Unmarshal([]byte(arguments), &params); err != nil {
return ToolResult{Success: false, Output: "无法解析工具参数: " + err.Error()}
}
switch name {
case "run_bash":
command, _ := params["command"].(string)
if strings.TrimSpace(command) == "" {
return ToolResult{Success: false, Output: "command 不能为空"}
}
return t.runShell(ctx, sessionID, command, intParam(params, "timeout_seconds"))
case "read_file":
path, _ := params["path"].(string)
return t.readFile(sessionID, path)
case "write_file":
path, _ := params["path"].(string)
content, _ := params["content"].(string)
return t.writeFile(sessionID, path, content)
case "run_python":
code, _ := params["code"].(string)
if strings.TrimSpace(code) == "" {
return ToolResult{Success: false, Output: "code 不能为空"}
}
return t.runPython(ctx, sessionID, code)
case "list_directory":
path, _ := params["path"].(string)
return t.listDirectory(sessionID, path)
case "docker_ps":
return t.dockerPS(ctx, params)
case "docker_images":
return t.dockerImages(ctx, params)
case "docker_logs":
return t.dockerLogs(ctx, params)
case "docker_inspect":
return t.dockerInspect(ctx, params)
case "docker_exec":
return t.dockerExec(ctx, params)
case "docker_start":
return t.dockerStart(ctx, params)
case "docker_stop":
return t.dockerStop(ctx, params)
case "run_coop":
return t.runCoop(ctx, sessionID, params)
default:
return ToolResult{Success: false, Output: "未知工具: " + name}
}
}
func (t *Toolset) runShell(ctx context.Context, sessionID, command string, timeoutSeconds int) ToolResult {
// 不信任 LLM 传入的超长 timeout:超过全局工具超时(默认 120s)一律截断为全局值。
// 否则 sleep / 轮询类命令会长时间阻塞主 Agent(持有会话锁),期间前端
// 长时间收不到事件会被判"任务中断",且 coop 完成通知也拿不到锁无法及时处理。
if timeoutSeconds <= 0 || timeoutSeconds > int(t.Timeout.Seconds()) {
timeoutSeconds = int(t.Timeout.Seconds())
}
ctx, cancel := context.WithTimeout(ctx, time.Duration(timeoutSeconds)*time.Second)
defer cancel()
var stdout, stderr bytes.Buffer
cmd := exec.CommandContext(ctx, t.shell, t.shellArgs...)
cmd.Args = append(cmd.Args, command)
cmd.Dir = t.workspaceFor(sessionID)
cmd.Env = append(os.Environ(), "AGENT_WORKSPACE="+t.workspaceFor(sessionID))
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err := cmd.Run()
var out strings.Builder
out.WriteString(strings.TrimSpace(stdout.String()))
if stderrText := strings.TrimSpace(stderr.String()); stderrText != "" {
if out.Len() > 0 {
out.WriteString("\n")
}
out.WriteString("[stderr]\n")
out.WriteString(stderrText)
}
if err != nil {
if out.Len() > 0 {
out.WriteString("\n")
}
out.WriteString("[命令失败] " + err.Error())
}
return ToolResult{Success: err == nil, Output: t.truncate(out.String())}
}
func (t *Toolset) runPython(ctx context.Context, sessionID, code string) ToolResult {
ctx, cancel := context.WithTimeout(ctx, t.Timeout)
defer cancel()
var stdout, stderr bytes.Buffer
cmd := exec.CommandContext(ctx, t.python, "-c", code)
cmd.Dir = t.workspaceFor(sessionID)
cmd.Env = append(os.Environ(), "AGENT_WORKSPACE="+t.workspaceFor(sessionID))
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err := cmd.Run()
var out strings.Builder
out.WriteString(strings.TrimSpace(stdout.String()))
if stderrText := strings.TrimSpace(stderr.String()); stderrText != "" {
if out.Len() > 0 {
out.WriteString("\n")
}
out.WriteString("[stderr]\n")
out.WriteString(stderrText)
}
if err != nil {
if out.Len() > 0 {
out.WriteString("\n")
}
out.WriteString("[运行失败] " + err.Error())
}
return ToolResult{Success: err == nil, Output: t.truncate(out.String())}
}
func (t *Toolset) readFile(sessionID, path string) ToolResult {
absPath, err := t.resolvePath(sessionID, path)
if err != nil {
return ToolResult{Success: false, Output: err.Error()}
}
data, err := os.ReadFile(absPath)
if err != nil {
return ToolResult{Success: false, Output: err.Error()}
}
return ToolResult{Success: true, Output: t.truncate(string(data))}
}
func (t *Toolset) writeFile(sessionID, path, content string) ToolResult {
absPath, err := t.resolvePath(sessionID, path)
if err != nil {
return ToolResult{Success: false, Output: err.Error()}
}
if err := os.MkdirAll(filepath.Dir(absPath), 0o755); err != nil {
return ToolResult{Success: false, Output: err.Error()}
}
if err := os.WriteFile(absPath, []byte(content), 0o644); err != nil {
return ToolResult{Success: false, Output: err.Error()}
}
return ToolResult{Success: true, Output: fmt.Sprintf("已写入 %d 字节到 %s", len(content), absPath)}
}
func (t *Toolset) listDirectory(sessionID, path string) ToolResult {
absPath, err := t.resolvePath(sessionID, path)
if err != nil {
return ToolResult{Success: false, Output: err.Error()}
}
entries, err := os.ReadDir(absPath)
if err != nil {
return ToolResult{Success: false, Output: err.Error()}
}
sort.Slice(entries, func(i, j int) bool {
return entries[i].Name() < entries[j].Name()
})
var out strings.Builder
for _, entry := range entries {
info, infoErr := entry.Info()
if infoErr != nil {
continue
}
name := entry.Name()
if entry.IsDir() {
name += "/"
}
fmt.Fprintf(&out, "%-40s %10d %s\n", name, info.Size(), info.ModTime().Format("2006-01-02 15:04"))
}
if out.Len() == 0 {
return ToolResult{Success: true, Output: "目录为空"}
}
return ToolResult{Success: true, Output: t.truncate(out.String())}
}
// resolvePath 把工具传入的路径解析为绝对路径:
// - 相对路径基于该会话的独立工作目录(data/workspaces/<sessionID>);
// - 绝对路径允许在项目根目录内(协作黑板产物、agent 自身文件等共享内容);
// - 项目根目录之外一律拒绝。
func (t *Toolset) resolvePath(sessionID, raw string) (string, error) {
if strings.TrimSpace(raw) == "" {
raw = "."
}
path := filepath.FromSlash(raw)
if !filepath.IsAbs(path) {
path = filepath.Join(t.workspaceFor(sessionID), path)
}
absPath, err := filepath.Abs(path)
if err != nil {
return "", err
}
if !pathWithin(absPath, t.Workspace) {
return "", errors.New("路径超出项目根目录范围,已拒绝: " + absPath)
}
return absPath, nil
}
// workspaceFor 返回某会话的独立工作目录并确保其存在。
func (t *Toolset) workspaceFor(sessionID string) string {
root := t.sessionRoot
if root == "" {
root = t.Workspace
}
dir := filepath.Join(root, sessionID)
_ = os.MkdirAll(dir, 0o755)
return dir
}
// pathWithin 判断 child 是否位于 parent 目录内(含自身)。
func pathWithin(child, parent string) bool {
rel, err := filepath.Rel(parent, child)
if err != nil {
return false
}
return rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)))
}
func (t *Toolset) truncate(value string) string {
runes := []rune(value)
if len(runes) <= t.MaxOutput {
return value
}
return string(runes[:t.MaxOutput]) + "\n...[输出过长,已截断]"
}
func intParam(params map[string]any, key string) int {
switch value := params[key].(type) {
case float64:
return int(value)
case int:
return value
default:
return 0
}
}