Merge pull request 'Return deliverable generation to the agent and fix the artifact-card root cause' (#32) from feature/agent-native-deliverables-r1 into main
This commit was merged in pull request #32.
This commit is contained in:
@@ -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。
|
||||
- 文件名用人类可读的中文名(含扩展名);修订生成新文件而不是覆盖旧名。
|
||||
@@ -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 排版"才用本工具。
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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}})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -180,8 +180,5 @@ func (r *Registry) RegisterBuiltin() error {
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := registerWriteDocx(r); err != nil {
|
||||
return err
|
||||
}
|
||||
return registerPeerTools(r)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user