Skip to content

Commit ac0a31b

Browse files
shirgoldbirdclaude
andcommitted
fix(pipeline): stop the agent files telling the model it has tools
The generation step returned a transcript of itself using tools instead of a page: I'll research the GitHub repo and peer pages before writing. <tool_call>{"name": "Glob", "arguments": {"pattern": "docs/.../*.mdx"}}</tool_call> <tool_response>docs/learning-how-tos/cookbook/context-aware.mdx ...</tool_response> build_authoring_system_prompt pastes .claude/agents/docs-writer.md verbatim into a plain Messages API call. That file is an agent definition: its frontmatter declares `tools: Read, Write, Edit, Grep, Glob, Bash, Agent`, its body says to search with Grep and Glob and to delegate to the diataxis agent, and it ends with "Return the complete draft as file content (using Write or Edit)". The call passes no tools, so the model did the only thing those instructions allow and faked them. diataxis.md and editorial-reviewer.md declare tools too, so rework and review shared the exposure. - load_agent() strips the YAML frontmatter from every agent file. - NO_TOOLS, appended after the guidelines so it wins, cancels what the bodies still assume: no file reading, no Grep/Glob, no delegation, no tool calls, and "using Write or Edit" means put the content in the reply. - generate.py and rework.py check the reply's shape and spend one corrective turn when it's wrong, rather than writing an unusable draft. rework validates the `--- SPLIT:` markers for multi-target runs and frontmatter otherwise. Salvaging a page out of a transcript is deliberately not attempted: a faked tool result can quote another page's frontmatter, so cutting at the first `---` risks promoting spliced content. Assistant prefill would have been stronger, but claude-sonnet-4-6 rejects it: "This model does not support assistant message prefill." Verified by running the pipeline end to end on the one live gap (thin_page for the google-sheets cookbook page, which reproduced the transcript before this change): generate -> evaluate (0 errors, 0 warnings) -> review (clean, 5 fixes applied) -> promote wrote the canonical .mdx. The promoted page was reverted; it was a verification run, not content to ship. Tests: pipeline/test_prompts.py (14), including the real transcript as a case. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 7294f6f commit ac0a31b

5 files changed

Lines changed: 281 additions & 29 deletions

File tree

‎pipeline/README.md‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,23 @@ All model prompts are assembled from the files above in one place: the
2323
`build_*_system_prompt` / `load_planning_context` helpers in `pipeline/util.py`. That
2424
is plumbing — change behavior in the files above, not in `util.py`.
2525

26+
Two things in `util.py` are not plumbing, because the agent files can't express them.
27+
The `.claude/agents/*.md` files are agent definitions: their frontmatter declares
28+
`tools:` and their bodies say to search the repo with Grep and Glob and to return work
29+
"using Write or Edit". The pipeline pastes their prose into plain API calls where no
30+
tools exist, and taken at face value those instructions make the model return a
31+
transcript of itself researching instead of a page. So:
32+
33+
- `load_agent()` strips the frontmatter, and the `NO_TOOLS` block (appended after the
34+
guidelines, so it wins) cancels what the bodies still assume.
35+
- `generate.py` and `rework.py` check the reply's shape and, if it isn't a file, spend
36+
one corrective turn on it. Salvaging a page out of a transcript is deliberately not
37+
attempted: a faked tool result can quote another page's frontmatter, so cutting at
38+
the first `---` risks promoting spliced content.
39+
40+
Keep that in mind when editing the agent files: prose aimed at the interactive agent
41+
is also prompt text for the pipeline.
42+
2643
## Setup
2744

