ripperdoc 0.2.4__py3-none-any.whl → 0.2.5__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. ripperdoc/__init__.py +1 -1
  2. ripperdoc/__main__.py +0 -5
  3. ripperdoc/cli/cli.py +37 -16
  4. ripperdoc/cli/commands/__init__.py +2 -0
  5. ripperdoc/cli/commands/agents_cmd.py +12 -9
  6. ripperdoc/cli/commands/compact_cmd.py +7 -3
  7. ripperdoc/cli/commands/context_cmd.py +33 -13
  8. ripperdoc/cli/commands/doctor_cmd.py +27 -14
  9. ripperdoc/cli/commands/exit_cmd.py +1 -1
  10. ripperdoc/cli/commands/mcp_cmd.py +13 -8
  11. ripperdoc/cli/commands/memory_cmd.py +5 -5
  12. ripperdoc/cli/commands/models_cmd.py +47 -16
  13. ripperdoc/cli/commands/permissions_cmd.py +302 -0
  14. ripperdoc/cli/commands/resume_cmd.py +1 -2
  15. ripperdoc/cli/commands/tasks_cmd.py +24 -13
  16. ripperdoc/cli/ui/rich_ui.py +500 -406
  17. ripperdoc/cli/ui/tool_renderers.py +298 -0
  18. ripperdoc/core/agents.py +17 -9
  19. ripperdoc/core/config.py +130 -6
  20. ripperdoc/core/default_tools.py +7 -2
  21. ripperdoc/core/permissions.py +20 -14
  22. ripperdoc/core/providers/anthropic.py +107 -4
  23. ripperdoc/core/providers/base.py +33 -4
  24. ripperdoc/core/providers/gemini.py +169 -50
  25. ripperdoc/core/providers/openai.py +257 -23
  26. ripperdoc/core/query.py +294 -61
  27. ripperdoc/core/query_utils.py +50 -6
  28. ripperdoc/core/skills.py +295 -0
  29. ripperdoc/core/system_prompt.py +13 -7
  30. ripperdoc/core/tool.py +8 -6
  31. ripperdoc/sdk/client.py +14 -1
  32. ripperdoc/tools/ask_user_question_tool.py +20 -22
  33. ripperdoc/tools/background_shell.py +19 -13
  34. ripperdoc/tools/bash_tool.py +356 -209
  35. ripperdoc/tools/dynamic_mcp_tool.py +428 -0
  36. ripperdoc/tools/enter_plan_mode_tool.py +5 -2
  37. ripperdoc/tools/exit_plan_mode_tool.py +6 -3
  38. ripperdoc/tools/file_edit_tool.py +53 -10
  39. ripperdoc/tools/file_read_tool.py +17 -7
  40. ripperdoc/tools/file_write_tool.py +49 -13
  41. ripperdoc/tools/glob_tool.py +10 -9
  42. ripperdoc/tools/grep_tool.py +182 -51
  43. ripperdoc/tools/ls_tool.py +6 -6
  44. ripperdoc/tools/mcp_tools.py +106 -456
  45. ripperdoc/tools/multi_edit_tool.py +49 -9
  46. ripperdoc/tools/notebook_edit_tool.py +57 -13
  47. ripperdoc/tools/skill_tool.py +205 -0
  48. ripperdoc/tools/task_tool.py +7 -8
  49. ripperdoc/tools/todo_tool.py +12 -12
  50. ripperdoc/tools/tool_search_tool.py +5 -6
  51. ripperdoc/utils/coerce.py +34 -0
  52. ripperdoc/utils/context_length_errors.py +252 -0
  53. ripperdoc/utils/file_watch.py +5 -4
  54. ripperdoc/utils/json_utils.py +4 -4
  55. ripperdoc/utils/log.py +3 -3
  56. ripperdoc/utils/mcp.py +36 -15
  57. ripperdoc/utils/memory.py +9 -6
  58. ripperdoc/utils/message_compaction.py +16 -11
  59. ripperdoc/utils/messages.py +73 -8
  60. ripperdoc/utils/path_ignore.py +677 -0
  61. ripperdoc/utils/permissions/__init__.py +7 -1
  62. ripperdoc/utils/permissions/path_validation_utils.py +5 -3
  63. ripperdoc/utils/permissions/shell_command_validation.py +496 -18
  64. ripperdoc/utils/prompt.py +1 -1
  65. ripperdoc/utils/safe_get_cwd.py +5 -2
  66. ripperdoc/utils/session_history.py +38 -19
  67. ripperdoc/utils/todo.py +6 -2
  68. ripperdoc/utils/token_estimation.py +4 -3
  69. {ripperdoc-0.2.4.dist-info → ripperdoc-0.2.5.dist-info}/METADATA +12 -1
  70. ripperdoc-0.2.5.dist-info/RECORD +107 -0
  71. ripperdoc-0.2.4.dist-info/RECORD +0 -99
  72. {ripperdoc-0.2.4.dist-info → ripperdoc-0.2.5.dist-info}/WHEEL +0 -0
  73. {ripperdoc-0.2.4.dist-info → ripperdoc-0.2.5.dist-info}/entry_points.txt +0 -0
  74. {ripperdoc-0.2.4.dist-info → ripperdoc-0.2.5.dist-info}/licenses/LICENSE +0 -0
  75. {ripperdoc-0.2.4.dist-info → ripperdoc-0.2.5.dist-info}/top_level.txt +0 -0
