Open the kernel workspace tools and the markdown-source build pipeline
ci / vet-and-test (pull_request) Successful in 41s
ci / vet-and-test (pull_request) Successful in 41s
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.
This commit is contained in:
@@ -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. **收尾**:回复只写结论摘要,并说明这次用的样式(主题、是否模板、是否仿制)以及用户还能怎么调。不要在回复里复述全文,也不要只口头说文件名。
|
||||
|
||||
@@ -7,7 +7,7 @@ description: 何时用工具、如何并行调用、如何处理工具结果
|
||||
---
|
||||
使用工具时遵循:
|
||||
|
||||
- 优先专用工具而非通用手段:检索知识用检索工具、写产物用工件工具、查时间用时间工具;能用结构化工具完成的,不绕道自由文本。
|
||||
- 优先专用工具而非通用手段:读文件用 read_file(行号分页)、找内容用 grep、找文件用 glob、改文件用 edit_file/multi_edit(须先读过、old_string 唯一);会话工作区内的文件操作一律用这些工具,不用 run_shell 的 cat/sed 代替。检索知识用检索工具、查时间用时间工具;能用结构化工具完成的,不绕道自由文本。
|
||||
- 相互独立的调用并行发起;有依赖的调用按依赖顺序串行。
|
||||
- 调用前确认参数完整、类型正确;不确定参数含义时先查阅工具描述,不猜测字段。
|
||||
- 工具结果是事实来源:基于结果作答并如实转述;结果为空或报错时说明情况,不假装拿到了数据。
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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])
|
||||
|
||||
Reference in New Issue
Block a user