Skip to content

Commit 41dc6dc

Browse files
fix: harden scaffold generator for repeated tool scaffolds
1 parent 2003fcc commit 41dc6dc

4 files changed

Lines changed: 135 additions & 48 deletions

File tree

models/tool_type_registry.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@
3232
}
3333
)
3434

35-
_DISPATCH_ID_RE = re.compile(r"[a-z][a-z0-9_]*")
35+
_DISPATCH_ID_RE = re.compile(r"[a-z]([a-z0-9]+(_[a-z0-9]+)*)?")
3636

3737

3838
@dataclass(frozen=True, slots=True)
@@ -48,6 +48,9 @@ def from_mapping(cls, raw: dict[str, Any]) -> TypedDictField:
4848
if not isinstance(name, str) or not name:
4949
msg = "typed_dict_fields entries require a non-empty string 'name'"
5050
raise ValueError(msg)
51+
if not name.isidentifier():
52+
msg = f"typed_dict_fields 'name' must be a valid Python identifier, got {name!r}"
53+
raise ValueError(msg)
5154
py_type = raw.get("type", "object")
5255
if not isinstance(py_type, str):
5356
msg = "typed_dict_fields 'type' must be a string"
@@ -144,7 +147,7 @@ def from_mapping(cls, raw: dict[str, Any] | None) -> ToolResultRecord | None:
144147
msg = "result.predicate_mode must be 'all' or 'any'"
145148
raise ValueError(msg)
146149
priority = raw.get("priority", 0)
147-
if not isinstance(priority, int):
150+
if isinstance(priority, bool) or not isinstance(priority, int):
148151
msg = "result.priority must be an int"
149152
raise ValueError(msg)
150153
if priority < 0:
@@ -301,7 +304,7 @@ def builder_name(self) -> str:
301304

302305

303306
def snake_to_pascal(snake: str) -> str:
304-
if not re.fullmatch(r"[a-z][a-z0-9_]*", snake):
307+
if not re.fullmatch(r"[a-z]([a-z0-9]+(_[a-z0-9]+)*)?", snake):
305308
msg = f"invalid snake_case name: {snake!r}"
306309
raise ValueError(msg)
307310
return "".join(part.capitalize() for part in snake.split("_"))
@@ -357,6 +360,4 @@ def _validate_dispatch_id(dispatch_id: str) -> None:
357360

358361
def _dispatch_id_to_camel(dispatch_id: str) -> str:
359362
parts = dispatch_id.split("_")
360-
if not parts:
361-
return "value"
362363
return parts[0] + "".join(part.capitalize() for part in parts[1:])

scripts/gen_tool_types_manifest.py

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,22 +9,43 @@
99
from __future__ import annotations
1010

1111
import json
12-
import sys
12+
import re
1313
from pathlib import Path
1414

1515
_REPO_ROOT = Path(__file__).resolve().parents[1]
1616
_MANIFEST_PATH = _REPO_ROOT / "static" / "tool_types.json"
1717

18-
if str(_REPO_ROOT) not in sys.path:
19-
sys.path.insert(0, str(_REPO_ROOT))
20-
21-
from scripts.scaffold_tool_type import _parse_handlers_from_text # noqa: E402
22-
2318

2419
def _load_known_tool_types(repo_root: Path) -> frozenset[str]:
2520
path = repo_root / "utils" / "tool_dispatch.py"
2621
text = path.read_text(encoding="utf-8")
27-
return _parse_handlers_from_text(text)
22+
marker = "_FILE_ACTIVITY_HANDLERS: dict"
23+
start = text.find(marker)
24+
if start == -1:
25+
msg = f"could not find {marker} in {path}"
26+
raise ValueError(msg)
27+
brace_start = text.find("{", start)
28+
if brace_start == -1:
29+
msg = f"could not find opening brace for {marker} in {path}"
30+
raise ValueError(msg)
31+
depth = 0
32+
i = brace_start
33+
while i < len(text):
34+
ch = text[i]
35+
if ch == "{":
36+
depth += 1
37+
elif ch == "}":
38+
depth -= 1
39+
if depth == 0:
40+
body = text[brace_start + 1 : i]
41+
keys = re.findall(r'"([^"]+)":', body)
42+
if not keys:
43+
msg = f"no tool names found in {marker} in {path}"
44+
raise ValueError(msg)
45+
return frozenset(keys)
46+
i += 1
47+
msg = f"unbalanced braces in {marker} in {path}"
48+
raise ValueError(msg)
2849

2950

3051
def write_tool_types_manifest(path: Path | None = None, *, repo_root: Path | None = None) -> int:

scripts/scaffold_tool_type.py