@@ -4,6 +4,7 @@ Allows performing multiple exact string replacements in a single file atomically
4
4
  """
5
5
 
6
6
  import difflib
7
+ import os
7
8
  from pathlib import Path
8
9
  from typing import AsyncGenerator, Optional, List
9
10
  from textwrap import dedent
@@ -168,6 +169,7 @@ class MultiEditTool(Tool[MultiEditToolInput, MultiEditToolOutput]):
168
169
  path = Path(input_data.file_path).expanduser()
169
170
  if not path.is_absolute():
170
171
  path = Path.cwd() / path
172
+ resolved_path = str(path.resolve())
171
173
 
172
174
  # Ensure edits differ.
173
175
  for edit in input_data.edits:
@@ -175,6 +177,7 @@ class MultiEditTool(Tool[MultiEditToolInput, MultiEditToolOutput]):
175
177
  return ValidationResult(
176
178
  result=False,
177
179
  message="old_string and new_string must be different",
180
+ error_code=1,
178
181
  )
179
182
 
180
183
  # If the file exists, ensure it is not a directory.
@@ -182,8 +185,41 @@ class MultiEditTool(Tool[MultiEditToolInput, MultiEditToolOutput]):
182
185
  return ValidationResult(
183
186
  result=False,
184
187
  message=f"Path is a directory, not a file: {path}",
188
+ error_code=2,
185
189
  )
186
190
 
191
+ # Check if this is a file creation (first edit has empty old_string)
192
+ is_creation = (
193
+ not path.exists()
194
+ and len(input_data.edits) > 0
195
+ and input_data.edits[0].old_string == ""
196
+ )
197
+
198
+ # If file exists, check if it has been read before editing
199
+ if path.exists() and not is_creation:
200
+ file_state_cache = getattr(context, "file_state_cache", {}) if context else {}
201
+ file_snapshot = file_state_cache.get(resolved_path)
202
+
203
+ if not file_snapshot:
204
+ return ValidationResult(
205
+ result=False,
206
+ message="File has not been read yet. Read it first before editing.",
207
+ error_code=3,
208
+ )
209
+
210
+ # Check if file has been modified since it was read
211
+ try:
212
+ current_mtime = os.path.getmtime(resolved_path)
213
+ if current_mtime > file_snapshot.timestamp:
214
+ return ValidationResult(
215
+ result=False,
216
+ message="File has been modified since read, either by the user or by a linter. "
217
+ "Read it again before attempting to edit it.",
218
+ error_code=4,
219
+ )
220
+ except OSError:
221
+ pass # File mtime check failed, proceed anyway
222
+
187
223
  return ValidationResult(result=True)
188
224
 
189
225
  def render_result_for_assistant(self, output: MultiEditToolOutput) -> str:
@@ -310,9 +346,11 @@ class MultiEditTool(Tool[MultiEditToolInput, MultiEditToolOutput]):
310
346
  try:
311
347
  if existing:
312
348
  original_content = file_path.read_text(encoding="utf-8")
313
- except Exception as exc: # pragma: no cover - unlikely permission issue
314
- logger.exception(
315
- "[multi_edit_tool] Error reading file before edits",
349
+ except (OSError, IOError, PermissionError) as exc:
350
+ # pragma: no cover - unlikely permission issue
351
+ logger.warning(
352
+ "[multi_edit_tool] Error reading file before edits: %s: %s",
353
+ type(exc).__name__, exc,
316
354
  extra={"file_path": str(file_path)},
317
355
  )
318
356
  output = MultiEditToolOutput(
@@ -367,14 +405,16 @@ class MultiEditTool(Tool[MultiEditToolInput, MultiEditToolOutput]):
367
405
  updated_content,
368
406
  getattr(context, "file_state_cache", {}),
369
407
  )
370
- except Exception:
371
- logger.exception(
372
- "[multi_edit_tool] Failed to record file snapshot",
408
+ except (OSError, IOError, RuntimeError) as exc:
409
+ logger.warning(
410
+ "[multi_edit_tool] Failed to record file snapshot: %s: %s",
411
+ type(exc).__name__, exc,
373
412
  extra={"file_path": str(file_path)},
374
413
  )
375
- except Exception as exc:
376
- logger.exception(
377
- "[multi_edit_tool] Error writing edited file",
414
+ except (OSError, IOError, PermissionError, UnicodeDecodeError) as exc:
415
+ logger.warning(
416
+ "[multi_edit_tool] Error writing edited file: %s: %s",
417
+ type(exc).__name__, exc,
378
418
  extra={"file_path": str(file_path)},
379
419
  )
380
420
  output = MultiEditToolOutput(
@@ -4,6 +4,7 @@ Allows performing insert/replace/delete operations on Jupyter notebook cells.
4
4
  """
