tunacode-cli 0.1.21__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.

Potentially problematic release.


This version of tunacode-cli might be problematic. Click here for more details.

Files changed (174) hide show
  1. tunacode/__init__.py +0 -0
  2. tunacode/cli/textual_repl.tcss +283 -0
  3. tunacode/configuration/__init__.py +1 -0
  4. tunacode/configuration/defaults.py +45 -0
  5. tunacode/configuration/models.py +147 -0
  6. tunacode/configuration/models_registry.json +1 -0
  7. tunacode/configuration/pricing.py +74 -0
  8. tunacode/configuration/settings.py +35 -0
  9. tunacode/constants.py +227 -0
  10. tunacode/core/__init__.py +6 -0
  11. tunacode/core/agents/__init__.py +39 -0
  12. tunacode/core/agents/agent_components/__init__.py +48 -0
  13. tunacode/core/agents/agent_components/agent_config.py +441 -0
  14. tunacode/core/agents/agent_components/agent_helpers.py +290 -0
  15. tunacode/core/agents/agent_components/message_handler.py +99 -0
  16. tunacode/core/agents/agent_components/node_processor.py +477 -0
  17. tunacode/core/agents/agent_components/response_state.py +129 -0
  18. tunacode/core/agents/agent_components/result_wrapper.py +51 -0
  19. tunacode/core/agents/agent_components/state_transition.py +112 -0
  20. tunacode/core/agents/agent_components/streaming.py +271 -0
  21. tunacode/core/agents/agent_components/task_completion.py +40 -0
  22. tunacode/core/agents/agent_components/tool_buffer.py +44 -0
  23. tunacode/core/agents/agent_components/tool_executor.py +101 -0
  24. tunacode/core/agents/agent_components/truncation_checker.py +37 -0
  25. tunacode/core/agents/delegation_tools.py +109 -0
  26. tunacode/core/agents/main.py +545 -0
  27. tunacode/core/agents/prompts.py +66 -0
  28. tunacode/core/agents/research_agent.py +231 -0
  29. tunacode/core/compaction.py +218 -0
  30. tunacode/core/prompting/__init__.py +27 -0
  31. tunacode/core/prompting/loader.py +66 -0
  32. tunacode/core/prompting/prompting_engine.py +98 -0
  33. tunacode/core/prompting/sections.py +50 -0
  34. tunacode/core/prompting/templates.py +69 -0
  35. tunacode/core/state.py +409 -0
  36. tunacode/exceptions.py +313 -0
  37. tunacode/indexing/__init__.py +5 -0
  38. tunacode/indexing/code_index.py +432 -0
  39. tunacode/indexing/constants.py +86 -0
  40. tunacode/lsp/__init__.py +112 -0
  41. tunacode/lsp/client.py +351 -0
  42. tunacode/lsp/diagnostics.py +19 -0
  43. tunacode/lsp/servers.py +101 -0
  44. tunacode/prompts/default_prompt.md +952 -0
  45. tunacode/prompts/research/sections/agent_role.xml +5 -0
  46. tunacode/prompts/research/sections/constraints.xml +14 -0
  47. tunacode/prompts/research/sections/output_format.xml +57 -0
  48. tunacode/prompts/research/sections/tool_use.xml +23 -0
  49. tunacode/prompts/sections/advanced_patterns.xml +255 -0
  50. tunacode/prompts/sections/agent_role.xml +8 -0
  51. tunacode/prompts/sections/completion.xml +10 -0
  52. tunacode/prompts/sections/critical_rules.xml +37 -0
  53. tunacode/prompts/sections/examples.xml +220 -0
  54. tunacode/prompts/sections/output_style.xml +94 -0
  55. tunacode/prompts/sections/parallel_exec.xml +105 -0
  56. tunacode/prompts/sections/search_pattern.xml +100 -0
  57. tunacode/prompts/sections/system_info.xml +6 -0
  58. tunacode/prompts/sections/tool_use.xml +84 -0
  59. tunacode/prompts/sections/user_instructions.xml +3 -0
  60. tunacode/py.typed +0 -0
  61. tunacode/templates/__init__.py +5 -0
  62. tunacode/templates/loader.py +15 -0
  63. tunacode/tools/__init__.py +10 -0
  64. tunacode/tools/authorization/__init__.py +29 -0
  65. tunacode/tools/authorization/context.py +32 -0
  66. tunacode/tools/authorization/factory.py +20 -0
  67. tunacode/tools/authorization/handler.py +58 -0
  68. tunacode/tools/authorization/notifier.py +35 -0
  69. tunacode/tools/authorization/policy.py +19 -0
  70. tunacode/tools/authorization/requests.py +119 -0
  71. tunacode/tools/authorization/rules.py +72 -0
  72. tunacode/tools/bash.py +222 -0
  73. tunacode/tools/decorators.py +213 -0
  74. tunacode/tools/glob.py +353 -0
  75. tunacode/tools/grep.py +468 -0
  76. tunacode/tools/grep_components/__init__.py +9 -0
  77. tunacode/tools/grep_components/file_filter.py +93 -0
  78. tunacode/tools/grep_components/pattern_matcher.py +158 -0
  79. tunacode/tools/grep_components/result_formatter.py +87 -0
  80. tunacode/tools/grep_components/search_result.py +34 -0
  81. tunacode/tools/list_dir.py +205 -0
  82. tunacode/tools/prompts/bash_prompt.xml +10 -0
  83. tunacode/tools/prompts/glob_prompt.xml +7 -0
  84. tunacode/tools/prompts/grep_prompt.xml +10 -0
  85. tunacode/tools/prompts/list_dir_prompt.xml +7 -0
  86. tunacode/tools/prompts/read_file_prompt.xml +9 -0
  87. tunacode/tools/prompts/todoclear_prompt.xml +12 -0
  88. tunacode/tools/prompts/todoread_prompt.xml +16 -0
  89. tunacode/tools/prompts/todowrite_prompt.xml +28 -0
  90. tunacode/tools/prompts/update_file_prompt.xml +9 -0
  91. tunacode/tools/prompts/web_fetch_prompt.xml +11 -0
  92. tunacode/tools/prompts/write_file_prompt.xml +7 -0
  93. tunacode/tools/react.py +111 -0
  94. tunacode/tools/read_file.py +68 -0
  95. tunacode/tools/todo.py +222 -0
  96. tunacode/tools/update_file.py +62 -0
  97. tunacode/tools/utils/__init__.py +1 -0
  98. tunacode/tools/utils/ripgrep.py +311 -0
  99. tunacode/tools/utils/text_match.py +352 -0
  100. tunacode/tools/web_fetch.py +245 -0
  101. tunacode/tools/write_file.py +34 -0
  102. tunacode/tools/xml_helper.py +34 -0
  103. tunacode/types/__init__.py +166 -0
  104. tunacode/types/base.py +94 -0
  105. tunacode/types/callbacks.py +53 -0
  106. tunacode/types/dataclasses.py +121 -0
  107. tunacode/types/pydantic_ai.py +31 -0
  108. tunacode/types/state.py +122 -0
  109. tunacode/ui/__init__.py +6 -0
  110. tunacode/ui/app.py +542 -0
  111. tunacode/ui/commands/__init__.py +430 -0
  112. tunacode/ui/components/__init__.py +1 -0
  113. tunacode/ui/headless/__init__.py +5 -0
  114. tunacode/ui/headless/output.py +72 -0
  115. tunacode/ui/main.py +252 -0
  116. tunacode/ui/renderers/__init__.py +41 -0
  117. tunacode/ui/renderers/errors.py +197 -0
  118. tunacode/ui/renderers/panels.py +550 -0
  119. tunacode/ui/renderers/search.py +314 -0
  120. tunacode/ui/renderers/tools/__init__.py +21 -0
  121. tunacode/ui/renderers/tools/bash.py +247 -0
  122. tunacode/ui/renderers/tools/diagnostics.py +186 -0
  123. tunacode/ui/renderers/tools/glob.py +226 -0
  124. tunacode/ui/renderers/tools/grep.py +228 -0
  125. tunacode/ui/renderers/tools/list_dir.py +198 -0
  126. tunacode/ui/renderers/tools/read_file.py +226 -0
  127. tunacode/ui/renderers/tools/research.py +294 -0
  128. tunacode/ui/renderers/tools/update_file.py +237 -0
  129. tunacode/ui/renderers/tools/web_fetch.py +182 -0
  130. tunacode/ui/repl_support.py +226 -0
  131. tunacode/ui/screens/__init__.py +16 -0
  132. tunacode/ui/screens/model_picker.py +303 -0
  133. tunacode/ui/screens/session_picker.py +181 -0
  134. tunacode/ui/screens/setup.py +218 -0
  135. tunacode/ui/screens/theme_picker.py +90 -0
  136. tunacode/ui/screens/update_confirm.py +69 -0
  137. tunacode/ui/shell_runner.py +129 -0
  138. tunacode/ui/styles/layout.tcss +98 -0
  139. tunacode/ui/styles/modals.tcss +38 -0
  140. tunacode/ui/styles/panels.tcss +81 -0
  141. tunacode/ui/styles/theme-nextstep.tcss +303 -0
  142. tunacode/ui/styles/widgets.tcss +33 -0
  143. tunacode/ui/styles.py +18 -0
  144. tunacode/ui/widgets/__init__.py +23 -0
  145. tunacode/ui/widgets/command_autocomplete.py +62 -0
  146. tunacode/ui/widgets/editor.py +402 -0
  147. tunacode/ui/widgets/file_autocomplete.py +47 -0
  148. tunacode/ui/widgets/messages.py +46 -0
  149. tunacode/ui/widgets/resource_bar.py +182 -0
  150. tunacode/ui/widgets/status_bar.py +98 -0
  151. tunacode/utils/__init__.py +0 -0
  152. tunacode/utils/config/__init__.py +13 -0
  153. tunacode/utils/config/user_configuration.py +91 -0
  154. tunacode/utils/messaging/__init__.py +10 -0
  155. tunacode/utils/messaging/message_utils.py +34 -0
  156. tunacode/utils/messaging/token_counter.py +77 -0
  157. tunacode/utils/parsing/__init__.py +13 -0
  158. tunacode/utils/parsing/command_parser.py +55 -0
  159. tunacode/utils/parsing/json_utils.py +188 -0
  160. tunacode/utils/parsing/retry.py +146 -0
  161. tunacode/utils/parsing/tool_parser.py +267 -0
  162. tunacode/utils/security/__init__.py +15 -0
  163. tunacode/utils/security/command.py +106 -0
  164. tunacode/utils/system/__init__.py +25 -0
  165. tunacode/utils/system/gitignore.py +155 -0
  166. tunacode/utils/system/paths.py +190 -0
  167. tunacode/utils/ui/__init__.py +9 -0
  168. tunacode/utils/ui/file_filter.py +135 -0
  169. tunacode/utils/ui/helpers.py +24 -0
  170. tunacode_cli-0.1.21.dist-info/METADATA +170 -0
  171. tunacode_cli-0.1.21.dist-info/RECORD +174 -0
  172. tunacode_cli-0.1.21.dist-info/WHEEL +4 -0
  173. tunacode_cli-0.1.21.dist-info/entry_points.txt +2 -0
  174. tunacode_cli-0.1.21.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,290 @@
