From d6bef8663f55287efbf488182f7ee5504d4dc2fa Mon Sep 17 00:00:00 2001 From: weichen Date: Thu, 1 Oct 2026 03:32:04 +0000 Subject: [PATCH] Return deliverable generation to the agent and fix the artifact-card root cause MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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 --- internal/app/app.go | 6 + internal/prompts/library/base-deliverables.md | 39 ++++++ internal/prompts/library/task-doc-minutes.md | 2 +- .../prompts/library/tool-write-artifact.md | 6 +- internal/prompts/library/tool-write-docx.md | 10 -- internal/prompts/prompts_test.go | 6 +- internal/runmgr/crossread_test.go | 4 +- internal/runmgr/exec_tool_artifact_test.go | 73 +++++++++++ internal/runmgr/runmgr.go | 44 +++++-- internal/runtime/llm.go | 34 ++++- internal/runtime/llm_think_test.go | 68 ++++++++++ internal/runtime/mock.go | 7 +- internal/tools/docx.go | 62 --------- internal/tools/docx_test.go | 81 ------------ internal/tools/exec.go | 120 ++++++++++++++++++ internal/tools/registry.go | 3 - 16 files changed, 382 insertions(+), 183 deletions(-) create mode 100644 internal/prompts/library/base-deliverables.md delete mode 100644 internal/prompts/library/tool-write-docx.md create mode 100644 internal/runmgr/exec_tool_artifact_test.go create mode 100644 internal/runtime/llm_think_test.go delete mode 100644 internal/tools/docx.go delete mode 100644 internal/tools/docx_test.go create mode 100644 internal/tools/exec.go diff --git a/internal/app/app.go b/internal/app/app.go index 1c4aaba..930956b 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -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 语义:未配置不静默降级)。 diff --git a/internal/prompts/library/base-deliverables.md b/internal/prompts/library/base-deliverables.md new file mode 100644 index 0000000..4329fb4 --- /dev/null +++ b/internal/prompts/library/base-deliverables.md @@ -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。 +- 文件名用人类可读的中文名(含扩展名);修订生成新文件而不是覆盖旧名。 diff --git a/internal/prompts/library/task-doc-minutes.md b/internal/prompts/library/task-doc-minutes.md index e453950..9ec0198 100644 --- a/internal/prompts/library/task-doc-minutes.md +++ b/internal/prompts/library/task-doc-minutes.md @@ -26,4 +26,4 @@ triggers: - 区分三件事:讨论过程(谁说了什么)、形成的决议(集体结论)、待办事项(谁在何时前做什么)——不要把发言当决议。 - 内容完整详实,除非用户明确要求精简,不要压缩篇幅;原文模糊或矛盾的点在"未决问题"中列出而不是擅自裁决。 - 待办事项若原文未指明负责人或期限,写"未指明",不要编造。 -- 交付形态:用 write_docx 生成正式 Word 文档(Markdown 表达结构:一级标题为纪要标题、基本信息与待办用表格、决议用编号列表),name 用「<主题>会议纪要.docx」;对话内只简述结论,全文以文档交付,不要在回复里复述全文。 +- 交付形态:按「交付物工作流」自己生成正式 Word 文档——run_python(python-docx 配方)产出「<主题>会议纪要.docx」(标题层级/基本信息表/待办表/决议编号列表都用工件排版表达),再 register_artifact 登记交付;对话内只简述结论,不要在回复里复述全文。 diff --git a/internal/prompts/library/tool-write-artifact.md b/internal/prompts/library/tool-write-artifact.md index 6e41caa..4ee36a2 100644 --- a/internal/prompts/library/tool-write-artifact.md +++ b/internal/prompts/library/tool-write-artifact.md @@ -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)。 diff --git a/internal/prompts/library/tool-write-docx.md b/internal/prompts/library/tool-write-docx.md deleted file mode 100644 index 931942d..0000000 --- a/internal/prompts/library/tool-write-docx.md +++ /dev/null @@ -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 排版"才用本工具。 diff --git a/internal/prompts/prompts_test.go b/internal/prompts/prompts_test.go index d787aa2..7ba366b 100644 --- a/internal/prompts/prompts_test.go +++ b/internal/prompts/prompts_test.go @@ -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 { diff --git a/internal/runmgr/crossread_test.go b/internal/runmgr/crossread_test.go index 3bf8f78..0ae2554 100644 --- a/internal/runmgr/crossread_test.go +++ b/internal/runmgr/crossread_test.go @@ -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) { diff --git a/internal/runmgr/exec_tool_artifact_test.go b/internal/runmgr/exec_tool_artifact_test.go new file mode 100644 index 0000000..b2ed6e4 --- /dev/null +++ b/internal/runmgr/exec_tool_artifact_test.go @@ -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) + } +} diff --git a/internal/runmgr/runmgr.go b/internal/runmgr/runmgr.go index e783cc0..2490904 100644 --- a/internal/runmgr/runmgr.go +++ b/internal/runmgr/runmgr.go @@ -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 } diff --git a/internal/runtime/llm.go b/internal/runtime/llm.go index 0e40fa7..54d0838 100644 --- a/internal/runtime/llm.go +++ b/internal/runtime/llm.go @@ -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++ + // 部分模型把推理以 内联混进 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 剥离正文里内联的 … 推理块(部分 OpenAI 兼容 +// 模型把思考混进 content 流):返回(干净正文, 思考内容)。只处理首部成对标签; +// 未闭合或非首部出现按原文返回(宁可多显示也不误删正文)。 +func splitThinkBlock(s string) (body, think string) { + trimmed := strings.TrimLeft(s, " \t\r\n") + if !strings.HasPrefix(trimmed, "") { + return s, "" + } + rest := strings.TrimPrefix(trimmed, "") + i := strings.Index(rest, "") + if i < 0 { + return s, "" + } + think = strings.TrimSpace(rest[:i]) + body = strings.TrimSpace(rest[i+len(""):]) + if think == "" { + return body, "" + } + return body, think +} diff --git a/internal/runtime/llm_think_test.go b/internal/runtime/llm_think_test.go new file mode 100644 index 0000000..a39d7c7 --- /dev/null +++ b/internal/runtime/llm_think_test.go @@ -0,0 +1,68 @@ +// 最终输出契约测试:内联 折离 + 终轮正文干净(llm.go splitThinkBlock)。 +package runtime + +import ( + "context" + "strings" + "testing" + + agentllm "agent-native/runtime/llm" + core "agent-system-core" +) + +// TestSplitThinkBlock 单测:首部 … 剥离;未闭合/非首部/空块按原文。 +func TestSplitThinkBlock(t *testing.T) { + cases := []struct { + in string + body, think string + }{ + {"推理答案", "答案", "推理"}, + {" 推理\n答案", "答案", "推理"}, + {"推理", "", "推理"}, + {"未闭合", "未闭合", ""}, + {"正文 混入", "正文 混入", ""}, + {"没有标签的正文", "没有标签的正文", ""}, + {"答案", "答案", ""}, + } + 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 契约:模型把推理以 内联进最终回复时, +// 落库拆为「思考(过程)+ 干净正文」——最终输出即对用户的回复。 +func TestLLMRunnerThinkStrippedFromReply(t *testing.T) { + turns := [][]agentllm.Event{ + { + agentllm.Event{Kind: agentllm.KindTextDelta, Text: "用户要纪要,我分三步……\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, "") || !strings.Contains(text.Text, "要点三项") { + t.Errorf("最终正文不干净: %+v", text) + } + if text.Process { + t.Errorf("终轮正文不得标记 Process: %+v", text) + } +} diff --git a/internal/runtime/mock.go b/internal/runtime/mock.go index e96a6f4..40c2613 100644 --- a/internal/runtime/mock.go +++ b/internal/runtime/mock.go @@ -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}}) } } diff --git a/internal/tools/docx.go b/internal/tools/docx.go deleted file mode 100644 index c3685d3..0000000 --- a/internal/tools/docx.go +++ /dev/null @@ -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 - }) -} diff --git a/internal/tools/docx_test.go b/internal/tools/docx_test.go deleted file mode 100644 index c9408cb..0000000 --- a/internal/tools/docx_test.go +++ /dev/null @@ -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"`, ``, ``} { - 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) - } - } -} diff --git a/internal/tools/exec.go b/internal/tools/exec.go new file mode 100644 index 0000000..32a3bb1 --- /dev/null +++ b/internal/tools/exec.go @@ -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" + } +} diff --git a/internal/tools/registry.go b/internal/tools/registry.go index be8356c..00422e6 100644 --- a/internal/tools/registry.go +++ b/internal/tools/registry.go @@ -180,8 +180,5 @@ func (r *Registry) RegisterBuiltin() error { }); err != nil { return err } - if err := registerWriteDocx(r); err != nil { - return err - } return registerPeerTools(r) }