Lines changed: 40 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ def __init__(self, root: Path, *, dry_run: bool = False, stdout: TextIO | None =
107107

108108
def emit(self, record: ToolTypeRecord) -> list[Path]:
109109
self._guard_not_present(record)
110+
self._written = []
110111
plan = _EmitPlan()
111112
self._plan_tool_results(record, plan)
112113
self._plan_tool_dispatch(record, plan)
@@ -124,7 +125,8 @@ def emit(self, record: ToolTypeRecord) -> list[Path]:
124125

125126
def _guard_not_present(self, record: ToolTypeRecord) -> None:
126127
dispatch_text = self.paths.tool_dispatch.read_text(encoding="utf-8")
127-
if f'"{record.name}"' in dispatch_text and f'"{record.name}":' in dispatch_text:
128+
known = _parse_handlers_from_text(dispatch_text)
129+
if record.name in known:
128130
msg = f"tool type {record.name!r} already present in _FILE_ACTIVITY_HANDLERS"
129131
raise ValueError(msg)
130132

@@ -148,7 +150,7 @@ def transform(text: str) -> str:
148150
text = self._insert_before(
149151
text,
150152
"# Registration order is tie-break only when priorities are equal.",
151-
self._render_dispatch_builder(record) + "\n",
153+
self._render_dispatch_builder(record) + "\n\n\n",
152154
)
153155
text = self._insert_before(
154156
text,
@@ -158,10 +160,10 @@ def transform(text: str) -> str:
158160
)
159161
guard = record.guard_name
160162
if f" {guard}," not in text:
161-
text = self._replace_once(
163+
text = self._append_to_block(
162164
text,
163-
" is_web_search_tool_result,\n)",
164-
f" is_web_search_tool_result,\n {guard},\n)",
165+
"from models.tool_results import (",
166+
f" {guard},",
165167
)
166168
entry = f' "{record.name}": {handler},\n'
167169
text = self._insert_before(text, "}\nKNOWN_TOOL_TYPES", entry, marker_in_prev_line=True)
@@ -173,7 +175,6 @@ def _render_dispatch_builder(self, record: ToolTypeRecord) -> str:
173175
assert record.result is not None
174176
sig = "tr: ToolResultDict, base: dict[str, object]"
175177
lines = [
176-
"",
177178
f"def {record.builder_name}({sig}) -> dict[str, object]:",
178179
" # TODO: map toolUseResult fields to parsed result keys.",
179180
" result = dict(base)",
@@ -208,25 +209,25 @@ def transform(text: str) -> str:
208209
text = self._insert_before(
209210
text,
210211
"# Dict passed into dispatch predicates",
211-
self._render_typed_dict(record) + "\n\n",
212+
self._render_typed_dict(record) + "\n\n\n",
212213
)
213214
member = f" | {record.typed_dict_class}\n"
214215
text = self._insert_before(
215216
text,
216217
" | ToolResultWithContentDict",
217218
member,
218219
)
219-
anchor = ' return "questions" in tr and "answers" in tr\n\n\n'
220-
text = self._replace_once(
220+
text = self._insert_before(
221221
text,
222-
anchor + "# Tool names on assistant",
223-
anchor + f"{self._render_guard(record)}\n\n\n# Tool names on assistant",
222+
"# Tool names on assistant",
223+
self._render_guard(record) + "\n\n\n",
224+
)
225+
if f' "{record.name}",' not in text:
226+
text = self._append_to_block(
227+
text,
228+
"ToolNameLiteral = Literal[",
229+
f' "{record.name}",',
224230
)
225-
text = self._replace_once(
226-
text,
227-
' "WebSearch",\n]',
228-
f' "WebSearch",\n "{record.name}",\n]',
229-
)
230231
return text
231232

232233
plan.stage_patch(self.paths.tool_results, transform)
@@ -375,25 +376,18 @@ def transform(text: str) -> str:
375376
for inv in record.result.overlap_invariants:
376377
before_guard = inv.resolved_before_guard()
377378
after_guard = inv.resolved_after_guard()
378-
if before_guard not in text:
379-
text = self._replace_once(
379+
if f" {before_guard}," not in text:
380+
text = self._append_to_block(
381+
text,
382+
"from models.tool_results import (",
383+
f" {before_guard},",
384+
)
385+
if f" {after_guard}," not in text:
386+
text = self._append_to_block(
380387
text,
381-
" is_task_async_tool_result,\n)",
382-
f" is_task_async_tool_result,\n {before_guard},\n)",
388+
"from models.tool_results import (",
389+
f" {after_guard},",
383390
)
384-
if after_guard not in text:
385-
if before_guard in text:
386-
text = self._replace_once(
387-
text,
388-
f" {before_guard},\n)",
389-
f" {before_guard},\n {after_guard},\n)",
390-
)
391-
else:
392-
text = self._replace_once(
393-
text,
394-
" is_task_async_tool_result,\n)",
395-
f" is_task_async_tool_result,\n {after_guard},\n)",
396-
)
397391
row = (
398392
f" (\n"
399393
f" {before_guard},\n"
@@ -477,6 +471,19 @@ def _replace_once(text: str, anchor: str, replacement: str) -> str:
477471
raise ValueError(msg)
478472
return text.replace(anchor, replacement, 1)
479473

474+
@staticmethod
475+
def _append_to_block(text: str, block_start: str, entry: str) -> str:
476+
"""Append *entry* before the closing ``)`` or ``]`` of *block_start*."""
477+
idx = text.find(block_start)
478+
if idx == -1:
479+
raise ValueError(f"scaffold block not found: {block_start!r}")
480+
search_from = idx + len(block_start)
481+
match = re.search(r"\n[)\]]", text[search_from:])
482+
if not match:
483+
raise ValueError(f"closing delimiter not found for block: {block_start!r}")
484+
close_pos = search_from + match.start()
485+
return text[:close_pos] + "\n" + entry + text[close_pos:]
486+
480487

481488
def _parse_handlers_from_text(text: str) -> frozenset[str]:
482489
marker = "_FILE_ACTIVITY_HANDLERS: dict"

tests/test_scaffold_tool_type.py

Lines changed: 61 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,16 @@ def test_from_mapping_rejects_invalid_dispatch_id() -> None:
7575
)
7676

7777

78+
def test_from_mapping_rejects_trailing_underscore_dispatch_id() -> None:
79+
with pytest.raises(ValueError, match="snake_case"):
80+
ToolTypeRecord.from_mapping(
81+
{
82+
"name": "BadTool",
83+
"result": {"dispatch_id": "bad_"},
84+
}
85+
)
86+
87+
7888
def test_overlap_invariant_resolves_explicit_guards() -> None:
7989
record = ToolTypeRecord.from_mapping(
8090
{
@@ -127,6 +137,47 @@ def test_from_mapping_rejects_negative_priority() -> None:
127137
)
128138

129139

140+
def test_from_mapping_rejects_bool_priority() -> None:
141+
with pytest.raises(ValueError, match="must be an int"):
142+
ToolTypeRecord.from_mapping(
143+
{
144+
"name": "BadTool",
145+
"result": {"dispatch_id": "bad_tool", "priority": True},
146+
}
147+
)
148+
149+
150+
def test_from_mapping_rejects_non_identifier_field_name() -> None:
151+
with pytest.raises(ValueError, match="valid Python identifier"):
152+
ToolTypeRecord.from_mapping(
153+
{
154+
"name": "BadTool",
155+
"result": {
156+
"dispatch_id": "bad_tool",
157+
"typed_dict_fields": [{"name": "not valid", "type": "str"}],
158+
},
159+
}
160+
)
161+
162+
163+
def test_append_to_block_inserts_before_closing_paren() -> None:
164+
text = "from foo import (\n bar,\n)\n"
165+
result = ScaffoldEmitter._append_to_block(text, "from foo import (", " baz,")
166+
assert " baz,\n)" in result
167+
assert result.index("bar,") < result.index("baz,")
168+
169+
170+
def test_append_to_block_raises_on_missing_block() -> None:
171+
with pytest.raises(ValueError, match="block not found"):
172+
ScaffoldEmitter._append_to_block("some text", "nonexistent(", " x,")
173+
174+
175+
def test_append_to_block_raises_on_missing_close() -> None:
176+
text = "from foo import (bar)"
177+
with pytest.raises(ValueError, match="closing delimiter not found"):
178+
ScaffoldEmitter._append_to_block(text, "from foo import (", " baz,")
179+
180+
130181
def test_dry_run_reports_planned_artifact_count(capsys: pytest.CaptureFixture[str]) -> None:
131182
code = main(["--name", "example_tool", "--dry-run"])
132183
assert code == 0
@@ -161,14 +212,14 @@ def test_emit_anchor_miss_in_tool_results_leaves_repo_unchanged(tmp_path: Path)
161212
tool_results = repo / "models" / "tool_results.py"
162213
original = tool_results.read_text(encoding="utf-8")
163214
corrupt = original.replace(
164-
' "WebSearch",\n]',
165-
' "WebSearch",\n "Legacy",\n]',
215+
"# Dict passed into dispatch predicates",
216+
"# Dict handed to dispatch predicates",
166217
)
167218
assert corrupt != original, "fixture corruption must change tool_results.py"
168219
tool_results.write_text(corrupt, encoding="utf-8")
169220
snapshot = _snapshot_emitter_targets(repo)
170221
record = ToolTypeRecord.from_cli_name("example_tool")
171-
with pytest.raises(ValueError, match="replacement anchor not found"):
222+
with pytest.raises(ValueError, match="marker not found"):
172223
ScaffoldEmitter(repo).emit(record)
173224
_assert_emitter_targets_unchanged(repo, snapshot)
174225

@@ -246,6 +297,13 @@ def test_scaffold_emits_declared_priority(tmp_path: Path) -> None:
246297
assert "priority=2," in dispatch
247298

248299

300+
def test_scaffold_two_result_tools_passes_dispatch_sync(tmp_path: Path) -> None:
301+
repo = _mirror_repo(tmp_path)
302+
ScaffoldEmitter(repo).emit(ToolTypeRecord.from_cli_name("alpha_tool"))
303+
ScaffoldEmitter(repo).emit(ToolTypeRecord.from_cli_name("beta_tool"))
304+
_assert_dispatch_sync(repo)
305+
306+
249307
def test_scaffold_in_temp_passes_dispatch_sync(tmp_path: Path) -> None:
250308
repo = _mirror_repo(tmp_path)
251309
record = ToolTypeRecord.from_cli_name("example_tool")

0 commit comments

Comments
 (0)