1
+ """Helper functions for agent operations to reduce code duplication."""
2
+
3
+ from typing import Any
4
+
5
+ from tunacode.types import FallbackResponse, StateManager
6
+
7
+
8
+ class UserPromptPartFallback:
9
+ """Fallback class for UserPromptPart when pydantic_ai is not available."""
10
+
11
+ def __init__(self, content: str, part_kind: str):
12
+ self.content = content
13
+ self.part_kind = part_kind
14
+
15
+
16
+ # Cache for UserPromptPart class
17
+ _USER_PROMPT_PART_CLASS = None
18
+
19
+
20
+ def get_user_prompt_part_class():
21
+ """Get UserPromptPart class with caching and fallback for test environment."""
22
+ global _USER_PROMPT_PART_CLASS
23
+
24
+ if _USER_PROMPT_PART_CLASS is not None:
25
+ return _USER_PROMPT_PART_CLASS
26
+
27
+ try:
28
+ import importlib
29
+
30
+ messages = importlib.import_module("pydantic_ai.messages")
31
+ _USER_PROMPT_PART_CLASS = getattr(messages, "UserPromptPart", None)
32
+
33
+ if _USER_PROMPT_PART_CLASS is None:
34
+ _USER_PROMPT_PART_CLASS = UserPromptPartFallback
35
+ except Exception:
36
+ _USER_PROMPT_PART_CLASS = UserPromptPartFallback
37
+
38
+ return _USER_PROMPT_PART_CLASS
39
+
40
+
41
+ def create_user_message(content: str, state_manager: StateManager):
42
+ """Create a user message and add it to the session messages."""
43
+ from .message_handler import get_model_messages
44
+
45
+ model_request_cls = get_model_messages()[0]
46
+ UserPromptPart = get_user_prompt_part_class()
47
+ user_prompt_part = UserPromptPart(content=content, part_kind="user-prompt")
48
+ message = model_request_cls(parts=[user_prompt_part], kind="request")
49
+ state_manager.session.messages.append(message)
50
+ state_manager.session.update_token_count()
51
+ return message
52
+
53
+
54
+ def get_tool_summary(tool_calls: list[dict[str, Any]]) -> dict[str, int]:
55
+ """Generate a summary of tool usage from tool calls."""
56
+ tool_summary: dict[str, int] = {}
57
+ for tc in tool_calls:
58
+ tool_name = tc.get("tool", "unknown")
59
+ tool_summary[tool_name] = tool_summary.get(tool_name, 0) + 1
60
+ return tool_summary
61
+
62
+
63
+ def get_tool_description(tool_name: str, tool_args: dict[str, Any]) -> str:
64
+ """Get a descriptive string for a tool call."""
65
+ tool_desc = tool_name
66
+ if tool_name in ["grep", "glob"] and isinstance(tool_args, dict):
67
+ pattern = tool_args.get("pattern", "")
68
+ tool_desc = f"{tool_name}('{pattern}')"
69
+ elif tool_name == "read_file" and isinstance(tool_args, dict):
70
+ path = tool_args.get("file_path", tool_args.get("filepath", ""))
71
+ tool_desc = f"{tool_name}('{path}')"
72
+ return tool_desc
73
+
74
+
75
+ def get_readable_tool_description(tool_name: str, tool_args: dict[str, Any]) -> str:
76
+ """Get a human-readable description of a tool operation for batch panel display."""
77
+ if not isinstance(tool_args, dict):
78
+ return f"Executing `{tool_name}`"
79
+
80
+ if tool_name == "read_file":
81
+ file_path = tool_args.get("file_path", tool_args.get("filepath", ""))
82
+ if file_path:
83
+ return f"Reading `{file_path}`"
84
+ return "Reading file"
85
+
86
+ if tool_name == "list_dir":
87
+ directory = tool_args.get("directory", "")
88
+ if directory:
89
+ return f"Listing directory `{directory}`"
90
+ return "Listing directory"
91
+
92
+ if tool_name == "grep":
93
+ pattern = tool_args.get("pattern", "")
94
+ include_files = tool_args.get("include_files", "")
95
+ if pattern and include_files:
96
+ return f"Searching for `{pattern}` in `{include_files}`"
97
+ if pattern:
98
+ return f"Searching for `{pattern}`"
99
+ return "Searching files"
100
+
101
+ if tool_name == "glob":
102
+ pattern = tool_args.get("pattern", "")
103
+ if pattern:
104
+ return f"Finding files matching `{pattern}`"
105
+ return "Finding files"
106
+
107
+ if tool_name == "research_codebase":
108
+ query = tool_args.get("query", "")
109
+ if query:
110
+ # Truncate long queries for display
111
+ query_display = query[:60] + "..." if len(query) > 60 else query
112
+ return f"Researching: {query_display}"
113
+ return "Researching codebase"
114
+
115
+ # Fallback for unknown tools
116
+ return f"Executing `{tool_name}`"
117
+
118
+
119
+ def get_recent_tools_context(tool_calls: list[dict[str, Any]], limit: int = 3) -> str:
120
+ """Get a context string describing recent tool usage."""
121
+ if not tool_calls:
122
+ return "No tools used yet"
123
+
124
+ last_tools = []
125
+ for tc in tool_calls[-limit:]:
126
+ tool_name = tc.get("tool", "unknown")
127
+ tool_args = tc.get("args", {})
128
+ tool_desc = get_tool_description(tool_name, tool_args)
129
+ last_tools.append(tool_desc)
130
+
131
+ return f"Recent tools: {', '.join(last_tools)}"
132
+
133
+
134
+ def create_empty_response_message(
135
+ message: str,
136
+ empty_reason: str,
137
+ tool_calls: list[dict[str, Any]],
138
+ iteration: int,
139
+ state_manager: StateManager,
140
+ ) -> str:
141
+ """Create a constructive message for handling empty responses."""
142
+ tools_context = get_recent_tools_context(tool_calls)
143
+
144
+ reason = empty_reason if empty_reason != "empty" else "empty"
145
+ content = f"""Response appears {reason} or incomplete. Let's troubleshoot and try again.
146
+
147
+ Task: {message[:200]}...
148
+ {tools_context}
149
+ Attempt: {iteration}
150
+
151
+ Please take one of these specific actions:
152
+
153
+ 1. **Search yielded no results?** → Try alternative search terms or broader patterns
154
+ 2. **Found what you need?** → Use TUNACODE DONE: to finalize
155
+ 3. **Encountering a blocker?** → Explain the specific issue preventing progress
156
+ 4. **Need more context?** → Use list_dir or expand your search scope
157
+
158
+ **Expected in your response:**
159
+ - Execute at least one tool OR provide substantial analysis
160
+ - If stuck, clearly describe what you've tried and what's blocking you
161
+ - Avoid empty responses - the system needs actionable output to proceed
162
+
163
+ Ready to continue with a complete response."""
164
+
165
+ return content
166
+
167
+
168
+ def create_progress_summary(tool_calls: list[dict[str, Any]]) -> tuple[dict[str, int], str]:
169
+ """Create a progress summary from tool calls."""
170
+ tool_summary = get_tool_summary(tool_calls)
171
+
172
+ if tool_summary:
173
+ summary_str = ", ".join([f"{name}: {count}" for name, count in tool_summary.items()])
174
+ else:
175
+ summary_str = "No tools used yet"
176
+
177
+ return tool_summary, summary_str
178
+
179
+
180
+ def create_fallback_response(
181
+ iterations: int,
182
+ max_iterations: int,
183
+ tool_calls: list[dict[str, Any]],
184
+ messages: list[Any],
185
+ verbosity: str = "normal",
186
+ ) -> FallbackResponse:
187
+ """Create a comprehensive fallback response when iteration limit is reached."""
188
+ fallback = FallbackResponse(
189
+ summary="Reached maximum iterations without producing a final response.",
190
+ progress=f"Completed {iterations} iterations (limit: {max_iterations})",
191
+ )
192
+
193
+ # Extract context from messages
194
+ tool_calls_summary = []
195
+ files_modified = set()
196
+ commands_run = []
197
+
198
+ for msg in messages:
199
+ if hasattr(msg, "parts"):
200
+ for part in msg.parts:
201
+ if hasattr(part, "part_kind") and part.part_kind == "tool-call":
202
+ tool_name = getattr(part, "tool_name", "unknown")
203
+ tool_calls_summary.append(tool_name)
204
+
205
+ # Track specific operations
206
+ if (
207
+ tool_name in ["write_file", "update_file"]
208
+ and hasattr(part, "args")
209
+ and isinstance(part.args, dict)
210
+ and "file_path" in part.args
211
+ ):
212
+ files_modified.add(part.args["file_path"])
213
+ elif (
214
+ tool_name == "bash"
215
+ and hasattr(part, "args")
216
+ and isinstance(part.args, dict)
217
+ and "command" in part.args
218
+ ):
219
+ commands_run.append(part.args["command"])
220
+
221
+ if verbosity in ["normal", "detailed"]:
222
+ # Add what was attempted
223
+ if tool_calls_summary:
224
+ tool_counts: dict[str, int] = {}
225
+ for tool in tool_calls_summary:
226
+ tool_counts[tool] = tool_counts.get(tool, 0) + 1
227
+
228
+ fallback.issues.append(f"Executed {len(tool_calls_summary)} tool calls:")
229
+ for tool, count in sorted(tool_counts.items()):
230
+ fallback.issues.append(f" • {tool}: {count}x")
231
+
232
+ if verbosity == "detailed":
233
+ if files_modified:
234
+ fallback.issues.append(f"\nFiles modified ({len(files_modified)}):")
235
+ for f in sorted(files_modified)[:5]:
236
+ fallback.issues.append(f" • {f}")
237
+ if len(files_modified) > 5:
238
+ fallback.issues.append(f" • ... and {len(files_modified) - 5} more")
239
+
240
+ if commands_run:
241
+ fallback.issues.append(f"\nCommands executed ({len(commands_run)}):")
242
+ for cmd in commands_run[:3]:
243
+ display_cmd = cmd if len(cmd) <= 60 else cmd[:57] + "..."
244
+ fallback.issues.append(f" • {display_cmd}")
245
+ if len(commands_run) > 3:
246
+ fallback.issues.append(f" • ... and {len(commands_run) - 3} more")
247
+
248
+ # Add helpful next steps
249
+ fallback.next_steps.append("The task may be too complex - try breaking it into smaller steps")
250
+ fallback.next_steps.append("Check the output above for any errors or partial progress")
251
+ if files_modified:
252
+ fallback.next_steps.append("Review modified files to see what changes were made")
253
+
254
+ return fallback
255
+
256
+
257
+ async def handle_empty_response(
258
+ message: str,
259
+ reason: str,
260
+ iter_index: int,
261
+ state: Any,
262
+ ) -> None:
263
+ """Handle empty responses by creating a synthetic user message with retry guidance."""
264
+ force_action_content = create_empty_response_message(
265
+ message,
266
+ reason,
267
+ getattr(state.sm.session, "tool_calls", []),
268
+ iter_index,
269
+ state.sm,
270
+ )
271
+ create_user_message(force_action_content, state.sm)
272
+
273
+
274
+ def format_fallback_output(fallback: FallbackResponse) -> str:
275
+ """Format a fallback response into a comprehensive output string."""
276
+ output_parts = [fallback.summary, ""]
277
+
278
+ if fallback.progress:
279
+ output_parts.append(f"Progress: {fallback.progress}")
280
+
281
+ if fallback.issues:
282
+ output_parts.append("\nWhat happened:")
283
+ output_parts.extend(fallback.issues)
284
+
285
+ if fallback.next_steps:
286
+ output_parts.append("\nSuggested next steps:")
287
+ for step in fallback.next_steps:
288
+ output_parts.append(f" • {step}")
289
+
290
+ return "\n".join(output_parts)
@@ -0,0 +1,99 @@
1
+ """Message handling utilities for agent communication."""
2
+
3
+ from datetime import UTC, datetime
4
+
5
+ from tunacode.core.state import StateManager
6
+
7
+ ToolCallId = str
8
+ ToolName = str
9
+ ErrorMessage = str | None
10
+
11
+
12
+ def get_model_messages():
13
+ """
14
+ Safely retrieve message-related classes from pydantic_ai.
15
+
16
+ If the running environment (e.g. our test stubs) does not define
17
+ SystemPromptPart we create a minimal placeholder so that the rest of the
18
+ code can continue to work without depending on the real implementation.
19
+ """
20
+ import importlib
21
+
22
+ messages = importlib.import_module("pydantic_ai.messages")
23
+
24
+ # Get the required classes
25
+ ModelRequest = messages.ModelRequest
26
+ ToolReturnPart = messages.ToolReturnPart
27
+
28
+ # Create minimal fallback for SystemPromptPart if it doesn't exist
29
+ if not hasattr(messages, "SystemPromptPart"):
30
+
31
+ class SystemPromptPart: # type: ignore
32
+ def __init__(self, content: str = "", role: str = "system", part_kind: str = ""):
33
+ self.content = content
34
+ self.role = role
35
+ self.part_kind = part_kind
36
+ else:
37
+ SystemPromptPart = messages.SystemPromptPart
38
+
39
+ return ModelRequest, ToolReturnPart, SystemPromptPart
40
+
41
+
42
+ def patch_tool_messages(
43
+ error_message: ErrorMessage = "Tool operation failed",
44
+ state_manager: StateManager = None,
45
+ ):
46
+ """
47
+ Find any tool calls without responses and add synthetic error responses for them.
48
+ Takes an error message to use in the synthesized tool response.
49
+
50
+ Ignores tools that have corresponding retry prompts as the model is already
51
+ addressing them.
52
+ """
53
+ if state_manager is None:
54
+ raise ValueError("state_manager is required for patch_tool_messages")
55
+
56
+ messages = state_manager.session.messages
57
+
58
+ if not messages:
59
+ return
60
+
61
+ # Map tool calls to their tool returns
62
+ tool_calls: dict[ToolCallId, ToolName] = {} # tool_call_id -> tool_name
63
+ tool_returns: set[ToolCallId] = set() # set of tool_call_ids with returns
64
+ retry_prompts: set[ToolCallId] = set() # set of tool_call_ids with retry prompts
65
+
66
+ for message in messages:
67
+ if hasattr(message, "parts"):
68
+ for part in message.parts:
69
+ if (
70
+ hasattr(part, "part_kind")
71
+ and hasattr(part, "tool_call_id")
72
+ and part.tool_call_id
73
+ ):
74
+ if part.part_kind == "tool-call":
75
+ tool_calls[part.tool_call_id] = part.tool_name
76
+ elif part.part_kind == "tool-return":
77
+ tool_returns.add(part.tool_call_id)
78
+ elif part.part_kind == "retry-prompt":
79
+ retry_prompts.add(part.tool_call_id)
80
+
81
+ # Identify orphaned tools (those without responses and not being retried)
82
+ for tool_call_id, tool_name in list(tool_calls.items()):
83
+ if tool_call_id not in tool_returns and tool_call_id not in retry_prompts:
84
+ # Import ModelRequest and ToolReturnPart lazily
85
+ ModelRequest, ToolReturnPart, _ = get_model_messages()
86
+ messages.append(
87
+ ModelRequest(
88
+ parts=[
89
+ ToolReturnPart(
90
+ tool_name=tool_name,
91
+ content=error_message,
92
+ tool_call_id=tool_call_id,
93
+ timestamp=datetime.now(UTC),
94
+ part_kind="tool-return",
95
+ )
96
+ ],
97
+ kind="request",
98
+ )
99
+ )