Files
Agent-System-Backend/internal/tools/exec.go
T
weichen d6bef8663f Return deliverable generation to the agent and fix the artifact-card root cause
Why: two field reports against the live build — deliverable cards never
appeared in chat (only model-written filenames) and the artifacts panel
felt dead. Root cause found in runmgr.ExecuteTool: registry tools that
write artifacts via rc.WriteArtifact never had res.Artifact set (only
the oversized-result path did), so llm.go never emitted artifact_ref
parts for real tool output; the unit suite passed via fakes that set
it directly. Architecturally, document generation moves back to the
agent (user directive): system provides execution primitives and the
delivery bridge, not domain tools like write_docx.

Evidence:
- runmgr: toolCtx tracks artifacts written during a call and
  ExecuteTool backfills res.Artifact (new runmgr test proves
  write_artifact now surfaces one; negative test: no phantom artifact)
- new tools/register_artifact: bridge from workspace files (binary
  docx/xlsx/pptx/pdf/images, <=10MB) to deliverable artifacts;
  run_shell/run_python interpreter executors are designed
  (script-to-disk + whitelisted bash/python3, sandbox cwd, caps) and
  land in a follow-up batch behind the security gate decision
- removed builtin write_docx + its prompt; write_artifact prompt now
  text-only with explicit no-binary guidance; new always-on base
  prompt base.deliverables teaches the generate->register->summarize
  workflow with python-docx/openpyxl/python-pptx recipes;
  task.doc-minutes rewritten to the workflow
- final-output contract: inline <think> blocks in streamed content are
  split into thinking (process) parts so the final reply stays clean
  (unit + runner tests)
- go test ./... 19 packages ok; vet clean

Impact: every tool artifact now lands as an in-chat deliverable card;
mock #doc unchanged as demo fixture; binary office mimes stay out of
the text index; write_docx callers see the tool gone (prompt reroutes
them).

Refs: task feature/agent-native-deliverables-r1
2026-10-01 03:32:30 +00:00

121 lines
4.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 执行原语工具族(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(解释器执行器)实现方案已定(脚本落盘+白名单解释器),
// 因安全门禁分批落盘——补齐后在 app.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"
}
}