Return deliverable generation to the agent and fix the artifact-card root cause #32

Merged
weichen merged 1 commits from feature/agent-native-deliverables-r1 into main 2026-10-01 03:33:54 +00:00
16 changed files with 382 additions and 183 deletions
+6
View File
@@ -75,6 +75,12 @@ func Run(opt Options) error {
if err := tools.RegisterFSTools(reg); err != nil {
return fmt.Errorf("fs tools: %w", err)
}
// 执行原语(agent-native 交付):shell/python 在会话工作区执行 + 产物登记桥,
// runtime.exec=false 可整体禁用(fail-closed)。生成文档/表格/演示稿的方法
// 在提示词库 base.deliverables,不在系统工具里。
if err := tools.RegisterExecTools(reg); err != nil {
return fmt.Errorf("exec tools: %w", err)
}
setSvc := settings.NewService(st)
setSvc.SetSystemOverride(map[string]any{"storage": map[string]any{"dataDir": opt.DataDir}})
// 网络搜索(Tavily):配置密钥池才注册(对齐 agent-native 语义:未配置不静默降级)。
@@ -0,0 +1,39 @@
---
id: base.deliverables
layer: base
version: 1
title: 交付物工作流
description: 生成文档/表格/演示稿等文件交付物的通用工作流与配方(常驻基座)
---
交付物(Word/Excel/PPT/PDF/图片等文件)的生成是你的能力,不是系统的——你通过执行原语(run_python / run_shell)亲自产出文件,再登记为工件交付。统一工作流:
1. **生成**:用 run_python 写脚本生成文件(首选,避免 shell 引号转义),文件落在会话工作区(脚本的工作目录)。
2. **登记**:调用 register_artifact登记生成文件(path 传相对路径);登记后对话内自动出现可预览的文件卡片。
3. **收尾**:对话回复只给结论摘要与要点(几句话),全文进文件,不要在回复里复述文档内容;也不要只口头说文件名——卡片就是交付。
配方(按需选用;缺库先 `run_shell: pip install --user python-docx` 装好再跑):
- **Word(.docx)**:python-docx。骨架:
```python
from docx import Document
from docx.shared import Pt, RGBColor
from docx.enum.text import WD_ALIGN_PARAGRAPH
doc = Document()
doc.styles['Normal'].font.name = 'Calibri'; doc.styles['Normal']._element.rPr.rFonts.set(__import__('docx.oxml.ns', fromlist=['qn']).qn('w:eastAsia'), '等线')
t = doc.add_heading('文档标题', level=0); t.alignment = WD_ALIGN_PARAGRAPH.CENTER
doc.add_heading('一、章节', level=1)
doc.add_paragraph('正文段落……')
table = doc.add_table(rows=1, cols=3); table.style = 'Light Grid Accent 1'
for i, h in enumerate(['列一','列二','列三']): table.rows[0].cells[i].text = h
doc.add_paragraph('要点', style='List Bullet'); doc.add_paragraph('结论', style='List Number')
doc.save('交付物名.docx')
```
- **Excel(.xlsx)**:openpyxl。骨架:`from openpyxl import Workbook; wb=Workbook(); ws=wb.active; ws.title='表1'; ws.append(['列一','列二']); ws.append([1,2]); ws.column_dimensions['A'].width=18; wb.save('名.xlsx')`。
- **PPT(.pptx)**:python-pptx。骨架:`from pptx import Presentation; prs=Presentation(); s=prs.slides.add_slide(prs.slide_layouts[0]); s.shapes.title.text='标题'; s.placeholders[1].text='副标题'; prs.save('名.pptx')`。
要求与边界:
- 正式文档必须有真实排版(标题层级、表格、列表样式),不得把纯文本改个 .docx 扩展名充数;生成后可用 register_artifact 交付,用户点击卡片即可预览排版效果。
- 生成脚本本身要可靠:先小样验证再生成全文;脚本报错读退出码与输出自纠,不重复盲试。
- 大文件(>10MB)或非交付物中间产物不登记;文本类小交付物(md/json/csv)可直接 write_artifact。
- 文件名用人类可读的中文名(含扩展名);修订生成新文件而不是覆盖旧名。
+1 -1
View File
@@ -26,4 +26,4 @@ triggers:
- 区分三件事:讨论过程(谁说了什么)、形成的决议(集体结论)、待办事项(谁在何时前做什么)——不要把发言当决议。
- 内容完整详实,除非用户明确要求精简,不要压缩篇幅;原文模糊或矛盾的点在"未决问题"中列出而不是擅自裁决。
- 待办事项若原文未指明负责人或期限,写"未指明",不要编造。
- 交付形态:用 write_docx 生成正式 Word 文档(Markdown 表达结构:一级标题为纪要标题、基本信息与待办用表格、决议用编号列表),name 用「<主题>会议纪要.docx」;对话内只简述结论,全文以文档交付,不要在回复里复述全文。
- 交付形态:按「交付物工作流」自己生成正式 Word 文档——run_python(python-docx 配方)产出「<主题>会议纪要.docx」(标题层级/基本信息表/待办表/决议编号列表都用工件排版表达),再 register_artifact 登记交付;对话内只简述结论,不要在回复里复述全文。
@@ -1,10 +1,10 @@
---
id: tool.write_artifact
layer: tool
version: 1
version: 2
title: write_artifact 工具描述
description: 工件写入工具的模型侧说明文案
---
把生成内容写入工件(文档/数据),返回工件 ID 与名称。用于正式交付物:代码文件、数据集、Markdown/纯文本资料等需要保存、后续引用或继续编辑的内容。对话内的临时说明不需要写工件。写入时 name 用人类可读的文件名(含扩展名),kind 按内容选 document/code/data;同一交付物修订时写新版本而不是覆盖引用旧名。
把**文本类**内容(Markdown/纯文本/JSON/CSV/代码)写入工件,返回工件 ID 与名称。用于文本交付物与需要保存、后续引用的数据。对话内的临时说明不需要写工件。写入时 name 用人类可读的文件名(含扩展名),kind 按内容选 document/code/data;同一交付物修订时写新版本而不是覆盖引用旧名。
分工:需要**带 Word 排版的正式文档**(报告、纪要、方案、说明书)时改用 write_docx——它把 Markdown 转成标题/表格/列表齐全的 .docx;本工具写的是原样字节,不要用它写 .docx(会产生无排版的伪 Word 文件)。
分工:本工具只收字符串内容,**不要用它写 .docx/.xlsx/.pptx 等二进制文件**(纯文本改扩展名是无排版的伪文件)。带排版的 Office/PDF/图片交付物由你用 run_python(或 run_shell)生成到工作区后,用 register_artifact 登记交付——配方见交付物工作流(base.deliverables)。
@@ -1,10 +0,0 @@
---
id: tool.write_docx
layer: tool
version: 1
title: write_docx 工具描述
description: Word 文档生成工具的模型侧说明文案
---
生成带排版样式的 Word 文档(.docx 工件):content 用 Markdown 书写——`#/##/###` 表达章节层级、`|表格|`、`-` 列表、`1.` 编号、`- [x]` 任务项、`>` 引用、`**粗体**`、`[链接](url)`——系统自动转换为正式 Word 排版(标题样式、表头底纹表格、编号列表、引用线、代码段),用户打开即是成品文档。
用于正式文档交付物:报告、会议纪要、方案、说明书、总结等需要以 Word 交付或打印的内容。name 用人类可读的中文文件名(可不含扩展名,自动补 .docx);文档标题默认取正文第一个一级标题。对话里的临时说明、代码片段、纯文本/Markdown/JSON 数据仍用 write_artifact——需要"Word 排版"才用本工具。
+3 -3
View File
@@ -72,9 +72,9 @@ func TestResolveSystemDefaultBase(t *testing.T) {
if err != nil {
t.Fatal(err)
}
// 默认:base 五块 + env 一块,无技能注入。
if len(blocks) != 6 {
t.Errorf("blocks = %d, want 6 (5 base + env)", len(blocks))
// 默认:base 六块(identity/behavior/caution/style/tools/deliverables)+ env 一块,无技能注入。
if len(blocks) != 7 {
t.Errorf("blocks = %d, want 7 (6 base + env)", len(blocks))
}
var envBody string
for _, b := range blocks {
+2 -2
View File
@@ -82,10 +82,10 @@ func newCrossEnv(t *testing.T) *crossEnv {
}
// peerCtx 构造门禁视角:owner=alice 在 projA 下经 convA 发起读取。
func (e *crossEnv) peerCtx(t *testing.T) toolCtx {
func (e *crossEnv) peerCtx(t *testing.T) *toolCtx {
t.Helper()
eff := e.svc.Effective("alice", e.projA.ID)
return toolCtx{rc: &runCtx{mgr: e.m, eff: eff, run: core.Run{Owner: "alice", ProjectID: e.projA.ID, ConversationID: e.convA.ID}}}
return &toolCtx{rc: &runCtx{mgr: e.m, eff: eff, run: core.Run{Owner: "alice", ProjectID: e.projA.ID, ConversationID: e.convA.ID}}}
}
func (e *crossEnv) setGate(t *testing.T, on bool) {
@@ -0,0 +1,73 @@
// ExecuteTool 工件回填契约(交付物卡片根因修复的验收):注册表工具经
// rc.WriteArtifact 写入的工件必须出现在 ToolCallResult.Artifact 上——
// llm.go 据此生成对话内 artifact_ref 卡片部件。此前只有超长截断路径会设置它。
package runmgr
import (
"context"
"encoding/json"
"path/filepath"
"testing"
"agent-system-backend/internal/bus"
"agent-system-backend/internal/settings"
"agent-system-backend/internal/store"
"agent-system-backend/internal/tools"
core "agent-system-core"
)
func newExecEnv(t *testing.T) (*runCtx, *store.Store) {
t.Helper()
st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "exec.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
b := bus.New()
reg := tools.NewRegistry()
if err := reg.RegisterBuiltin(); err != nil {
t.Fatal(err)
}
proj, err := st.CreateProject("alice", "p1")
if err != nil {
t.Fatal(err)
}
conv, err := st.CreateConversation("alice", proj.ID, "c1", "")
if err != nil {
t.Fatal(err)
}
svc := settings.NewService(st)
m := &Manager{st: st, bus: b, registry: reg, settings: svc, events: newEventBuffer(st, b)}
eff := svc.Effective("alice", "")
c := &runCtx{
mgr: m, run: core.Run{ProjectID: proj.ID, ConversationID: conv.ID, Owner: "alice"},
prof: core.AgentProfile{}, eff: eff, ctx: context.Background(),
}
return c, st
}
func TestExecuteToolBackfillsArtifact(t *testing.T) {
c, _ := newExecEnv(t)
res := c.ExecuteTool(core.ToolCallRequest{
CallID: "call1", Name: "write_artifact",
Arguments: json.RawMessage(`{"name":"summary.md","kind":"document","content":"# 摘要正文"}`),
})
if res.Status != "ok" {
t.Fatalf("write_artifact: %s", res.Output)
}
if res.Artifact == nil {
t.Fatal("ToolCallResult.Artifact 未回填——对话内交付物卡片不会出现(回归)")
}
if res.Artifact.Name != "summary.md" {
t.Errorf("回填工件名不符: %+v", res.Artifact)
}
}
// 空手工具(如 time_now)不得凭空挂 Artifact。
func TestExecuteToolNoArtifactByDefault(t *testing.T) {
c, _ := newExecEnv(t)
res := c.ExecuteTool(core.ToolCallRequest{CallID: "c1", Name: "time_now", Arguments: json.RawMessage(`{}`)})
if res.Status != "ok" || res.Artifact != nil {
t.Errorf("无产物工具不应携带 Artifact: %+v", res)
}
}
+31 -13
View File
@@ -422,7 +422,16 @@ func (c *runCtx) ExecuteTool(call core.ToolCallRequest) core.ToolCallResult {
return core.ToolCallResult{CallID: call.CallID, Status: "error", Output: "用户取消了本次工具调用"}
}
}
res := c.mgr.registry.Execute(toolCtx{rc: c}, call)
tc := &toolCtx{rc: c}
res := c.mgr.registry.Execute(tc, call)
// 工具产物回填(交付物卡片根因修复):注册表工具执行期经 rc.WriteArtifact
// 写入的工件(write_artifact / fs_write_file / register_artifact / 未来执行
// 原语产物)挂到结果上——llm.go 依 res.Artifact 生成对话内 artifact_ref 卡片。
// 此前只有「结果超长截断转工件」会设置它,正常产物的卡片从未出现。
if res.Artifact == nil && len(tc.arts) > 0 {
last := tc.arts[len(tc.arts)-1]
res.Artifact = &last
}
// 超长截断并转工件引用(4.10)。
limit := c.prof.Context.MaxToolResultChars
if limit <= 0 {
@@ -440,19 +449,28 @@ func (c *runCtx) ExecuteTool(call core.ToolCallRequest) core.ToolCallResult {
return res
}
type toolCtx struct{ rc *runCtx }
// toolCtx 包装 runCtx 供工具执行;arts 记录本调用期间写入的工件,
// ExecuteTool 据此回填 res.Artifact(对话内交付物卡片的来源)。
type toolCtx struct {
rc *runCtx
arts []core.Artifact
}
func (t toolCtx) ProjectID() string { return t.rc.run.ProjectID }
func (t toolCtx) ConversationID() string { return t.rc.run.ConversationID }
func (t toolCtx) Retrieve(q core.RetrievalQuery) ([]core.RetrievalHit, error) {
func (t *toolCtx) ProjectID() string { return t.rc.run.ProjectID }
func (t *toolCtx) ConversationID() string { return t.rc.run.ConversationID }
func (t *toolCtx) Retrieve(q core.RetrievalQuery) ([]core.RetrievalHit, error) {
return t.rc.Retrieve(q)
}
func (t toolCtx) WriteArtifact(name, kind, mime string, content []byte) (core.Artifact, error) {
return t.rc.WriteArtifact(name, kind, mime, content)
func (t *toolCtx) WriteArtifact(name, kind, mime string, content []byte) (core.Artifact, error) {
art, err := t.rc.WriteArtifact(name, kind, mime, content)
if err == nil {
t.arts = append(t.arts, art)
}
return art, err
}
func (t toolCtx) OwnerID() string { return t.rc.run.Owner }
func (t toolCtx) WorkspaceRoot() string { return t.rc.mgr.workspaceRoot }
func (t toolCtx) SettingBool(key string, def bool) bool { return t.rc.eff.GetBool(key, def) }
func (t *toolCtx) OwnerID() string { return t.rc.run.Owner }
func (t *toolCtx) WorkspaceRoot() string { return t.rc.mgr.workspaceRoot }
func (t *toolCtx) SettingBool(key string, def bool) bool { return t.rc.eff.GetBool(key, def) }
// ---- 跨会话读取门禁(提案 2026-09-30-cross-session-reading;fail-closed)----
//
@@ -468,7 +486,7 @@ var (
)
// crossReadSelf 加载当前会话并校验项目闸门;出站检查由调用方按目标是否为自身决定。
func (t toolCtx) crossReadSelf() (core.Conversation, error) {
func (t *toolCtx) crossReadSelf() (core.Conversation, error) {
if !t.rc.eff.GetBool("crossSession.read", false) {
return core.Conversation{}, ErrCrossReadDisabled
}
@@ -479,7 +497,7 @@ func (t toolCtx) crossReadSelf() (core.Conversation, error) {
return self, nil
}
func (t toolCtx) PeerConversations() ([]core.Conversation, error) {
func (t *toolCtx) PeerConversations() ([]core.Conversation, error) {
self, err := t.crossReadSelf()
if err != nil {
return nil, err
@@ -502,7 +520,7 @@ func (t toolCtx) PeerConversations() ([]core.Conversation, error) {
return out, nil
}
func (t toolCtx) PeerConversationMessages(conversationID string, limit int) ([]core.Message, error) {
func (t *toolCtx) PeerConversationMessages(conversationID string, limit int) ([]core.Message, error) {
if limit <= 0 {
limit = 50
}
+32 -2
View File
@@ -293,8 +293,17 @@ func (l *LLMRunner) Execute(rc RunCtx) error {
}
if turnText.Len() > 0 {
// 工具轮的文本是过程解说(终态随过程折叠);终轮文本才是对用户的回复。
finalParts = append(finalParts, core.Part{Type: core.PartText, Text: turnText.String(), Process: len(calls) > 0})
textIdx++
// 部分模型把推理以 <think> 内联混进 content 流:折离为思考部件(过程),
// 保证「最终输出 = 干净的对用户回复」契约。
body, think := splitThinkBlock(turnText.String())
if think != "" {
finalParts = append(finalParts, core.Part{Type: core.PartThinking, Text: think, Process: true})
thinkingIdx++
}
if body != "" {
finalParts = append(finalParts, core.Part{Type: core.PartText, Text: body, Process: len(calls) > 0})
textIdx++
}
}
if len(calls) == 0 {
@@ -588,3 +597,24 @@ func truncateRun(s string, n int) string {
}
return string(r[:n]) + "…"
}
// splitThinkBlock 剥离正文里内联的 <think>…</think> 推理块(部分 OpenAI 兼容
// 模型把思考混进 content 流):返回(干净正文, 思考内容)。只处理首部成对标签;
// 未闭合或非首部出现按原文返回(宁可多显示也不误删正文)。
func splitThinkBlock(s string) (body, think string) {
trimmed := strings.TrimLeft(s, " \t\r\n")
if !strings.HasPrefix(trimmed, "<think>") {
return s, ""
}
rest := strings.TrimPrefix(trimmed, "<think>")
i := strings.Index(rest, "</think>")
if i < 0 {
return s, ""
}
think = strings.TrimSpace(rest[:i])
body = strings.TrimSpace(rest[i+len("</think>"):])
if think == "" {
return body, ""
}
return body, think
}
+68
View File
@@ -0,0 +1,68 @@
// 最终输出契约测试:内联 <think> 折离 + 终轮正文干净(llm.go splitThinkBlock)。
package runtime
import (
"context"
"strings"
"testing"
agentllm "agent-native/runtime/llm"
core "agent-system-core"
)
// TestSplitThinkBlock 单测:首部 <think>…</think> 剥离;未闭合/非首部/空块按原文。
func TestSplitThinkBlock(t *testing.T) {
cases := []struct {
in string
body, think string
}{
{"<think>推理</think>答案", "答案", "推理"},
{" <think>推理</think>\n答案", "答案", "推理"},
{"<think>推理</think>", "", "推理"},
{"<think>未闭合", "<think>未闭合", ""},
{"正文 <think>混入</think>", "正文 <think>混入</think>", ""},
{"没有标签的正文", "没有标签的正文", ""},
{"<think></think>答案", "答案", ""},
}
for _, c := range cases {
b, th := splitThinkBlock(c.in)
if b != c.body || th != c.think {
t.Errorf("splitThinkBlock(%q) = (%q, %q), want (%q, %q)", c.in, b, th, c.body, c.think)
}
}
}
// TestLLMRunnerThinkStrippedFromReply 契约:模型把推理以 <think> 内联进最终回复时,
// 落库拆为「思考(过程)+ 干净正文」——最终输出即对用户的回复。
func TestLLMRunnerThinkStrippedFromReply(t *testing.T) {
turns := [][]agentllm.Event{
{
agentllm.Event{Kind: agentllm.KindTextDelta, Text: "<think>用户要纪要,我分三步……</think>\n已生成会议纪要,要点三项。"},
agentllm.Event{Kind: agentllm.KindMessageEnd},
},
}
r := &LLMRunner{Stream: scriptStream(turns, nil)}
rc := &fakeRunCtx{ctx: context.Background(), prof: core.AgentProfile{Model: core.ModelRoute{Chat: "m"}}}
if err := r.Execute(rc); err != nil {
t.Fatalf("Execute: %v", err)
}
var think, text *core.Part
for i := range rc.assistant {
p := rc.assistant[i]
if p.Type == core.PartThinking {
think = &rc.assistant[i]
}
if p.Type == core.PartText {
text = &rc.assistant[i]
}
}
if think == nil || !strings.Contains(think.Text, "分三步") || !think.Process {
t.Errorf("内联推理未折离为思考部件: %+v", rc.assistant)
}
if text == nil || strings.Contains(text.Text, "<think>") || !strings.Contains(text.Text, "要点三项") {
t.Errorf("最终正文不干净: %+v", text)
}
if text.Process {
t.Errorf("终轮正文不得标记 Process: %+v", text)
}
}
+4 -3
View File
@@ -172,8 +172,9 @@ func (m *MockRunner) Execute(rc RunCtx) error {
case strings.Contains(user, "#doc"):
rc.Emit(core.EvThinkingDelta, core.ThinkingDeltaPayload{Index: 0, Text: "准备生成文档……"})
parts = append(parts, core.Part{Type: core.PartThinking, Text: "准备生成文档……", Process: true})
// 真 docx 演示(与 write_docx 工具同一生成器):mock 模式即可看到
// 「带排版 Word + 交付物卡片」全链路——标题层级/表格/列表都在文档字节里。
// 真 docx 演示(mock 专用确定性生成器——真实链路由 Agent 经 run_python +
// register_artifact 自产自登记):mock 模式即可看到「带排版 Word + 交付物
// 卡片」全链路——标题层级/表格/列表都在文档字节里。
md := "# 演示会议纪要\n\n这是 mock runner 生成的示例 Word 工件。\n\n## 基本信息\n\n| 项目 | 内容 |\n|---|---|\n| 指令 | #doc |\n| 生成器 | docgen |\n\n## 要点\n\n1. 由 #doc 指令触发\n2. 带 Word 排版(标题/表格/编号列表)\n3. 对话内以交付物卡片呈现,点击右栏预览\n"
buf, derr := docgen.Build("", md)
if derr != nil {
@@ -189,7 +190,7 @@ func (m *MockRunner) Execute(rc RunCtx) error {
// 工件门面卡演示(CF-A/D47):registry 工具大结果的统一呈现——
// 摘要 + 来源 kv + doc-ref(正文权威在工件);收件箱关闭时 SubmitCard
// 返回空 ID,跳过卡部件。
if fc, ferr := rc.SubmitCard(artifactFacadeCard("write_docx", art, "演示 Word 工件已生成(#doc,带排版)")); ferr == nil && fc.ID != "" {
if fc, ferr := rc.SubmitCard(artifactFacadeCard("run_python", art, "演示 Word 工件已生成(#doc,带排版)")); ferr == nil && fc.ID != "" {
parts = append(parts, core.Part{Type: core.PartCard, Card: &core.CardPart{Kind: string(core.CardKindResult), CardID: fc.ID}})
}
}
-62
View File
@@ -1,62 +0,0 @@
// write_docx 工具(提案:正式交付物直接可交):模型交 Markdown,后端用
// docgen 转成带 Word 样式的 .docx 工件(标题层级/表格/编号列表/引用/代码段)。
// 对话内临时说明与纯文本/数据交付物仍走 write_artifact——两者分工见
// 提示词库 tool.write_docx / tool.write_artifact。
package tools
import (
"encoding/json"
"fmt"
"path/filepath"
"strings"
core "agent-system-core"
"agent-system-backend/internal/docgen"
)
const docxMIME = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
// registerWriteDocx 注册 docx 生成工具:name 建议含 .docx(缺省自动补),
// content 为 Markdown 源文(排版结构由 Markdown 表达,样式由生成器保证)。
func registerWriteDocx(r *Registry) error {
return r.Register(core.ToolDesc{
Name: "write_docx", Source: core.SourceBuiltin,
Description: "生成带排版样式的 Word 文档(.docx 工件):标题层级、表格、编号列表、引用、代码段、粗斜体与超链接。content 用 Markdown 书写,自动转换为正式 Word 排版。用于正式文档交付物(报告、纪要、方案、说明书)。",
Parameters: json.RawMessage(`{"type":"object","properties":{` +
`"name":{"type":"string","description":"文件名(建议 中文标题.docx;缺扩展名自动补)"},` +
`"title":{"type":"string","description":"文档标题(缺省取正文第一个标题)"},` +
`"content":{"type":"string","description":"Markdown 正文:#/##/### 表达章节,|表格|、- 列表、> 引用、**粗体**"}}` +
`,"required":["name","content"]}`),
}, func(rc ToolExecCtx, args json.RawMessage) (string, error) {
var a struct {
Name string `json:"name"`
Title string `json:"title"`
Content string `json:"content"`
}
if len(args) > 0 {
if err := json.Unmarshal(args, &a); err != nil {
return "", fmt.Errorf("write_docx: bad args: %w", err)
}
}
if strings.TrimSpace(a.Name) == "" {
return "", fmt.Errorf("write_docx: name required")
}
if strings.TrimSpace(a.Content) == "" {
return "", fmt.Errorf("write_docx: content required")
}
name := strings.TrimSpace(a.Name)
if strings.ToLower(filepath.Ext(name)) != ".docx" {
name += ".docx"
}
buf, err := docgen.Build(a.Title, a.Content)
if err != nil {
return "", fmt.Errorf("write_docx: %w", err)
}
art, err := rc.WriteArtifact(name, "document", docxMIME, buf)
if err != nil {
return "", err
}
return fmt.Sprintf("已生成 Word 文档 %s(%s,%d 字节,含标题/表格/列表排版)", art.Name, art.ID, art.Size), nil
})
}
-81
View File
@@ -1,81 +0,0 @@
// write_docx 工具测试:Markdown 进、合法 OOXML docx 工件出(标题/表格排版
// 落在文档字节里,而不是纯文本改扩展名);参数校验与扩展名补全。
package tools
import (
"archive/zip"
"bytes"
"encoding/xml"
"io"
"strings"
"testing"
)
func TestWriteDocxProducesStyledOOXML(t *testing.T) {
r := NewRegistry()
if err := r.RegisterBuiltin(); err != nil {
t.Fatal(err)
}
rc := &fakeFSCtx{}
res := call(t, r, rc, "write_docx", `{"name":"评审会议纪要","content":"# 纪要\n\n| 项目 | 内容 |\n|---|---|\n| 时间 | 2026-10-01 |\n\n- 要点一\n"}`)
if res.Status != "ok" {
t.Fatalf("write_docx: %s", res.Output)
}
if len(rc.written) != 1 || rc.written[0] != "评审会议纪要.docx" {
t.Fatalf("工件名不符(应自动补 .docx): %+v", rc.written)
}
if !strings.Contains(rc.lastMime, "wordprocessingml") {
t.Errorf("docx 工件 mime 不符: %s", rc.lastMime)
}
zr, err := zip.NewReader(bytes.NewReader(rc.lastContent), int64(len(rc.lastContent)))
if err != nil {
t.Fatalf("产物不是合法 zip/docx 容器: %v", err)
}
var doc string
for _, f := range zr.File {
if f.Name != "word/document.xml" {
continue
}
rc2, _ := f.Open()
b, _ := io.ReadAll(rc2)
rc2.Close()
doc = string(b)
}
if doc == "" {
t.Fatal("word/document.xml 缺失")
}
dec := xml.NewDecoder(strings.NewReader(doc))
for {
_, err := dec.Token()
if err == io.EOF {
break
}
if err != nil {
t.Fatalf("document.xml 不良构: %v", err)
}
}
for _, marker := range []string{`w:val="Heading1"`, `<w:tbl>`, `<w:numPr>`} {
if !strings.Contains(doc, marker) {
t.Errorf("docx 缺少排版标记 %s(仍是纯文本?)", marker)
}
}
}
func TestWriteDocxArgValidation(t *testing.T) {
r := NewRegistry()
if err := r.RegisterBuiltin(); err != nil {
t.Fatal(err)
}
cases := []struct{ name, args string }{
{"缺 name", `{"content":"x"}`},
{"缺 content", `{"name":"a.docx"}`},
{"坏 JSON", `{"name":`},
}
for _, c := range cases {
res := call(t, r, &fakeFSCtx{}, "write_docx", c.args)
if res.Status != "error" {
t.Errorf("%s: 期望 error,得 %s(%s)", c.name, res.Status, res.Output)
}
}
}
+120
View File
@@ -0,0 +1,120 @@
// 执行原语工具族(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"
}
}
-3
View File
@@ -180,8 +180,5 @@ func (r *Registry) RegisterBuiltin() error {
}); err != nil {
return err
}
if err := registerWriteDocx(r); err != nil {
return err
}
return registerPeerTools(r)
}