5
5
 
6
6
  import json
7
+ import os
7
8
  import random
8
9
  import string
9
10
  from pathlib import Path
@@ -137,39 +138,79 @@ class NotebookEditTool(Tool[NotebookEditInput, NotebookEditOutput]):
137
138
  self, input_data: NotebookEditInput, context: Optional[ToolUseContext] = None
138
139
  ) -> ValidationResult:
139
140
  path = _resolve_path(input_data.notebook_path)
141
+ resolved_path = str(path.resolve())
140
142
 
141
143
  if not path.exists():
142
- return ValidationResult(result=False, message="Notebook file does not exist.")
144
+ return ValidationResult(
145
+ result=False,
146
+ message="Notebook file does not exist.",
147
+ error_code=1,
148
+ )
143
149
  if path.suffix != ".ipynb":
144
150
  return ValidationResult(
145
151
  result=False,
146
152
  message="File must be a Jupyter notebook (.ipynb file). Use Edit for other file types.",
153
+ error_code=2,
147
154
  )
148
155
 
149
156
  mode = (input_data.edit_mode or "replace").lower()
150
157
  if mode not in {"replace", "insert", "delete"}:
151
158
  return ValidationResult(
152
- result=False, message="edit_mode must be replace, insert, or delete."
159
+ result=False,
160
+ message="edit_mode must be replace, insert, or delete.",
161
+ error_code=3,
153
162
  )
154
163
  if mode == "insert" and not input_data.cell_type:
155
164
  return ValidationResult(
156
165
  result=False,
157
166
  message="cell_type is required when using edit_mode=insert.",
167
+ error_code=4,
158
168
  )
159
169
  if mode != "insert" and not input_data.cell_id:
160
170
  return ValidationResult(
161
171
  result=False,
162
172
  message="cell_id must be specified when using edit_mode=replace or delete.",
173
+ error_code=5,
174
+ )
175
+
176
+ # Check if file has been read before editing
177
+ file_state_cache = getattr(context, "file_state_cache", {}) if context else {}
178
+ file_snapshot = file_state_cache.get(resolved_path)
179
+
180
+ if not file_snapshot:
181
+ return ValidationResult(
182
+ result=False,
183
+ message="Notebook has not been read yet. Read it first before editing.",
184
+ error_code=6,
163
185
  )
164
186
 
