Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ tests/*
!tests/test_scala_analyzer.py
!tests/test_agent_limits.py
!tests/test_module_scope.py
!tests/test_doc_write_guard.py
!tests/test_overview_generation.py

# Jupyter
*.ipynb
Expand Down
191 changes: 191 additions & 0 deletions codewiki/src/be/agent_tools/doc_guard.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
"""Make a documentation agent actually write its page.

A module agent can end its run with a text answer and never call
``str_replace_editor create`` (or call it only with arguments the tool
rejects). The run then "succeeds" but the page is missing, and the CLI only
notices at the very end; re-running repeats the same miss (issue #134).

:func:`run_until_doc_written` checks the page after each run and, when it is
missing, continues the same conversation with a reminder naming the exact
file to create. Every miss is logged with the agent's final answer and a
summary of its tool calls, so a page that is still missing can be diagnosed.
"""

from __future__ import annotations

import logging
import os
from collections import Counter
from typing import Any

from pydantic_ai.messages import (
ModelRequest,
ModelResponse,
RetryPromptPart,
ToolCallPart,
ToolReturnPart,
)

from codewiki.src.be.doc_layout import OVERVIEW_FILENAME, module_doc_file
from codewiki.src.be.module_naming import resolve_module_doc_path
from codewiki.src.be.overview_support import reusable_overview

logger = logging.getLogger(__name__)

# Follow-up turns sent to an agent that finished without writing its page.
DOC_WRITE_REMINDERS = 2

_EXCERPT_CHARS = 300


class DocumentationWriteError(RuntimeError):
"""An agent exhausted its attempts without creating its required page."""


def doc_written(
docs_dir: str, module_name: str, module_path: list[str], module_tree: dict[str, Any] | None
) -> bool:
"""Whether the page the agent for ``module_name`` must write exists."""
if not module_path:
# The whole-repository agent writes {repo_name}.md, renamed to overview.md later.
return reusable_overview(os.path.join(docs_dir, f"{module_name}.md")) or reusable_overview(
os.path.join(docs_dir, OVERVIEW_FILENAME)
)
path = resolve_module_doc_path(docs_dir, module_name, module_tree)
return path is not None and os.path.isfile(path)


def reminder_prompt(module_name: str, doc_file: str) -> str:
return (
f"You finished without creating `{doc_file}`, so the documentation for "
f"`{module_name}` is missing. Create it now: call `str_replace_editor` with "
f'working_dir="docs", command="create", path="./{doc_file}" and the complete '
f"Markdown page as file_text. If an earlier tool call returned an error, fix the "
f"arguments and try again. If the file already exists but is incomplete, replace "
f"its contents with str_replace_editor instead of create. "
f"Do not reply with the documentation as text."
)


def _excerpt(text: Any) -> str:
text = " ".join(str(text or "").split())
if len(text) > _EXCERPT_CHARS:
return text[:_EXCERPT_CHARS] + "..."
return text or "<empty>"


def _editor_command(part: ToolCallPart) -> str:
try:
args = part.args_as_dict()
except Exception: # noqa: BLE001 — malformed args are reported as such
return "<unparseable args>"
return f"{args.get('command')} {args.get('path') or args.get('file')}"


def summarize_tool_activity(messages: list[Any]) -> str:
"""One-line summary of an agent run: tool calls, editor writes and their results,
rejected tool calls and why the last response ended."""
calls: Counter[str] = Counter()
writes: dict[str, str] = {}
results: dict[str, str] = {}
rejected: list[str] = []
finish_reason = None
for message in messages:
if isinstance(message, ModelResponse):
finish_reason = getattr(message, "finish_reason", None)
for part in message.parts:
if isinstance(part, ToolCallPart):
calls[part.tool_name] += 1
if part.tool_name == "str_replace_editor":
command = _editor_command(part)
if not command.startswith("view "):
writes[part.tool_call_id] = command
elif isinstance(message, ModelRequest):
for part in message.parts:
if isinstance(part, ToolReturnPart) and part.tool_call_id in writes:
lines = str(part.content).strip().splitlines()
results[part.tool_call_id] = _excerpt(lines[0] if lines else "")
elif isinstance(part, RetryPromptPart) and part.tool_name:
rejected.append(f"{part.tool_name}: {_excerpt(part.model_response())}")

summary = [
"tool calls: "
+ (", ".join(f"{name} x{count}" for name, count in sorted(calls.items())) or "none")
]
if writes:
summary.append(
"editor writes: "
+ "; ".join(
f"{command} -> {results.get(call_id, 'no result')}"
for call_id, command in writes.items()
)
)
else:
summary.append("editor writes: none")
if rejected:
summary.append(f"rejected tool calls ({len(rejected)}): " + "; ".join(rejected[-3:]))
summary.append(f"last finish_reason: {finish_reason}")
return " | ".join(summary)


def run_usage(result: Any) -> Any:
"""``usage`` is a property in pydantic-ai 2.x and a method in earlier releases."""
usage = getattr(result, "usage", None)
return usage() if callable(usage) else usage


async def run_until_doc_written(
agent: Any,
user_prompt: str,
*,
deps: Any,
module_name: str,
module_path: list[str],
layout: str | None,
usage_limits: Any = None,
) -> tuple[Any, Any]:
"""Run ``agent`` and remind it (up to :data:`DOC_WRITE_REMINDERS` times) to
write its page if it ended without one.

Returns the last run's result and the token usage summed over all runs.
Each reminder is a separate run with its own ``usage_limits``, so a first
run that used most of its request budget still gets a chance to write.
Raises :class:`DocumentationWriteError` if all attempts miss the page.
"""
doc_file = module_doc_file(module_name, module_path, layout)
result = await agent.run(user_prompt, deps=deps, usage_limits=usage_limits)
usage = run_usage(result)
for attempt in range(1, DOC_WRITE_REMINDERS + 1):
if doc_written(deps.absolute_docs_path, module_name, module_path, deps.module_tree):
return result, usage
logger.warning(
"Agent for %s finished without writing %s (reminder %d/%d). Final answer: %s | %s",
module_name,
doc_file,
attempt,
DOC_WRITE_REMINDERS,
_excerpt(result.output),
summarize_tool_activity(result.new_messages()),
)
result = await agent.run(
reminder_prompt(module_name, doc_file),
deps=deps,
usage_limits=usage_limits,
message_history=result.all_messages(),
)
more = run_usage(result)
usage = more if usage is None else usage if more is None else usage + more
if not doc_written(deps.absolute_docs_path, module_name, module_path, deps.module_tree):
logger.error(
"Agent for %s still has not written %s after %d reminders. Final answer: %s | %s",
module_name,
doc_file,
DOC_WRITE_REMINDERS,
_excerpt(result.output),
summarize_tool_activity(result.new_messages()),
)
raise DocumentationWriteError(
f"Agent for {module_name} has not written {doc_file} after "
f"{DOC_WRITE_REMINDERS} reminders"
)
return result, usage
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from pydantic_ai.usage import UsageLimits

from codewiki.src.be.agent_tools.deps import CodeWikiDeps
from codewiki.src.be.agent_tools.doc_guard import run_until_doc_written
from codewiki.src.be.doc_layout import config_layout, module_doc_file
from codewiki.src.be.module_naming import skipped_report, plan_sub_module_specs, sub_module_report
from codewiki.src.be.agent_tools.read_code_components import read_code_components_tool
Expand Down Expand Up @@ -146,7 +147,8 @@ async def generate_sub_module_documentation(

try:
with usage_target(sub_module_name):
await sub_agent.run(
await run_until_doc_written(
sub_agent,
format_user_prompt(
module_name=deps.current_module_name,
core_component_ids=core_component_ids,
Expand All @@ -156,6 +158,9 @@ async def generate_sub_module_documentation(
layout=layout,
),
deps=ctx.deps,
module_name=sub_module_name,
module_path=list(deps.path_to_current_module),
layout=layout,
usage_limits=UsageLimits(request_limit=ctx.deps.config.request_limit),
)
finally:
Expand Down
23 changes: 20 additions & 3 deletions codewiki/src/be/caw_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
from caw import ToolGroup

from codewiki.src.be.agent_tools.deps import CodeWikiDeps
from codewiki.src.be.agent_tools.doc_guard import DocumentationWriteError, doc_written
from codewiki.src.be.backend import AgentReply, LLMBackend
from codewiki.src.be.module_scope import (
complete_file_candidates,
Expand All @@ -44,7 +45,8 @@
format_user_prompt,
)
from codewiki.src.be.utils import is_complex_module, set_main_loop
from codewiki.src.be.doc_layout import config_layout, find_doc
from codewiki.src.be.doc_layout import config_layout, find_doc, module_doc_file
from codewiki.src.be.overview_support import reusable_overview, root_recovery_prompt
from codewiki.src.config import MODULE_TREE_FILENAME, OVERVIEW_FILENAME, Config
from codewiki.src.utils import file_manager

Expand Down Expand Up @@ -393,13 +395,13 @@ def _run_module_agent_sync(
# or a resume after a partial run silently skips every missing one.
if not module_path:
overview_docs_path = os.path.join(working_dir, OVERVIEW_FILENAME)
if os.path.exists(overview_docs_path):
if reusable_overview(overview_docs_path):
logger.info("✓ Overview docs already exists at %s", overview_docs_path)
return module_tree
docs_path = find_doc(working_dir, module_name, module_tree) if module_path else None
if docs_path is None:
docs_path = os.path.join(working_dir, f"{module_name}.md")
if os.path.exists(docs_path):
if os.path.isfile(docs_path) and (module_path or reusable_overview(docs_path)):
logger.info("✓ Module docs already exists at %s", docs_path)
return module_tree

Expand Down Expand Up @@ -463,6 +465,8 @@ def _run_module_agent_sync(
module_path=list(module_path),
layout=config_layout(config),
)
if not module_path:
user_prompt = root_recovery_prompt(user_prompt, working_dir, module_name)

# caw forks claude / codex via subprocess.Popen without a cwd, so the
# child CLI inherits Python's cwd — typically the repo root where the
Expand Down Expand Up @@ -508,6 +512,7 @@ def _run_module_agent_sync(
traj = agent.completion(user_prompt)
finally:
os.chdir(original_cwd)
file_manager.save_json(deps.module_tree, module_tree_path)
# Nested sub-module agents are separate CLI processes that record
# their own usage, so this covers only this module's own run.
usage = self._record_usage(
Expand All @@ -524,6 +529,18 @@ def _run_module_agent_sync(
traj.total_tool_calls,
(usage or {}).get("cost_usd", 0.0),
)
if not doc_written(working_dir, module_name, list(module_path), deps.module_tree):
doc_file = module_doc_file(module_name, list(module_path), config_layout(config))
logger.error(
"Agent for %s finished without writing %s (turns=%d, tool_calls=%d). "
"Final answer: %s",
module_name,
doc_file,
traj.num_turns,
traj.total_tool_calls,
" ".join((getattr(traj, "result", None) or "").split())[:300] or "<empty>",
)
raise DocumentationWriteError(f"Agent for {module_name} has not written {doc_file}")
validate_module_file_coverage(deps.module_tree, components)
file_manager.save_json(deps.module_tree, module_tree_path)
return deps.module_tree
Expand Down
Loading
Loading