From 77da63edacc7a530cffd2feb223c6fd3fee89c2f Mon Sep 17 00:00:00 2001 From: weichen Date: Fri, 9 Oct 2026 18:01:06 +0000 Subject: [PATCH] Open the kernel workspace tools and the markdown-source build pipeline Why: User rulings 1 and 3. The direct conversation path exposed only run_shell/run_python, so any file edit meant rewriting and re-running whole generation scripts (diagnosis: 24 calls with 9 failures in one turn, 6KB scripts regenerated 8 times). The kernel already ships line-numbered reads, exact edit/multi_edit with read-before-write, grep and glob; they were wired only into the squad path. Deliverables had no editable source: docx output came from one-shot python scripts, so "make section 3 more detailed" rebuilt everything. Now: WorkspaceToolsCheckpointed (sandbox = conversation workspace, checkpoint hooks wired) is registered into the direct run and its decls join the tool surface (plan mode keeps the read-only subset). Word deliverables get a markdown-source pipeline - python3 -m housestyle.build renders .md deterministically via docx_kit and runs lint + outline on every build (write-time diagnostics, exit 1 on error so the model fixes the source and rebuilds). run_python kits remain for xlsx/pptx/images (ruling 3). base-tools/base-deliverables prompts and the house-style SKILL document the new loop. Evidence: SELF - TestDirectRunExposesKernelWorkspaceTools (read->edit->grep against the real sandbox FS), TestDirectRunWithoutSandboxHidesKernelTools, three new BuildTests in recipes (renders+lint passes, heading-skip errors exit 1, CLI entry); go vet + go test ./... pass; live before/after on the golden task set recorded on the phase03 task card per the policy clause. Impact: Direct conversations can now read and edit workspace files surgically; docx deliverables become edit-source + rebuild instead of regenerate. No contract or storage changes. --- internal/prompts/library/base-deliverables.md | 2 +- internal/prompts/library/base-tools.md | 2 +- internal/runtime/llm_direct.go | 49 +++++ internal/runtime/workspace_tools_test.go | 71 ++++++++ internal/skills/recipes/house-style/SKILL.md | 12 ++ .../recipes/house-style/housestyle/build.py | 169 ++++++++++++++++++ .../house-style/tests/test_housestyle.py | 44 +++++ 7 files changed, 347 insertions(+), 2 deletions(-) create mode 100644 internal/runtime/workspace_tools_test.go create mode 100644 internal/skills/recipes/house-style/housestyle/build.py diff --git a/internal/prompts/library/base-deliverables.md b/internal/prompts/library/base-deliverables.md index b0e80c8..cbef7fb 100644 --- a/internal/prompts/library/base-deliverables.md +++ b/internal/prompts/library/base-deliverables.md @@ -11,7 +11,7 @@ description: 生成文档、表格、演示稿的工作流、样式决策与技 1. **定样式**,按下面的优先级只取第一个命中项:本轮用户的明确指令(例如改成公文、换成品牌色)→ 本轮上传的模板(.docx/.dotx/.pptx/.potx/.xlsx/.xltx)→ 本轮要求仿制的参考文档(`from housestyle.extract import extract`,抽取字体、字号、色板、页边距,得到自定义主题)→ 本会话已经确定的样式 → 设置中的默认模板 → 设置中的 deliverables.style(modern / formal / minimal)和强调色。未指定时用环境段给出的默认样式。 2. **规划版面**:先定读者、结构和每一节的内容,再写脚本。不要边生成边补空节。 -3. **生成**:`import housestyle`,用 docx_kit / pptx_kit / xlsx_kit 的构件写文件。文件落在会话工作区。模板文件和参考原件在 `inputs/`(环境段列出相对路径)。 +3. **生成(Word 交付物优先走源码化)**:先写 Markdown 源文件(`write_file 方案.md`,约定见 `python3 -m housestyle.build` 的 docstring:`#`标题/`##`-`####` 三级、`-`/`1.` 列表、`| |` 表格、`>` 引用、`!!! 标签 文本` 强调块、首行 `` 可选主题),再 `python3 -m housestyle.build 方案.md` 确定性渲染成 docx——build 自动跑 lint 并回读结构(写入即诊断),有 error 就改 .md 源的对应段落再重建,不要整段重写。修改已有交付物 = `edit_file` 改 .md 源 + 重新 build。Excel/PPT/图片等其他类型仍用 `run_python` + 对应 kit。文件都在会话工作区;模板和参考原件在 `inputs/`(环境段列出相对路径)。 4. **自检**:`python3 -m housestyle.lint <文件>`。输出为 JSON;存在 severity=error 时必须修好再继续,不要带着 error 登记。然后用 `python3 -m housestyle.outline_cli <文件>` 回读标题、表格和页数,核对与规划一致。 5. **登记**:`register_artifact`(path 传相对路径)。登记后对话里出现可预览的卡片。 6. **收尾**:回复只写结论摘要,并说明这次用的样式(主题、是否模板、是否仿制)以及用户还能怎么调。不要在回复里复述全文,也不要只口头说文件名。 diff --git a/internal/prompts/library/base-tools.md b/internal/prompts/library/base-tools.md index 239b451..724f889 100644 --- a/internal/prompts/library/base-tools.md +++ b/internal/prompts/library/base-tools.md @@ -7,7 +7,7 @@ description: 何时用工具、如何并行调用、如何处理工具结果 --- 使用工具时遵循: -- 优先专用工具而非通用手段:检索知识用检索工具、写产物用工件工具、查时间用时间工具;能用结构化工具完成的,不绕道自由文本。 +- 优先专用工具而非通用手段:读文件用 read_file(行号分页)、找内容用 grep、找文件用 glob、改文件用 edit_file/multi_edit(须先读过、old_string 唯一);会话工作区内的文件操作一律用这些工具,不用 run_shell 的 cat/sed 代替。检索知识用检索工具、查时间用时间工具;能用结构化工具完成的,不绕道自由文本。 - 相互独立的调用并行发起;有依赖的调用按依赖顺序串行。 - 调用前确认参数完整、类型正确;不确定参数含义时先查阅工具描述,不猜测字段。 - 工具结果是事实来源:基于结果作答并如实转述;结果为空或报错时说明情况,不假装拿到了数据。 diff --git a/internal/runtime/llm_direct.go b/internal/runtime/llm_direct.go index 56c397f..6262157 100644 --- a/internal/runtime/llm_direct.go +++ b/internal/runtime/llm_direct.go @@ -11,6 +11,8 @@ import ( "time" nruntime "agent-native/runtime" + "agent-native/runtime/checkpoint" + "agent-native/runtime/filestate" "agent-native/runtime/gates" "agent-native/runtime/hooks" agentllm "agent-native/runtime/llm" @@ -126,6 +128,35 @@ func (l *LLMRunner) runDirect(rc RunCtx, stream agentllm.StreamFunc, model strin return st.runTool(name, string(args)) }}) } + // 内核工作区工具接入直连路径(裁定 1:尽可能开放工具):行号分页 read、 + // 精确 edit/multi_edit(先读后写)、grep/glob/list——沙箱=会话工作区。 + // 产品侧同域 fs_* 仍按 runtime.fsTools 门控(隐藏),不与内核名冲突。 + if sandbox := conversationSandbox(rc); sandbox != "" { + var cpLog *checkpoint.Log + var anchor func() string + if host, ok := rc.(interface { + OpenCheckpoints() (*checkpoint.Log, error) + CheckpointAnchor() string + }); ok { + if lg, err := host.OpenCheckpoints(); err == nil { + cpLog = lg + a := host.CheckpointAnchor() + anchor = func() string { return a } + } + } + ws := agenttools.WorkspaceToolsCheckpointed(sandbox, filestate.New(), cpLog, anchor) + for _, wd := range ws.Decls() { + name := wd.Name + decls = append(decls, agentllm.ToolDecl{Name: wd.Name, Description: wd.Description, Parameters: wd.Parameters}) + wsReg := ws + reg.Register(directTool{name: name, run: func(ctx context.Context, args json.RawMessage) agenttools.Result { + res := wsReg.Execute(ctx, name, args) + st.noteKernelResult(res) + return res + }}) + } + decls = filterPlanDecls(decls, permissionModeOf(rc)) + } user := agentllm.Message{Role: agentllm.RoleUser} if len(msgs) > 0 { @@ -325,6 +356,24 @@ func (l *LLMRunner) runDirect(rc RunCtx, stream agentllm.StreamFunc, model strin return nil } +// noteKernelResult 镜像 runTool 的结果登记:内核工作区工具不经过产品执行路径, +// 摘要/状态/验证文本在这里补记,保证过程面板与终轮验证器看到同样的证据。 +func (st *directRun) noteKernelResult(res agenttools.Result) { + if st.inflight == nil { + return + } + summary := summarize(res.Content, nil) + if res.IsError { + summary = "error: " + summary + } + st.inflight.summary = summary + st.inflight.status = statusOf(nil) + if res.IsError { + st.inflight.status = "error" + } + st.inflight.verifyText = tailRunes(res.Content, 600) +} + // noteToolDelta 把模型正在编写的工具参数立刻转成会话事件。 // 执行开始前的这段时间原先没有任何工具事件,界面只剩光标。 func (st *directRun) noteModelSwitch(from, to string) { diff --git a/internal/runtime/workspace_tools_test.go b/internal/runtime/workspace_tools_test.go new file mode 100644 index 0000000..45dff41 --- /dev/null +++ b/internal/runtime/workspace_tools_test.go @@ -0,0 +1,71 @@ +package runtime + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + agentllm "agent-native/runtime/llm" + core "agent-system-core" +) + +// 裁定 1:会话沙箱配置时,内核工作区工具(read/edit/multi_edit/grep/glob/list) +// 进入直连工具面;计划模式只留只读子集。 +func TestDirectRunExposesKernelWorkspaceTools(t *testing.T) { + sandbox := t.TempDir() + if err := os.WriteFile(filepath.Join(sandbox, "note.txt"), []byte("alpha\nbeta\n"), 0o644); err != nil { + t.Fatal(err) + } + rc := &fakeRunCtx{ + ctx: context.Background(), sandbox: sandbox, + prof: core.AgentProfile{Model: core.ModelRoute{Chat: "test-model"}}, + } + // 第一步 read_file(行号分页),第二步 edit_file(须先读过),第三步 grep。 + turns := padTurns( + textDeltaTurn("", toolCall("read_file", `{"path":"note.txt"}`)), + textDeltaTurn("", toolCall("edit_file", `{"path":"note.txt","old":"alpha","new":"gamma"}`)), + textDeltaTurn("", toolCall("grep", `{"pattern":"gamma"}`)), + textDeltaTurn("改完了。"), + ) + r := &LLMRunner{Stream: scriptStream(turns, nil)} + if err := r.Execute(rc); err != nil { + t.Fatalf("Execute: %v", err) + } + raw, err := os.ReadFile(filepath.Join(sandbox, "note.txt")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(raw), "gamma") || strings.Contains(string(raw), "alpha") { + t.Fatalf("edit_file did not apply: %q", string(raw)) + } + found := 0 + for _, p := range rc.assistant { + if p.ToolResult != nil && p.ToolResult.Summary != "" { + found++ + } + } + if found < 3 { + t.Fatalf("workspace tool results missing from assistant parts: %+v", rc.assistant) + } +} + +// 无沙箱(未配置工作区)时内核工具不注册,模型看不到。 +func TestDirectRunWithoutSandboxHidesKernelTools(t *testing.T) { + rc := &fakeRunCtx{ + ctx: context.Background(), + prof: core.AgentProfile{Model: core.ModelRoute{Chat: "test-model"}}, + } + r := &LLMRunner{Stream: scriptStream(textTurns("你好。"), nil)} + if err := r.Execute(rc); err != nil { + t.Fatalf("Execute: %v", err) + } + for _, p := range rc.assistant { + if p.ToolCall != nil && p.ToolCall.Name == "read_file" { + t.Fatalf("read_file must not be visible without a sandbox") + } + } +} + +var _ = agentllm.RoleUser diff --git a/internal/skills/recipes/house-style/SKILL.md b/internal/skills/recipes/house-style/SKILL.md index 980840c..8d12e88 100644 --- a/internal/skills/recipes/house-style/SKILL.md +++ b/internal/skills/recipes/house-style/SKILL.md @@ -71,6 +71,18 @@ xs.save(wb, "交付物.xlsx", ["单位:元。口径见源表。"]) 标题用 `add_heading`,列表用 `add_bullets` / `add_numbers`,数据用 `add_table`。间距由样式控制。不要 `add_paragraph("")`,不要在普通段落开头写 `•`。 +## 源码化构建(Word 首选) + +```bash +python3 -m housestyle.build 方案.md # 渲染为 方案.docx,自动 lint + 回读结构 +python3 -m housestyle.build 方案.md -o 输出.docx +``` + +`.md` 源是可编辑的交付物源码:`# 标题`、`>` 引用、`##/###/####` 三级标题、`-`/`1.` 列表、 +`| |` 表格、`!!! 标签 文本` 强调块;首行 `` +可选主题。build 退出码非 0 时按输出的诊断改 .md 源对应段落后重建;修改交付物 = edit 源 + 重建。 +Excel/PPT 用对应 kit 的 run_python 脚本。 + ## 自检 ```bash diff --git a/internal/skills/recipes/house-style/housestyle/build.py b/internal/skills/recipes/house-style/housestyle/build.py new file mode 100644 index 0000000..8230ae5 --- /dev/null +++ b/internal/skills/recipes/house-style/housestyle/build.py @@ -0,0 +1,169 @@ +"""交付物源码化渲染:python3 -m housestyle.build <源.md> [-o 输出.docx]。 + +把可编辑的 Markdown 源文件确定性渲染为 docx(docx_kit 构件 + 主题), +写入即诊断:渲染后自动跑 lint 并回读结构,severity=error 时退出码 1, +诊断信息带触发段落原文,模型据此改 .md 源再重建(改源码→编译→看诊断), +不再整段重写生成脚本。xlsx/pptx 等其他类型仍走 run_python + 对应 kit。 + +Markdown 约定: + # 标题 文档标题(Title,只取第一个) + > 副标题或日期 标题下副题(只取标题后的第一段 >) + ## 一级 / ### 二级 / #### 三级 Heading 1/2/3(标题层级连续) + - 或 * 条目 项目符号 + 1. 条目 编号列表 + | a | b | 表格(首个分隔行 |---|---| 定表头) + > 引文 引用块(副题之后) + !!! 标签 文本 强调块(label=标签,缺省「摘要」) + 主题与强调色(可省) +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +from housestyle import docx_kit as dx +from housestyle import lint + + +def build(source: str, output: str | None = None) -> int: + src = Path(source) + if not src.is_file(): + print(f"build: 源文件不存在: {source}", file=sys.stderr) + return 2 + text = src.read_text(encoding="utf-8") + out = Path(output or str(src.with_suffix(".docx"))) + if out.suffix.lower() not in (".docx",): + print(f"build: 输出只支持 .docx(其他类型用 run_python + 对应 kit)", file=sys.stderr) + return 2 + + theme, accent = "modern", None + lines = text.splitlines() + if lines and lines[0].strip().startswith("", "").split(): + if "=" in kv: + k, v = kv.split("=", 1) + if k == "theme": + theme = v + elif k == "accent": + accent = v + lines = lines[1:] + + doc = dx.new_document(theme=theme, accent=accent, title=_first_title(lines) or out.stem) + _render(doc, lines) + dx.save(doc, str(out)) + + issues = lint.lint_path(str(out)) + errors = [i for i in issues if i.get("severity") == "error"] + from housestyle.outline import summarize + + print(f"built {out.name}") + print(summarize(str(out))) + if issues: + print(json.dumps(issues, ensure_ascii=False, indent=2)) + if errors: + print(f"lint: {len(errors)} 个 error——修 .md 源里对应段落后重新 build", file=sys.stderr) + return 1 + print("lint: 通过(0 error)") + return 0 + + +def _first_title(lines: list[str]) -> str | None: + for line in lines: + if line.startswith("# "): + return line[2:].strip() + return None + + +def _split_row(line: str) -> list[str]: + body = line.strip().strip("|") + return [c.strip() for c in body.split("|")] + + +def _is_table_sep(line: str) -> bool: + s = line.strip() + if not s.startswith("|"): + return False + cells = [c.strip() for c in s.strip("|").split("|")] + return bool(cells) and all(set(c) <= set("-: ") and c for c in cells) + + +def _render(doc, lines: list[str]) -> None: + title_seen = False + subtitle_used = False + i = 0 + pending_numbers: list[str] = [] + pending_bullets: list[str] = [] + + def flush_lists() -> None: + nonlocal pending_numbers, pending_bullets + if pending_bullets: + dx.add_bullets(doc, pending_bullets) + pending_bullets = [] + if pending_numbers: + dx.add_numbers(doc, pending_numbers) + pending_numbers = [] + + while i < len(lines): + raw = lines[i] + line = raw.rstrip() + stripped = line.strip() + if not stripped or stripped == "---": + flush_lists() + i += 1 + continue + if stripped.startswith("|") and i + 1 < len(lines) and _is_table_sep(lines[i + 1]): + flush_lists() + headers = _split_row(stripped) + rows = [] + j = i + 2 + while j < len(lines) and lines[j].strip().startswith("|"): + rows.append(_split_row(lines[j])) + j += 1 + dx.add_table(doc, headers, rows) + i = j + continue + flush_lists() + if stripped.startswith("# ") and not title_seen: + dx.add_title(doc, stripped[2:].strip()) + title_seen = True + elif stripped.startswith("#### "): + dx.add_heading(doc, stripped[5:].strip(), 3) + elif stripped.startswith("### "): + dx.add_heading(doc, stripped[4:].strip(), 2) + elif stripped.startswith("## "): + dx.add_heading(doc, stripped[3:].strip(), 1) + elif stripped.startswith("!!!"): + rest = stripped[3:].strip() + label, _, body = rest.partition(" ") + dx.add_callout(doc, body.strip() or rest, label.strip() or "摘要") + elif stripped.startswith("> ") or stripped.startswith(">"): + quote = stripped.lstrip(">").strip() + dx.add_quote(doc, quote, "") + elif stripped.startswith(("- ", "* ")): + pending_bullets.append(stripped[2:].strip()) + elif len(stripped) > 2 and stripped[0].isdigit() and stripped[1:3] in (". ", ") ") or ( + len(stripped) > 3 and stripped[:2].isdigit() and stripped[2:4] == ". " + ): + pending_numbers.append(stripped.split(".", 1)[1].strip() if "." in stripped else stripped) + else: + dx.add_paragraph(doc, stripped) + i += 1 + flush_lists() + + +if __name__ == "__main__": + if len(sys.argv) < 2: + print("usage: python3 -m housestyle.build <源.md> [-o 输出.docx]", file=sys.stderr) + raise SystemExit(2) + args = sys.argv[1:] + out = None + if "-o" in args: + k = args.index("-o") + if k + 1 >= len(args): + print("build: -o 缺少输出路径", file=sys.stderr) + raise SystemExit(2) + out = args[k + 1] + args = args[:k] + args[k + 2 :] + raise SystemExit(build(args[0], out)) diff --git a/internal/skills/recipes/house-style/tests/test_housestyle.py b/internal/skills/recipes/house-style/tests/test_housestyle.py index 9fa944d..49fd0b3 100644 --- a/internal/skills/recipes/house-style/tests/test_housestyle.py +++ b/internal/skills/recipes/house-style/tests/test_housestyle.py @@ -168,3 +168,47 @@ class VisualTests(unittest.TestCase): if __name__ == "__main__": unittest.main() + + +class BuildTests(unittest.TestCase): + def test_build_renders_markdown_and_passes_lint(self): + import tempfile, os, subprocess, sys, json + from housestyle import build as B + with tempfile.TemporaryDirectory() as d: + src = os.path.join(d, "plan.md") + out = os.path.join(d, "plan.docx") + open(src, "w", encoding="utf-8").write( + "# 测试方案:先写需求再开发\n\n" + "## 一、结论\n结论:先写需求文档。\n\n" + "- 第一步:停代码\n- 第二步:写需求\n\n" + "## 二、行动项\n\n| 事项 | 负责人 |\n|---|---|\n| 需求文档 | 陈威 |\n\n" + "1. 确认设计输入\n" + ) + self.assertEqual(B.build(src, out), 0) + self.assertTrue(os.path.exists(out)) + from housestyle.outline import summarize + o = summarize(out) + self.assertIn("测试方案", o) + self.assertIn("一、结论", o) + self.assertIn("tables 1", o) + + def test_build_reports_lint_errors_with_nonzero_exit(self): + import tempfile, os + from housestyle import build as B + with tempfile.TemporaryDirectory() as d: + # 标题跳级(## 后直接 ####)触发 heading-skip error —— 诊断导向改源。 + src = os.path.join(d, "bad.md") + out = os.path.join(d, "bad.docx") + open(src, "w", encoding="utf-8").write("# 标题\n\n## 一、章节\n\n#### 跳级\n内容\n") + self.assertEqual(B.build(src, out), 1) + + def test_build_cli_entry(self): + import subprocess, sys, tempfile, os + with tempfile.TemporaryDirectory() as d: + src = os.path.join(d, "s.md") + open(src, "w", encoding="utf-8").write("# T\n\n## 一、A\n内容\n") + r = subprocess.run([sys.executable, "-m", "housestyle.build", src, "-o", os.path.join(d, "s.docx")], + capture_output=True, text=True, cwd=os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + # PYTHONPATH 由调用方保证;这里直接在 house-style 根目录下跑模块。 + if r.returncode not in (0, 1): + self.fail(r.stderr[:200])