187
+ # Check if file has been modified since it was read
188
+ try:
189
+ current_mtime = os.path.getmtime(resolved_path)
190
+ if current_mtime > file_snapshot.timestamp:
191
+ return ValidationResult(
192
+ result=False,
193
+ message="Notebook has been modified since read, either by the user or by a linter. "
194
+ "Read it again before attempting to edit it.",
195
+ error_code=7,
196
+ )
197
+ except OSError:
198
+ pass # File mtime check failed, proceed anyway
199
+
165
200
  # Validate notebook structure and target cell.
166
201
  try:
167
202
  raw = path.read_text(encoding="utf-8")
168
203
  nb_json = json.loads(raw)
169
- except Exception:
170
- logger.exception("Failed to parse notebook", extra={"path": str(path)})
204
+ except (OSError, json.JSONDecodeError, UnicodeDecodeError) as exc:
205
+ logger.warning(
206
+ "Failed to parse notebook: %s: %s",
207
+ type(exc).__name__, exc,
208
+ extra={"path": str(path)},
209
+ )
171
210
  return ValidationResult(
172
- result=False, message="Notebook is not valid JSON.", error_code=6
211
+ result=False,
212
+ message="Notebook is not valid JSON.",
213
+ error_code=8,
173
214
  )
174
215
 
175
216
  cells = nb_json.get("cells", [])
@@ -180,7 +221,7 @@ class NotebookEditTool(Tool[NotebookEditInput, NotebookEditOutput]):
180
221
  return ValidationResult(
181
222
  result=False,
182
223
  message=f"Cell '{input_data.cell_id}' not found in notebook.",
183
- error_code=7,
224
+ error_code=9,
184
225
  )
185
226
 
186
227
  return ValidationResult(result=True)
@@ -279,9 +320,10 @@ class NotebookEditTool(Tool[NotebookEditInput, NotebookEditOutput]):
279
320
  json.dumps(nb_json, indent=1),
280
321
  getattr(context, "file_state_cache", {}),
281
322
  )