2845
```bash

‎pipeline/generate.py‎

Lines changed: 49 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -30,14 +30,7 @@
3030

3131
import yaml
3232

33-
try:
34-
import anthropic
35-
except ImportError:
36-
print("Install the Anthropic SDK: pip install anthropic")
37-
sys.exit(1)
38-
39-
40-
from util import build_authoring_system_prompt
33+
from util import build_authoring_system_prompt, looks_like_mdx, format_retry_prompt
4134
from open_prs import EXIT_NOTHING_TO_DO, fetch_open_prs, split_claimed_gaps
4235

4336
REPO_ROOT = Path(__file__).resolve().parent.parent
@@ -136,6 +129,20 @@ def find_existing_docs_for_family(family_name):
136129
return context_docs
137130

138131

132+
def make_client():
133+
"""Build the API client, importing the SDK only when a call is imminent.
134+
135+
Kept lazy so --dry-run, gap detection and the tests all work without the
136+
SDK installed. None of them talk to the model.
137+
"""
138+
try:
139+
import anthropic
140+
except ImportError:
141+
print("Install the Anthropic SDK: pip install anthropic")
142+
sys.exit(1)
143+
return anthropic.Anthropic()
144+
145+
139146
def build_system_prompt():
140147
# All writing rules live in the agent files, assembled in one place.
141148
return build_authoring_system_prompt(
@@ -266,17 +273,41 @@ def build_generation_prompt(gap, family_name, openapi_context, existing_docs_con
266273
{existing_docs_summary}"""
267274

268275

269-
def generate_content(client, system_prompt, user_prompt):
270-
"""Call Claude to generate content."""
276+
def _complete(client, system_prompt, messages):
271277
response = client.messages.create(
272278
model=MODEL,
273279
max_tokens=MAX_TOKENS,
274280
system=system_prompt,
275-
messages=[{"role": "user", "content": user_prompt}],
281+
messages=messages,
276282
)
277283
return response.content[0].text
278284

279285

286+
def generate_content(client, system_prompt, user_prompt, expect_file=True):
287+
"""Call Claude to generate content.
288+
289+
A reply that isn't a page gets one corrective turn rather than failing the
290+
run. The docs-writer guidelines are written for a harness with tools, so the
291+
model occasionally obliges by narrating research it cannot do and returns a
292+
transcript instead of a file.
293+
"""
294+
messages = [{"role": "user", "content": user_prompt}]
295+
text = _complete(client, system_prompt, messages)
296+
297+
if not expect_file or looks_like_mdx(text) or not text.strip():
298+
return text
299+
300+
print(" Reply was not a page (commentary or a faked tool transcript), asking again")
301+
messages += [
302+
# Echo a slice back rather than the whole thing: the bad reply can run to
303+
# hundreds of lines and only needs to be identifiable.
304+
{"role": "assistant", "content": text[:500].strip()},
305+
{"role": "user", "content": format_retry_prompt(
306+
"it did not start with YAML frontmatter (---)")},
307+
]
308+
return _complete(client, system_prompt, messages)
309+
310+
280311
def determine_output_path(gap, family_name, content=None):
281312
"""Determine where to write the generated content."""
282313
gap_type = gap["type"]
@@ -426,7 +457,7 @@ def main():
426457
print()
427458
return 0
428459

429-
client = anthropic.Anthropic()
460+
client = make_client()
430461
system_prompt = build_system_prompt()
431462

432463
run_id = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
@@ -454,7 +485,12 @@ def main():
454485
existing_docs = find_existing_docs_for_family(family)
455486
user_prompt = build_generation_prompt(gap, family, openapi_context, existing_docs)
456487

457-
content = generate_content(client, system_prompt, user_prompt)
488+
# Every gap but missing_description wants a whole file back;
489+
# missing_description wants a bare string, so don't shape-check it.
490+
content = generate_content(
491+
client, system_prompt, user_prompt,
492+
expect_file=gap["type"] != "missing_description",
493+
)
458494

459495
if gap["type"] == "missing_description":
460496
description = content.strip().strip('"').strip("'")

‎pipeline/rework.py‎

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@
5858
sys.exit(1)
5959

6060

61-
from util import build_authoring_system_prompt
61+
from util import build_authoring_system_prompt, looks_like_mdx, format_retry_prompt
6262

6363
REPO_ROOT = Path(__file__).resolve().parent.parent
6464
OPENAPI_PATH = REPO_ROOT / "api-reference" / "openapi.yaml"
@@ -359,13 +359,35 @@ def main():
359359
# Call Claude
360360
print("Calling Claude...")
361361
client = anthropic.Anthropic()
362-
with client.messages.stream(
363-
model=MODEL,
364-
max_tokens=MAX_TOKENS * len(targets),
365-
system=system_prompt,
366-
messages=[{"role": "user", "content": user_prompt}],
367-
) as stream:
368-
raw_output = stream.get_final_text()
362+
363+
def stream_text(messages):
364+
with client.messages.stream(
365+
model=MODEL,
366+
max_tokens=MAX_TOKENS * len(targets),
367+
system=system_prompt,
368+
messages=messages,
369+
) as stream:
370+
return stream.get_final_text()
371+
372+
messages = [{"role": "user", "content": user_prompt}]
373+
raw_output = stream_text(messages)
374+
375+
# A multi-target reply is keyed on `--- SPLIT: <path> ---` markers; a
376+
# single-target one is just the file. Either way, give one corrective turn
377+
# when the shape is wrong instead of writing an unusable draft.
378+
if len(targets) > 1:
379+
ok = "--- SPLIT:" in raw_output
380+
problem = "it contained no `--- SPLIT: <path> ---` markers"
381+
else:
382+
ok = looks_like_mdx(raw_output)
383+
problem = "it did not start with YAML frontmatter (---)"
384+
385+
if not ok and raw_output.strip():
386+
print(" Reply was the wrong shape, asking again")
387+
raw_output = stream_text(messages + [
388+
{"role": "assistant", "content": raw_output[:500].strip()},
389+
{"role": "user", "content": format_retry_prompt(problem)},
390+
])
369391

370392
# Parse output
371393
if len(targets) > 1:

‎pipeline/test_prompts.py‎

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
#!/usr/bin/env python3
2+
"""Tests for prompt assembly and reply-shape checking.
3+
4+
python pipeline/test_prompts.py
5+
6+
The .claude/agents/*.md files are agent definitions written for a harness with
7+
tools. The pipeline reuses their prose in plain API calls where no tools exist,
8+
and when their tool instructions leak through, the model returns a transcript of
9+
itself "researching" instead of a page. These cover both halves of the guard:
10+
stripping the `tools:` frontmatter, and recognizing a reply that isn't a file.
11+
"""
12+
13+
import sys
14+
import tempfile
15+
import unittest
16+
from pathlib import Path
17+
18+
sys.path.insert(0, str(Path(__file__).resolve().parent))
19+
20+
from util import ( # noqa: E402
21+
NO_TOOLS,
22+
build_authoring_system_prompt,
23+
build_review_system_prompt,
24+
format_retry_prompt,
25+
load_agent,
26+
looks_like_mdx,
27+
)
28+
29+
30+
class LoadAgentTest(unittest.TestCase):
31+
def setUp(self):
32+
self._tmp = tempfile.TemporaryDirectory()
33+
self.addCleanup(self._tmp.cleanup)
34+
self.dir = Path(self._tmp.name)
35+
36+
def write(self, text):
37+
path = self.dir / "agent.md"
38+
path.write_text(text, encoding="utf-8")
39+
return path
40+
41+
def test_drops_the_frontmatter_including_the_tools_line(self):
42+
path = self.write(
43+
"---\nname: docs-writer\ntools: Read, Write, Grep, Glob, Agent\n---\n\n"
44+
"# Your Role\n\nYou write docs.\n"
45+
)
46+
body = load_agent(path)
47+
self.assertTrue(body.startswith("# Your Role"))
48+
self.assertNotIn("tools:", body)
49+
self.assertIn("You write docs.", body)
50+
51+
def test_a_file_without_frontmatter_is_unchanged(self):
52+
path = self.write("# Just Prose\n\nNo frontmatter here.\n")
53+
self.assertEqual(load_agent(path), "# Just Prose\n\nNo frontmatter here.\n")
54+
55+
def test_body_horizontal_rules_survive(self):
56+
"""Only the leading block is frontmatter; --- later is content."""
57+
path = self.write("---\nname: a\n---\n\n# T\n\nOne\n\n---\n\nTwo\n")
58+
body = load_agent(path)
59+
self.assertIn("\n---\n", body)
60+
self.assertIn("Two", body)
61+
62+
def test_missing_file_is_empty(self):
63+
self.assertEqual(load_agent(self.dir / "nope.md"), "")
64+
65+
66+
class SystemPromptTest(unittest.TestCase):
67+
"""Built from the repo's real agent files."""
68+
69+
def test_authoring_prompt_carries_the_override_and_no_tools_frontmatter(self):
70+
prompt = build_authoring_system_prompt("You are a documentation writer.")
71+
self.assertIn(NO_TOOLS, prompt)
72+
self.assertNotIn("tools: Read, Write", prompt)
73+
self.assertIn("Output ONLY the .mdx file content", prompt)
74+
75+
def test_the_override_comes_after_the_guidelines_it_overrides(self):
76+
prompt = build_authoring_system_prompt("role")
77+
self.assertGreater(prompt.index(NO_TOOLS), prompt.index("Docs Writer Guidelines"))
78+
79+
def test_review_prompt_too(self):
80+
prompt = build_review_system_prompt()
81+
self.assertIn(NO_TOOLS, prompt)
82+
self.assertNotIn("tools: Read, Grep", prompt)
83+
84+
85+
class LooksLikeMdxTest(unittest.TestCase):
86+
def test_a_page_passes(self):
87+
self.assertTrue(looks_like_mdx('---\ntitle: "T"\n---\n\nBody.\n'))
88+
89+
def test_leading_blank_lines_are_tolerated(self):
90+
self.assertTrue(looks_like_mdx('\n\n---\ntitle: T\n---\n\nBody.\n'))
91+
92+
def test_the_faked_tool_transcript_fails(self):
93+
"""The actual failure this guard exists for."""
94+
reply = (
95+
"I'll research the GitHub repo and peer pages before writing.\n\n"
96+
'<tool_call>\n{"name": "Glob", "arguments": {"pattern": "docs/**/*.mdx"}}\n'
97+
"</tool_call>\n<tool_response>\n---\ntitle: A peer page\n---\n</tool_response>\n"
98+
)
99+
self.assertFalse(looks_like_mdx(reply))
100+
101+
def test_commentary_then_a_page_fails(self):
102+
self.assertFalse(looks_like_mdx("Here is the page:\n\n---\ntitle: T\n---\n\nBody.\n"))
103+
104+
def test_unclosed_frontmatter_fails(self):
105+
self.assertFalse(looks_like_mdx("---\ntitle: T\n\nBody with no closing fence.\n"))
106+
107+
def test_empty_and_none(self):
108+
self.assertFalse(looks_like_mdx(""))
109+
self.assertFalse(looks_like_mdx(None))
110+
111+
112+
class RetryPromptTest(unittest.TestCase):
113+
def test_names_the_problem_and_forbids_a_preamble(self):
114+
prompt = format_retry_prompt("it did not start with YAML frontmatter (---)")
115+
self.assertIn("did not start with YAML frontmatter", prompt)
116+
self.assertIn("No preamble", prompt)
117+
118+
119+
if __name__ == "__main__":
120+
unittest.main(verbosity=2)

0 commit comments

Comments
 (0)