Why: deliverable prompts already tell the model to generate Office files with run_python and register them, but the executors were never registered, so calls missed the registry and fell back to text-only write_artifact. Evidence: go test ./... — 19 packages ok, including echo/script-retention, empty-script and runtime.exec gate, process-group timeout, python ZIP magic, and ExecuteTool backfill of register_artifact after run_python. Scripts stay under the session .agent/ directory; bash/python3 are resolved to absolute paths; timeout kills the process group. Impact: models can execute workspace scripts and register binary deliverables. runtime.exec=false still fail-closes the whole family. Office extensions now get real MIME types so binary bytes are not indexed as text. Refs: task feature/exec-interpreters Co-authored-by: Cursor <cursoragent@cursor.com>
120 lines
4.2 KiB
Go
120 lines
4.2 KiB
Go
// 执行原语工具族(agent-native 交付,2026-10-01 用户定调):文档/表格/演示稿
|
||
// 等产物生成是 Agent 的能力——系统只提供原语与登记桥,生成方法沉淀在提示词库
|
||
// (base.deliverables 配方),不提供 write_docx 这类领域工具。
|
||
//
|
||
// 信任模型:本地单用户工作台(单二进制自托管)。run_shell/run_python 的脚本
|
||
// 一律先落盘为会话工作区内的文件再交给白名单解释器(bash/python3)执行,
|
||
// 工作目录锁定会话沙箱,超时与输出上限硬性截断;画像三清单与 runtime.exec
|
||
// 开关可禁用(fail-closed)。脚本落盘保留审计痕迹。
|
||
package tools
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
|
||
core "agent-system-core"
|
||
)
|
||
|
||
const (
|
||
execMaxOutput = 64 << 10 // 单次输出上限 64KB(头尾保留)
|
||
execDefaultTimeoutS = 60
|
||
execMaxTimeoutS = 300
|
||
execMaxArtifactBytes = 10 << 20 // register_artifact 登记上限 10MB
|
||
execScriptDir = ".agent" // 脚本落位(会话工作区内,可审计)
|
||
execSetting = "runtime.exec"
|
||
)
|
||
|
||
// execGate runtime.exec 总闸(默认开;false = 执行原语整体禁用,fail-closed)。
|
||
func execGate(rc ToolExecCtx) error {
|
||
if !rc.SettingBool(execSetting, true) {
|
||
return fmt.Errorf("exec tools: 已被设置禁用(%s=false)", execSetting)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// execSandbox 返回(并确保存在)会话沙箱目录。
|
||
func execSandbox(rc ToolExecCtx) (string, error) {
|
||
sandbox, err := fsSandbox(rc)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
if err := os.MkdirAll(sandbox, 0o755); err != nil {
|
||
return "", fmt.Errorf("exec tools: create sandbox: %w", err)
|
||
}
|
||
return sandbox, nil
|
||
}
|
||
|
||
// RegisterExecTools 注册执行原语族的登记桥(register_artifact)。
|
||
// run_shell / run_python 在 exec_interpreter.go,由 RegisterInterpreterTools 同批注册
|
||
// (契约见 base.deliverables 提示词)。
|
||
func RegisterExecTools(r *Registry) error {
|
||
return r.Register(core.ToolDesc{
|
||
Name: "register_artifact", Source: core.SourceBuiltin,
|
||
Description: "把会话工作区内已生成的文件登记为交付物工件(docx/xlsx/pptx/pdf/图片/数据等):登记后文件出现在对话交付卡片与工件面板,可预览、下载。二进制文件不能用 fs_write_file(只收文本)——先由 run_python/run_shell 生成到工作区,再用本工具登记。",
|
||
Parameters: json.RawMessage(`{"type":"object","properties":{` +
|
||
`"path":{"type":"string","description":"工作区内相对路径(如 报告.docx)"}}` +
|
||
`,"required":["path"]}`),
|
||
}, execRegisterArtifact)
|
||
}
|
||
|
||
func execRegisterArtifact(rc ToolExecCtx, args json.RawMessage) (string, error) {
|
||
if err := execGate(rc); err != nil {
|
||
return "", err
|
||
}
|
||
var a struct {
|
||
Path string `json:"path"`
|
||
}
|
||
if len(args) > 0 {
|
||
if err := json.Unmarshal(args, &a); err != nil {
|
||
return "", fmt.Errorf("register_artifact: bad args: %w", err)
|
||
}
|
||
}
|
||
if strings.TrimSpace(a.Path) == "" {
|
||
return "", fmt.Errorf("register_artifact: path required")
|
||
}
|
||
sandbox, err := execSandbox(rc)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
abs, err := fsResolve(sandbox, a.Path)
|
||
if err != nil {
|
||
return "", fmt.Errorf("register_artifact: %w", err)
|
||
}
|
||
st, err := os.Stat(abs)
|
||
if err != nil {
|
||
return "", fmt.Errorf("register_artifact: %s: %v", a.Path, err)
|
||
}
|
||
if st.IsDir() {
|
||
return "", fmt.Errorf("register_artifact: %s 是目录(逐个文件登记)", a.Path)
|
||
}
|
||
if st.Size() > execMaxArtifactBytes {
|
||
return "", fmt.Errorf("register_artifact: 文件超过 10MB 上限(%d 字节)", st.Size())
|
||
}
|
||
b, err := os.ReadFile(abs)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
name := filepath.Base(abs)
|
||
mime := mimeOfExt(name)
|
||
art, err := rc.WriteArtifact(name, artifactKindOf(name, mime), mime, b)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
return fmt.Sprintf("已登记交付物 %s(%s,%d 字节)——对话内会出现可预览的文件卡片", art.Name, art.ID, art.Size), nil
|
||
}
|
||
|
||
// artifactKindOf 按扩展名/mime 归类工件 kind(工件面板过滤与卡片图标用)。
|
||
func artifactKindOf(name, mime string) string {
|
||
switch {
|
||
case strings.HasPrefix(mime, "image/"):
|
||
return "image"
|
||
case strings.Contains(mime, "json") || strings.Contains(mime, "csv"):
|
||
return "data"
|
||
default:
|
||
return "document"
|
||
}
|
||
}
|