282
- except Exception:
283
- logger.exception(
284
- "[notebook_edit_tool] Failed to record file snapshot",
323
+ except (OSError, IOError, RuntimeError) as exc:
324
+ logger.warning(
325
+ "[notebook_edit_tool] Failed to record file snapshot: %s: %s",
326
+ type(exc).__name__, exc,
285
327
  extra={"file_path": input_data.notebook_path},
286
328
  )
287
329
 
@@ -296,10 +338,12 @@ class NotebookEditTool(Tool[NotebookEditInput, NotebookEditOutput]):
296
338
  yield ToolResult(
297
339
  data=output, result_for_assistant=self.render_result_for_assistant(output)
298
340
  )
299
- except Exception as exc: # pragma: no cover - error path
300
- logger.exception(
301
- "Error editing notebook",
302
- extra={"path": input_data.notebook_path, "error": str(exc)},
341
+ except (OSError, json.JSONDecodeError, ValueError, KeyError) as exc:
342
+ # pragma: no cover - error path
343
+ logger.warning(
344
+ "Error editing notebook: %s: %s",
345
+ type(exc).__name__, exc,
346
+ extra={"path": input_data.notebook_path},
303
347
  )
304
348
  output = NotebookEditOutput(
305
349
  new_source=new_source,
@@ -0,0 +1,205 @@
1
+ """Skill loader tool.
2
+
3
+ Loads SKILL.md content from .ripperdoc/skills or ~/.ripperdoc/skills so the
4
+ assistant can pull in specialized instructions only when needed.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from pathlib import Path
10
+ from typing import AsyncGenerator, List, Optional
11
+
12
+ from pydantic import BaseModel, Field
13
+
14
+ from ripperdoc.core.skills import SkillDefinition, find_skill
15
+ from ripperdoc.core.tool import (
16
+ Tool,
17
+ ToolOutput,
18
+ ToolResult,
19
+ ToolUseContext,
20
+ ToolUseExample,
21
+ ValidationResult,
22
+ )
23
+ from ripperdoc.utils.log import get_logger
24
+
25
+ logger = get_logger()
26
+
27
+
28
+ class SkillToolInput(BaseModel):
29
+ """Input schema for the Skill tool."""
30
+
31
+ skill: str = Field(description='The skill name (e.g. "pdf-processing").')
32
+
33
+
34
+ class SkillToolOutput(BaseModel):
35
+ """Structured output for a loaded skill."""
36
+
37
+ success: bool = True
38
+ skill: str
39
+ description: str
40
+ location: str
41
+ base_dir: str
42
+ path: str
43
+ allowed_tools: List[str] = Field(default_factory=list)
44
+ model: Optional[str] = None
45
+ max_thinking_tokens: Optional[int] = None
46
+ skill_type: str = "prompt"
47
+ disable_model_invocation: bool = False
48
+ content: str
49
+
50
+
51
+ class SkillTool(Tool[SkillToolInput, SkillToolOutput]):
52
+ """Load a skill's instructions by name."""
53
+
54
+ def __init__(self, project_path: Optional[Path] = None, home: Optional[Path] = None) -> None:
55
+ self._project_path = project_path
56
+ self._home = home
57
+
58
+ @property
59
+ def name(self) -> str:
60
+ return "Skill"
61
+
62
+ async def description(self) -> str:
63
+ return (
64
+ "Execute a skill by name to load its SKILL.md instructions. "
65
+ "Use this only when the skill description clearly matches the user's request. "
66
+ "Skill metadata may include allowed-tools, model, or max-thinking-tokens hints."
67
+ )
68
+
69
+ @property
70
+ def input_schema(self) -> type[SkillToolInput]:
71
+ return SkillToolInput
72
+
73
+ def input_examples(self) -> List[ToolUseExample]:
74
+ return [
75
+ ToolUseExample(
76
+ description="Load PDF processing guidance",
77
+ example={"skill": "pdf-processing"},
78
+ ),
79
+ ToolUseExample(
80
+ description="Load commit message helper instructions",
81
+ example={"skill": "generating-commit-messages"},
82
+ ),
83
+ ]
84
+
85
+ async def prompt(self, safe_mode: bool = False) -> str: # noqa: ARG002
86
+ return (
87
+ "Load a skill by name to read its SKILL.md content. "
88
+ "Only call this when the skill description is clearly relevant. "
89
+ "If the skill specifies allowed-tools, model, or max-thinking-tokens in frontmatter, "
90
+ "assume those hints apply for subsequent reasoning. "
91
+ "Skill files may reference additional files under the same directory; "
92
+ "use file tools to read them if needed."
93
+ )
94
+
95
+ def is_read_only(self) -> bool:
96
+ return True
97
+
98
+ def is_concurrency_safe(self) -> bool:
99
+ return False
100
+
101
+ def needs_permissions(self, input_data: Optional[SkillToolInput] = None) -> bool: # noqa: ARG002
102
+ return False
103
+
104
+ async def validate_input(
105
+ self,
106
+ input_data: SkillToolInput,
107
+ context: Optional[ToolUseContext] = None, # noqa: ARG002
108
+ ) -> ValidationResult:
109
+ skill_name = (input_data.skill or "").strip().lstrip("/")
110
+ if not skill_name:
111
+ return ValidationResult(
112
+ result=False, message="Provide a skill name to load.", error_code=1
113
+ )
114
+ skill = find_skill(skill_name, project_path=self._project_path, home=self._home)
115
+ if not skill:
116
+ return ValidationResult(
117
+ result=False, message=f"Unknown skill: {skill_name}", error_code=2
118
+ )
119
+ if skill.disable_model_invocation:
120
+ return ValidationResult(
121
+ result=False,
122
+ message=f"Skill {skill_name} is blocked by disable-model-invocation.",
123
+ error_code=4,
124
+ )
125
+ if skill.skill_type and skill.skill_type != "prompt":
126
+ return ValidationResult(
127
+ result=False,
128
+ message=f"Skill {skill_name} is not a prompt-based skill (type={skill.skill_type}).",
129
+ error_code=5,
130
+ meta={"skill_type": skill.skill_type},
131
+ )
132
+ return ValidationResult(result=True)
133
+
134
+ def _render_result(self, skill: SkillDefinition) -> str:
135
+ allowed = ", ".join(skill.allowed_tools) if skill.allowed_tools else "no specific limit"
136
+ model_hint = f"\nModel hint: {skill.model}" if skill.model else ""
137
+ max_tokens = (
138
+ f"\nMax thinking tokens hint: {skill.max_thinking_tokens}"
139
+ if skill.max_thinking_tokens is not None
140
+ else ""
141
+ )
142
+ lines = [
143
+ f"Skill loaded: {skill.name} ({skill.location.value})",
144
+ f"Description: {skill.description}",
145
+ f"Skill directory: {skill.base_dir}",
146
+ f"Allowed tools (if specified): {allowed}{model_hint}{max_tokens}",
147
+ "SKILL.md content:",
148
+ skill.content,
149
+ ]
150
+ return "\n".join(lines)
151
+
152
+ def _to_output(self, skill: SkillDefinition) -> SkillToolOutput:
153
+ return SkillToolOutput(
154
+ success=True,
155
+ skill=skill.name,
156
+ description=skill.description,
157
+ location=skill.location.value,
158
+ base_dir=str(skill.base_dir),
159
+ path=str(skill.path),
160
+ allowed_tools=list(skill.allowed_tools),
161
+ model=skill.model,
162
+ max_thinking_tokens=skill.max_thinking_tokens,
163
+ skill_type=skill.skill_type,
164
+ disable_model_invocation=skill.disable_model_invocation,
165
+ content=skill.content,
166
+ )
167
+
168
+ async def call(
169
+ self, input_data: SkillToolInput, context: ToolUseContext
170
+ ) -> AsyncGenerator[ToolOutput, None]: # noqa: ARG002
171
+ skill_name = (input_data.skill or "").strip().lstrip("/")
172
+ skill = find_skill(skill_name, project_path=self._project_path, home=self._home)
173
+ if not skill:
174
+ error_text = (
175
+ f"Skill '{skill_name}' not found. Ensure it exists under "
176
+ "~/.ripperdoc/skills or ./.ripperdoc/skills."
177
+ )
178
+ yield ToolResult(data={"error": error_text}, result_for_assistant=error_text)
179
+ return
180
+ if skill.allowed_tools and context.tool_registry is not None:
181
+ # Ensure preferred tools for this skill are activated in the registry.
182
+ context.tool_registry.activate_tools(skill.allowed_tools)
183
+
184
+ output = self._to_output(skill)
185
+ yield ToolResult(data=output, result_for_assistant=self._render_result(skill))
186
+
187
+ def render_result_for_assistant(self, output: SkillToolOutput) -> str:
188
+ allowed = ", ".join(output.allowed_tools) if output.allowed_tools else "no specific limit"
189
+ model_hint = f"\nModel hint: {output.model}" if output.model else ""
190
+ max_tokens = (
191
+ f"\nMax thinking tokens hint: {output.max_thinking_tokens}"
192
+ if output.max_thinking_tokens is not None
193
+ else ""
194
+ )
195
+ return (
196
+ f"Skill loaded: {output.skill} ({output.location})\n"
197
+ f"Description: {output.description}\n"
198
+ f"Skill directory: {output.base_dir}\n"
199
+ f"Allowed tools (if specified): {allowed}{model_hint}{max_tokens}\n"
200
+ "SKILL.md content:\n"
201
+ f"{output.content}"
202
+ )
203
+
204
+ def render_tool_use_message(self, input_data: SkillToolInput, verbose: bool = False) -> str: # noqa: ARG002
205
+ return f"Load skill '{input_data.skill}'"
@@ -83,9 +83,7 @@ class TaskTool(Tool[TaskToolInput, TaskToolOutput]):
83
83
  )
84
84
  tools_label = "All tools"
85
85
  if getattr(agent, "tools", None):
86
- tools_label = (
87
- "All tools" if "*" in agent.tools else ", ".join(agent.tools)
88
- )
86
+ tools_label = "All tools" if "*" in agent.tools else ", ".join(agent.tools)
89
87
  agent_lines.append(
90
88
  f"- {agent.agent_type}: {agent.when_to_use} ({properties}Tools: {tools_label})"
91
89
  )
@@ -157,7 +155,7 @@ class TaskTool(Tool[TaskToolInput, TaskToolOutput]):
157
155
  "<commentary>\n"
158
156
  "Since the user is greeting, use the greeting-responder agent to respond with a friendly joke\n"
159
157
  "</commentary>\n"
160
- f'assistant: "I\'m going to use the {task_tool_name} tool to launch the greeting-responder agent\"\n'
158
+ f'assistant: "I\'m going to use the {task_tool_name} tool to launch the greeting-responder agent"\n'
161
159
  "</example>"
162
160
  )
163
161
 
@@ -369,10 +367,11 @@ class TaskTool(Tool[TaskToolInput, TaskToolOutput]):
369
367
 
370
368
  try:
371
369
  serialized = json.dumps(inp, ensure_ascii=False)
372
- except Exception:
373
- logger.exception(
374
- "[task_tool] Failed to serialize tool_use input",
375
- extra={"tool_use_input": str(inp)},
370
+ except (TypeError, ValueError) as exc:
371
+ logger.warning(
372
+ "[task_tool] Failed to serialize tool_use input: %s: %s",
373
+ type(exc).__name__, exc,
374
+ extra={"tool_use_input": str(inp)[:200]},
376
375
  )
377
376
  serialized = str(inp)
378
377
  return serialized if len(serialized) <= 120 else serialized[:117] + "..."
@@ -309,7 +309,7 @@ class TodoWriteTool(Tool[TodoWriteToolInput, TodoToolOutput]):
309
309
  ),
310
310
  ]
