Merge pull request 'Open kernel workspace tools and the markdown-source build pipeline (quality P3)' (#120) from feature/agent-quality-phase03 into main
ci / vet-and-test (push) Successful in 43s

This commit was merged in pull request #120.
This commit is contained in:
2026-10-09 18:29:38 +00:00
7 changed files with 347 additions and 2 deletions
@@ -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.` 列表、`| |` 表格、`>` 引用、`!!! 标签 文本` 强调块、首行 `<!-- housestyle: theme=modern -->` 可选主题),再 `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. **收尾**:回复只写结论摘要,并说明这次用的样式(主题、是否模板、是否仿制)以及用户还能怎么调。不要在回复里复述全文,也不要只口头说文件名。
+1 -1
View File
@@ -7,7 +7,7 @@ description: 何时用工具、如何并行调用、如何处理工具结果
---
使用工具时遵循:
- 优先专用工具而非通用手段:检索知识用检索工具、写产物用工件工具、查时间用时间工具;能用结构化工具完成的,不绕道自由文本。
- 优先专用工具而非通用手段:读文件用 read_file(行号分页)、找内容用 grep、找文件用 glob、改文件用 edit_file/multi_edit(须先读过、old_string 唯一);会话工作区内的文件操作一律用这些工具,不用 run_shell 的 cat/sed 代替。检索知识用检索工具、查时间用时间工具;能用结构化工具完成的,不绕道自由文本。
- 相互独立的调用并行发起;有依赖的调用按依赖顺序串行。
- 调用前确认参数完整、类型正确;不确定参数含义时先查阅工具描述,不猜测字段。
- 工具结果是事实来源:基于结果作答并如实转述;结果为空或报错时说明情况,不假装拿到了数据。
+49
View File
@@ -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) {
+71
View File
@@ -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
@@ -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.` 列表、
`| |` 表格、`!!! 标签 文本` 强调块;首行 `<!-- housestyle: theme=modern accent=1B3A4B -->`
可选主题。build 退出码非 0 时按输出的诊断改 .md 源对应段落后重建;修改交付物 = edit 源 + 重建。
Excel/PPT 用对应 kit 的 run_python 脚本。
## 自检
```bash
@@ -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=标签,缺省「摘要」)
<!-- housestyle: theme=modern accent=1B3A4B --> 主题与强调色(可省)
"""
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("<!-- housestyle:"):
for kv in lines[0].replace("<!-- housestyle:", "").replace("-->", "").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))
@@ -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])