Skip to content

Commit a966b4c

Browse files
authored
Merge pull request #114 from FSoft-AI4Code/fix/whole-repo-update-loop
updater: stop whole-repository --update from looping (#113)
2 parents 532ba90 + 4572815 commit a966b4c

7 files changed

Lines changed: 504 additions & 18 deletions

File tree

‎codewiki/src/be/agent_tools/generate_sub_module_documentations.py‎

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from pydantic_ai import RunContext, Tool, Agent
44

55
from codewiki.src.be.agent_tools.deps import CodeWikiDeps
6-
from codewiki.src.be.module_naming import normalize_sub_module_specs
6+
from codewiki.src.be.module_naming import plan_sub_module_specs
77
from codewiki.src.be.agent_tools.read_code_components import read_code_components_tool
88
from codewiki.src.be.agent_tools.str_replace_editor import str_replace_editor_tool
99
from codewiki.src.be.llm_services import create_fallback_models
@@ -25,9 +25,10 @@ async def generate_sub_module_documentation(
2525
sub_module_specs: A dictionary mapping sub-module names to their core component IDs.
2626
Example: {"authentication": ["auth_handler.py::AuthHandler", "auth_middleware.py::verify_token"], "database": ["db_client.py::DBClient", "models.py::UserModel"]}
2727
Each key is a descriptive sub-module name, and the value is a list of component IDs from the current module's core components that belong to that sub-module.
28-
Sub-module names must be unique across the whole wiki; a name that is already used by another
29-
module is automatically prefixed with the current module name, and the tool result reports the
30-
final file names actually saved.
28+
Sub-module names must be unique across the whole wiki. A name already used by another module is
29+
prefixed with the current module name; a name that is already documented (plain or prefixed)
30+
is SKIPPED, not regenerated. The tool result reports the final file names actually saved and
31+
every skipped request.
3132
"""
3233

3334
deps = ctx.deps
@@ -38,15 +39,21 @@ async def generate_sub_module_documentation(
3839

3940
# Resolve name collisions against the module tree and files already on disk
4041
# before touching the tree (issue #76): docs live in one flat directory.
41-
name_map = normalize_sub_module_specs(
42+
# A request that is already documented (plain or parent-prefixed name) is
43+
# skipped rather than renamed x_2, x_3, ... (issue #113).
44+
plan = plan_sub_module_specs(
4245
sub_module_specs,
4346
previous_module_name,
4447
deps.module_tree,
4548
deps.absolute_docs_path,
4649
)
50+
name_map = plan.name_map
51+
if not name_map:
52+
return _skipped_report(plan.skipped, deps.current_module_name)
4753
final_specs = {
4854
name_map[requested_name]: core_component_ids
4955
for requested_name, core_component_ids in sub_module_specs.items()
56+
if requested_name in name_map
5057
}
5158

5259
# add the sub-module to the module tree
@@ -135,9 +142,22 @@ async def generate_sub_module_documentation(
135142
if missing:
136143
report += f" MISSING (generation did not produce these files): {', '.join(missing)}."
137144
logger.warning("Sub-module documentation missing after generation: %s", ", ".join(missing))
145+
if plan.skipped:
146+
report += " " + _skipped_report(plan.skipped, deps.current_module_name)
138147
return report
139148

140149

150+
def _skipped_report(skipped: dict[str, str], current_module_name: str) -> str:
151+
"""Tell the parent agent, unambiguously, not to retry skipped sub-modules."""
152+
if not skipped:
153+
return "No sub-modules were generated."
154+
items = ", ".join(f"'{name}' ({reason})" for name, reason in skipped.items())
155+
return (
156+
f"Skipped sub-modules: {items}. Do NOT call generate_sub_module_documentation again "
157+
f"for these; link the existing pages from `{current_module_name}.md` instead."
158+
)
159+
160+
141161
generate_sub_module_documentation_tool = Tool(
142162
function=generate_sub_module_documentation,
143163
name="generate_sub_module_documentation",

‎codewiki/src/be/caw_toolkit.py‎

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
from mcp.server.fastmcp import Context
2929

3030
from codewiki.src.be.agent_tools.deps import CodeWikiDeps
31-
from codewiki.src.be.module_naming import normalize_sub_module_specs
31+
from codewiki.src.be.module_naming import plan_sub_module_specs
3232

3333
if TYPE_CHECKING:
3434
from codewiki.src.be.caw_backend import CawBackend
@@ -242,9 +242,10 @@ async def str_replace_editor(
242242
"sub_module_specs: a dictionary mapping sub-module names to their core component IDs. "
243243
"Example: {'authentication': ['auth_handler.py::AuthHandler'], "
244244
"'database': ['db_client.py::DBClient']}\n"
245-
"Sub-module names must be unique across the whole wiki; a name already used by another "
246-
"module is automatically prefixed with the current module name, and the tool result "
247-
"reports the final file names actually saved."
245+
"Sub-module names must be unique across the whole wiki. A name already used by another "
246+
"module is prefixed with the current module name; a name that is already documented "
247+
"(plain or prefixed) is SKIPPED, not regenerated. The result reports the files actually "
248+
"saved and every skipped request."
248249
)
249250
)
250251
async def generate_sub_module_documentation(
@@ -284,15 +285,21 @@ def _run_sub_modules(self, sub_module_specs: dict[str, list[str]]) -> str:
284285

285286
# Resolve name collisions against the module tree and files already on
286287
# disk before touching the tree (issue #76): docs live in one flat directory.
287-
name_map = normalize_sub_module_specs(
288+
# A request that is already documented (plain or parent-prefixed name) is
289+
# skipped rather than renamed x_2, x_3, ... (issue #113).
290+
plan = plan_sub_module_specs(
288291
sub_module_specs,
289292
previous_module_name,
290293
deps.module_tree,
291294
deps.absolute_docs_path,
292295
)
296+
name_map = plan.name_map
297+
if not name_map:
298+
return _skipped_report(plan.skipped, deps.current_module_name)
293299
final_specs = {
294300
name_map[requested_name]: core_ids
295301
for requested_name, core_ids in sub_module_specs.items()
302+
if requested_name in name_map
296303
}
297304

298305
# Add sub-modules to the in-memory module tree.
@@ -352,4 +359,17 @@ def _run_sub_modules(self, sub_module_specs: dict[str, list[str]]) -> str:
352359
logger.warning(
353360
"Sub-module documentation missing after generation: %s", ", ".join(missing)
354361
)
362+
if plan.skipped:
363+
report += " " + _skipped_report(plan.skipped, deps.current_module_name)
355364
return report
365+
366+
367+
def _skipped_report(skipped: dict[str, str], current_module_name: str) -> str:
368+
"""Tell the parent agent, unambiguously, not to retry skipped sub-modules."""
369+
if not skipped:
370+
return "No sub-modules were generated."
371+
items = ", ".join(f"'{name}' ({reason})" for name, reason in skipped.items())
372+
return (
373+
f"Skipped sub-modules: {items}. Do NOT call generate_sub_module_documentation again "
374+
f"for these; link the existing pages from `{current_module_name}.md` instead."
375+
)

‎codewiki/src/be/module_naming.py‎

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
"""
99

1010
import os
11+
from dataclasses import dataclass, field
1112
from typing import Any, Dict, List, Optional, Set
1213

1314
import logging
@@ -97,6 +98,66 @@ def normalize_sub_module_specs(
9798
return name_map
9899

99100

101+
@dataclass
102+
class SubModulePlan:
103+
"""What ``generate_sub_module_documentation`` will actually create.
104+
105+
``name_map``: requested name -> final, unique file stem to generate.
106+
``skipped``: requested name -> reason it is not generated (already
107+
documented under that name or its parent-prefixed variant).
108+
"""
109+
110+
name_map: Dict[str, str] = field(default_factory=dict)
111+
skipped: Dict[str, str] = field(default_factory=dict)
112+
113+
114+
def plan_sub_module_specs(
115+
sub_module_specs: Dict[str, Any],
116+
parent_name: Optional[str],
117+
module_tree: Dict[str, Any],
118+
working_dir: str,
119+
) -> SubModulePlan:
120+
"""Decide which requested sub-modules to generate (issue #113).
121+
122+
Like :func:`normalize_sub_module_specs`, a name that collides with the
123+
tree, a ``.md`` on disk or a reserved stem gets the parent prefix. Unlike
124+
it, a request whose plain *and* prefixed names are both taken is treated
125+
as a repeat of something already documented and skipped, never renamed
126+
with a numeric suffix: that suffixing is what let one agent regenerate the
127+
same modules as ``x_2``, ``x_3``, ... without ever converging.
128+
"""
129+
taken = collect_module_tree_names(module_tree)
130+
taken |= _existing_doc_stems(working_dir)
131+
taken |= RESERVED_STEMS
132+
133+
plan = SubModulePlan()
134+
for requested_name in sub_module_specs:
135+
name = sanitize_module_name(requested_name)
136+
if name not in taken:
137+
final_name = name
138+
else:
139+
prefixed = f"{sanitize_module_name(parent_name)}_{name}" if parent_name else name
140+
if prefixed in taken:
141+
existing = name if name in taken else prefixed
142+
reason = f"already documented as {existing}.md; do not request it again"
143+
plan.skipped[requested_name] = reason
144+
logger.info(
145+
"Sub-module '%s' already documented as '%s'; skipping duplicate request.",
146+
requested_name,
147+
existing,
148+
)
149+
continue
150+
final_name = prefixed
151+
logger.info(
152+
"Sub-module name '%s' collides with an existing module or file; renamed to '%s'.",
153+
requested_name,
154+
final_name,
155+
)
156+
taken.add(final_name)
157+
plan.name_map[requested_name] = final_name
158+
return plan
159+
160+
100161
def dedupe_module_tree_names(module_tree: Dict[str, Any]) -> Dict[str, Any]:
101162
"""Sanitize and uniquify all module names in a freshly clustered tree.
102163

‎codewiki/src/be/updater/orchestrator.py‎

Lines changed: 68 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from typing import Any
1010

1111
from codewiki.src.be.backend import LLMBackend
12-
from codewiki.src.be.cluster_modules import cluster_modules
12+
from codewiki.src.be.cluster_modules import cluster_modules, get_clustering_input_token_count
1313
from codewiki.src.be.dependency_analyzer.models.core import Node
1414
from codewiki.src.be.updater import pages as P
1515
from codewiki.src.be.updater import tree as T
@@ -41,7 +41,12 @@
4141
)
4242
from codewiki.src.be.updater.routing import RoutingAgent
4343
from codewiki.src.be.updater.stale_scan import StaleScanner
44-
from codewiki.src.be.updater.tree_repair import RepairResult, repair_tree
44+
from codewiki.src.be.updater.tree_repair import (
45+
RULE_AGENT,
46+
RepairResult,
47+
RoutingDecision,
48+
repair_tree,
49+
)
4550
from codewiki.src.config import MODULE_TREE_FILENAME, Config
4651
from codewiki.src.utils import file_manager
4752

@@ -85,6 +90,17 @@ def _load_state(self, old_graph_path: str) -> tuple[dict[str, Node], dict[str, A
8590
self.record.detector_notes.append("whole-repository mode: one virtual leaf (overview)")
8691
return old_graph, tree
8792

93+
def _route_to_overview(
94+
self, orphans: list[str], context: dict[str, Any]
95+
) -> list[RoutingDecision]:
96+
"""Whole-repository mode: every orphan belongs to the single page."""
97+
return [
98+
RoutingDecision(
99+
cid, RULE_AGENT, (P.OVERVIEW_STEM,), detail="whole-repository mode: single page"
100+
)
101+
for cid in orphans
102+
]
103+
88104
def _module_path(self, path: tuple[str, ...]) -> list[str]:
89105
if self.whole_repo and path == (P.OVERVIEW_STEM,):
90106
return []
@@ -253,6 +269,12 @@ async def _run(
253269
if self.opts.use_routing_agent
254270
else None
255271
)
272+
if self.whole_repo:
273+
# One page documents everything: orphans go to the virtual leaf
274+
# without an LLM call and without creating leaves. Each created
275+
# leaf would otherwise map back onto the overview page and rewrite
276+
# it once more in Step 5 (issue #113).
277+
router = self._route_to_overview
256278
repair: RepairResult = repair_tree(
257279
old_tree, diff, new_graph, tracked_new, self.opts, route_orphans=router
258280
)
@@ -288,11 +310,43 @@ async def _run(
288310
ratios["fired"] = (
289311
ratios["r_leaf"] >= self.opts.tau_full or ratios["r_tree"] >= self.opts.tau_tree
290312
)
291-
if self.whole_repo and ratios["fired"]:
292-
# One virtual leaf: any change is 100% active by construction, and
293-
# patching that single page is exactly the incremental step.
294-
ratios["fired"] = False
295-
ratios["note"] = "whole-repository mode: fallback rule not applied"
313+
if self.whole_repo:
314+
# One virtual leaf: any change is 100% active by construction, so
315+
# r_leaf says nothing. What matters is whether the *current* scope
316+
# still fits one page under the same threshold a fresh build uses
317+
# to skip clustering. A whole-repo baseline built from a narrow
318+
# --include and then updated against the full repo does not, and
319+
# feeding that to the single-page agent runs unbounded (issue #113).
320+
scope = [c for c in leaf_nodes if c in new_graph]
321+
tokens = get_clustering_input_token_count(scope, new_graph)
322+
threshold = self.config.max_token_per_module
323+
ratios["clustering_tokens"] = tokens
324+
ratios["tau_cluster"] = threshold
325+
if tokens <= threshold:
326+
ratios["fired"] = False
327+
note = "whole-repository mode: scope still fits one page; fallback rule not applied"
328+
ratios["note"] = note
329+
logger.info(
330+
"Whole-repository mode: current scope is %d clustering tokens "
331+
"(threshold %d, %d leaf nodes); updating the single page in place",
332+
tokens,
333+
threshold,
334+
len(scope),
335+
)
336+
else:
337+
ratios["fired"] = True
338+
ratios["note"] = (
339+
"whole-repository baseline but current scope exceeds the clustering "
340+
"threshold; a fresh build would cluster, so fall back to a full build"
341+
)
342+
rec.detector_notes.append(ratios["note"])
343+
logger.warning(
344+
"Whole-repository baseline cannot be updated in place: current scope is "
345+
"%d clustering tokens (threshold %d, %d leaf nodes); falling back to a full build",
346+
tokens,
347+
threshold,
348+
len(scope),
349+
)
296350
rec.fallback = ratios
297351
if ratios["fired"]:
298352
rec.outcome = OUTCOME_FULL_FALLBACK
@@ -311,6 +365,13 @@ async def _run(
311365

312366
# ---- Step 5: sequential leaf agents
313367
order = order_active(active, new_tree, new_graph)
368+
if self.whole_repo and len(order) > 1:
369+
# Every active unit is the same overview page; regenerate it once.
370+
overview_path = (P.OVERVIEW_STEM,)
371+
order = [overview_path if overview_path in reports else order[0]]
372+
rec.detector_notes.append(
373+
f"whole-repository mode: {len(active)} active units collapsed into one overview run"
374+
)
314375
dep = T.leaf_dependents(new_tree, new_graph)
315376
inv = inverse(ref_index)
316377
rec.active = [

0 commit comments

Comments
 (0)