311
311
 
312
- async def prompt(self, safe_mode: bool = False) -> str:
312
+ async def prompt(self, _safe_mode: bool = False) -> str:
313
313
  return TODO_WRITE_PROMPT
314
314
 
315
315
  def is_read_only(self) -> bool:
@@ -318,13 +318,13 @@ class TodoWriteTool(Tool[TodoWriteToolInput, TodoToolOutput]):
318
318
  def is_concurrency_safe(self) -> bool:
319
319
  return False
320
320
 
321
- def needs_permissions(self, input_data: Optional[TodoWriteToolInput] = None) -> bool:
321
+ def needs_permissions(self, _input_data: Optional[TodoWriteToolInput] = None) -> bool:
322
322
  return False
323
323
 
324
324
  async def validate_input(
325
325
  self,
326
326
  input_data: TodoWriteToolInput,
327
- context: Optional[ToolUseContext] = None,
327
+ _context: Optional[ToolUseContext] = None,
328
328
  ) -> ValidationResult:
329
329
  todos = [TodoItem(**todo.model_dump()) for todo in input_data.todos]
330
330
  ok, message = validate_todos(todos)
@@ -338,14 +338,14 @@ class TodoWriteTool(Tool[TodoWriteToolInput, TodoToolOutput]):
338
338
  def render_tool_use_message(
339
339
  self,
340
340
  input_data: TodoWriteToolInput,
341
- verbose: bool = False,
341
+ _verbose: bool = False,
342
342
  ) -> str:
343
343
  return f"Updating todo list with {len(input_data.todos)} item(s)"
344
344
 
345
345
  async def call(
346
346
  self,
347
347
  input_data: TodoWriteToolInput,
348
- context: ToolUseContext,
348
+ _context: ToolUseContext,
349
349
  ) -> AsyncGenerator[ToolOutput, None]:
350
350
  try:
351
351
  todos = [TodoItem(**todo.model_dump()) for todo in input_data.todos]
@@ -360,8 +360,8 @@ class TodoWriteTool(Tool[TodoWriteToolInput, TodoToolOutput]):
360
360
  next_todo=get_next_actionable(updated),
361
361
  )
362
362
  yield ToolResult(data=output, result_for_assistant=result_text)
363
- except Exception as exc:
364
- logger.exception("[todo_tool] Error updating todos", extra={"error": str(exc)})
363
+ except (OSError, ValueError, KeyError, TypeError) as exc:
364
+ logger.warning("[todo_tool] Error updating todos: %s: %s", type(exc).__name__, exc)
365
365
  error = f"Error updating todos: {exc}"
366
366
  yield ToolResult(
367
367
  data=TodoToolOutput(
@@ -403,7 +403,7 @@ class TodoReadTool(Tool[TodoReadToolInput, TodoToolOutput]):
403
403
  ),
404
404
  ]
405
405
 
406
- async def prompt(self, safe_mode: bool = False) -> str:
406
+ async def prompt(self, _safe_mode: bool = False) -> str:
407
407
  return (
408
408
  "Use TodoRead to fetch the current todo list before making progress or when you need "
409
409
  "to confirm the next action. You can request only the next actionable item or filter "
@@ -416,13 +416,13 @@ class TodoReadTool(Tool[TodoReadToolInput, TodoToolOutput]):
416
416
  def is_concurrency_safe(self) -> bool:
417
417
  return True
418
418
 
419
- def needs_permissions(self, input_data: Optional[TodoReadToolInput] = None) -> bool:
419
+ def needs_permissions(self, _input_data: Optional[TodoReadToolInput] = None) -> bool:
420
420
  return False
421
421
 
422
422
  async def validate_input(
423
423
  self,
424
424
  input_data: TodoReadToolInput,
425
- context: Optional[ToolUseContext] = None,
425
+ _context: Optional[ToolUseContext] = None,
426
426
  ) -> ValidationResult:
427
427
  if input_data.limit < 0:
428
428
  return ValidationResult(result=False, message="limit cannot be negative")
@@ -445,7 +445,7 @@ class TodoReadTool(Tool[TodoReadToolInput, TodoToolOutput]):
445
445
  def render_tool_use_message(
446
446
  self,
447
447
  input_data: TodoReadToolInput,
448
- verbose: bool = False,
448
+ _verbose: bool = False,
449
449
  ) -> str:
450
450
  if input_data.next_only:
451
451
  return "Reading next actionable todo"
@@ -454,7 +454,7 @@ class TodoReadTool(Tool[TodoReadToolInput, TodoToolOutput]):
454
454
  async def call(
455
455
  self,
456
456
  input_data: TodoReadToolInput,
457
- context: ToolUseContext,
457
+ _context: ToolUseContext,
458
458
  ) -> AsyncGenerator[ToolOutput, None]:
459
459
  all_todos = load_todos()
460
460
  filtered = all_todos
@@ -119,9 +119,7 @@ class ToolSearchTool(Tool[ToolSearchInput, ToolSearchOutput]):
119
119
  def is_concurrency_safe(self) -> bool:
120
120
  return True
121
121
 
122
- def needs_permissions(
123
- self, input_data: Optional[ToolSearchInput] = None
124
- ) -> bool: # noqa: ARG002
122
+ def needs_permissions(self, input_data: Optional[ToolSearchInput] = None) -> bool: # noqa: ARG002
125
123
  return False
126
124
 
127
125
  async def validate_input(
@@ -191,10 +189,11 @@ class ToolSearchTool(Tool[ToolSearchInput, ToolSearchOutput]):
191
189
  description = await build_tool_description(
192
190
  tool, include_examples=include_examples, max_examples=2
193
191
  )
194
- except Exception:
192
+ except (OSError, RuntimeError, ValueError, TypeError, AttributeError, KeyError) as exc:
195
193
  description = ""
196
- logger.exception(
197
- "[tool_search] Failed to build tool description",
194
+ logger.warning(
195
+ "[tool_search] Failed to build tool description: %s: %s",
196
+ type(exc).__name__, exc,
198
197
  extra={"tool_name": getattr(tool, "name", None)},
199
198
  )
200
199
  doc_text = " ".join([name, tool.user_facing_name(), description])
@@ -0,0 +1,34 @@
1
+ """Lightweight parsing helpers for permissive type coercion."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Optional
6
+
7
+
8
+ def parse_boolish(value: object, default: bool = False) -> bool:
9
+ """Parse a truthy/falsey value from common representations."""
10
+ if value is None:
11
+ return default
12
+ if isinstance(value, bool):
13
+ return value
14
+ if isinstance(value, (int, float)):
15
+ return bool(value)
16
+ if isinstance(value, str):
17
+ normalized = value.strip().lower()
18
+ if normalized in {"1", "true", "yes", "on"}:
19
+ return True
20
+ if normalized in {"0", "false", "no", "off"}:
21
+ return False
22
+ return default
23
+
24
+
25
+ def parse_optional_int(value: object) -> Optional[int]:
26
+ """Best-effort int parsing; returns None on failure."""
27
+ try:
28
+ if value is None:
29
+ return None
30
+ if isinstance(value, bool):
31
+ return int(value)
32
+ return int(str(value).strip())
33
+ except (ValueError, TypeError):
34
+ return None