ripperdoc 0.2.6__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 (107) hide show
  1. ripperdoc/__init__.py +3 -0
  2. ripperdoc/__main__.py +20 -0
  3. ripperdoc/cli/__init__.py +1 -0
  4. ripperdoc/cli/cli.py +405 -0
  5. ripperdoc/cli/commands/__init__.py +82 -0
  6. ripperdoc/cli/commands/agents_cmd.py +263 -0
  7. ripperdoc/cli/commands/base.py +19 -0
  8. ripperdoc/cli/commands/clear_cmd.py +18 -0
  9. ripperdoc/cli/commands/compact_cmd.py +23 -0
  10. ripperdoc/cli/commands/config_cmd.py +31 -0
  11. ripperdoc/cli/commands/context_cmd.py +144 -0
  12. ripperdoc/cli/commands/cost_cmd.py +82 -0
  13. ripperdoc/cli/commands/doctor_cmd.py +221 -0
  14. ripperdoc/cli/commands/exit_cmd.py +19 -0
  15. ripperdoc/cli/commands/help_cmd.py +20 -0
  16. ripperdoc/cli/commands/mcp_cmd.py +70 -0
  17. ripperdoc/cli/commands/memory_cmd.py +202 -0
  18. ripperdoc/cli/commands/models_cmd.py +413 -0
  19. ripperdoc/cli/commands/permissions_cmd.py +302 -0
  20. ripperdoc/cli/commands/resume_cmd.py +98 -0
  21. ripperdoc/cli/commands/status_cmd.py +167 -0
  22. ripperdoc/cli/commands/tasks_cmd.py +278 -0
  23. ripperdoc/cli/commands/todos_cmd.py +69 -0
  24. ripperdoc/cli/commands/tools_cmd.py +19 -0
  25. ripperdoc/cli/ui/__init__.py +1 -0
  26. ripperdoc/cli/ui/context_display.py +298 -0
  27. ripperdoc/cli/ui/helpers.py +22 -0
  28. ripperdoc/cli/ui/rich_ui.py +1557 -0
  29. ripperdoc/cli/ui/spinner.py +49 -0
  30. ripperdoc/cli/ui/thinking_spinner.py +128 -0
  31. ripperdoc/cli/ui/tool_renderers.py +298 -0
  32. ripperdoc/core/__init__.py +1 -0
  33. ripperdoc/core/agents.py +486 -0
  34. ripperdoc/core/commands.py +33 -0
  35. ripperdoc/core/config.py +559 -0
  36. ripperdoc/core/default_tools.py +88 -0
  37. ripperdoc/core/permissions.py +252 -0
  38. ripperdoc/core/providers/__init__.py +47 -0
  39. ripperdoc/core/providers/anthropic.py +250 -0
  40. ripperdoc/core/providers/base.py +265 -0
  41. ripperdoc/core/providers/gemini.py +615 -0
  42. ripperdoc/core/providers/openai.py +487 -0
  43. ripperdoc/core/query.py +1058 -0
  44. ripperdoc/core/query_utils.py +622 -0
  45. ripperdoc/core/skills.py +295 -0
  46. ripperdoc/core/system_prompt.py +431 -0
  47. ripperdoc/core/tool.py +240 -0
  48. ripperdoc/sdk/__init__.py +9 -0
  49. ripperdoc/sdk/client.py +333 -0
  50. ripperdoc/tools/__init__.py +1 -0
  51. ripperdoc/tools/ask_user_question_tool.py +431 -0
  52. ripperdoc/tools/background_shell.py +389 -0
  53. ripperdoc/tools/bash_output_tool.py +98 -0
  54. ripperdoc/tools/bash_tool.py +1016 -0
  55. ripperdoc/tools/dynamic_mcp_tool.py +428 -0
  56. ripperdoc/tools/enter_plan_mode_tool.py +226 -0
  57. ripperdoc/tools/exit_plan_mode_tool.py +153 -0
  58. ripperdoc/tools/file_edit_tool.py +346 -0
  59. ripperdoc/tools/file_read_tool.py +203 -0
  60. ripperdoc/tools/file_write_tool.py +205 -0
  61. ripperdoc/tools/glob_tool.py +179 -0
  62. ripperdoc/tools/grep_tool.py +370 -0
  63. ripperdoc/tools/kill_bash_tool.py +136 -0
  64. ripperdoc/tools/ls_tool.py +471 -0
  65. ripperdoc/tools/mcp_tools.py +591 -0
  66. ripperdoc/tools/multi_edit_tool.py +456 -0
  67. ripperdoc/tools/notebook_edit_tool.py +386 -0
  68. ripperdoc/tools/skill_tool.py +205 -0
  69. ripperdoc/tools/task_tool.py +379 -0
  70. ripperdoc/tools/todo_tool.py +494 -0
  71. ripperdoc/tools/tool_search_tool.py +380 -0
  72. ripperdoc/utils/__init__.py +1 -0
  73. ripperdoc/utils/bash_constants.py +51 -0
  74. ripperdoc/utils/bash_output_utils.py +43 -0
  75. ripperdoc/utils/coerce.py +34 -0
  76. ripperdoc/utils/context_length_errors.py +252 -0
  77. ripperdoc/utils/exit_code_handlers.py +241 -0
  78. ripperdoc/utils/file_watch.py +135 -0
  79. ripperdoc/utils/git_utils.py +274 -0
  80. ripperdoc/utils/json_utils.py +27 -0
  81. ripperdoc/utils/log.py +176 -0
  82. ripperdoc/utils/mcp.py +560 -0
  83. ripperdoc/utils/memory.py +253 -0
  84. ripperdoc/utils/message_compaction.py +676 -0
  85. ripperdoc/utils/messages.py +519 -0
  86. ripperdoc/utils/output_utils.py +258 -0
  87. ripperdoc/utils/path_ignore.py +677 -0
  88. ripperdoc/utils/path_utils.py +46 -0
  89. ripperdoc/utils/permissions/__init__.py +27 -0
  90. ripperdoc/utils/permissions/path_validation_utils.py +174 -0
  91. ripperdoc/utils/permissions/shell_command_validation.py +552 -0
  92. ripperdoc/utils/permissions/tool_permission_utils.py +279 -0
  93. ripperdoc/utils/prompt.py +17 -0
  94. ripperdoc/utils/safe_get_cwd.py +31 -0
  95. ripperdoc/utils/sandbox_utils.py +38 -0
  96. ripperdoc/utils/session_history.py +260 -0
  97. ripperdoc/utils/session_usage.py +117 -0
  98. ripperdoc/utils/shell_token_utils.py +95 -0
  99. ripperdoc/utils/shell_utils.py +159 -0
  100. ripperdoc/utils/todo.py +203 -0
  101. ripperdoc/utils/token_estimation.py +34 -0
  102. ripperdoc-0.2.6.dist-info/METADATA +193 -0
  103. ripperdoc-0.2.6.dist-info/RECORD +107 -0
  104. ripperdoc-0.2.6.dist-info/WHEEL +5 -0
  105. ripperdoc-0.2.6.dist-info/entry_points.txt +3 -0
  106. ripperdoc-0.2.6.dist-info/licenses/LICENSE +53 -0
  107. ripperdoc-0.2.6.dist-info/top_level.txt +1 -0
@@ -0,0 +1,622 @@
1
+ """Utility helpers for query handling, tool schemas, and message normalization."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ from typing import Any, Dict, List, Mapping, Optional, Union
8
+ from uuid import uuid4
9
+
10
+ from json_repair import repair_json # type: ignore[import-not-found]
11
+ from pydantic import ValidationError
12
+
13
+ from ripperdoc.core.config import ModelProfile, ProviderType, get_global_config
14
+ from ripperdoc.core.tool import Tool, build_tool_description, tool_input_examples
15
+ from ripperdoc.utils.json_utils import safe_parse_json
16
+ from ripperdoc.utils.log import get_logger
17
+ from ripperdoc.utils.messages import (
18
+ AssistantMessage,
19
+ MessageContent,
20
+ ProgressMessage,
21
+ UserMessage,
22
+ create_assistant_message,
23
+ create_user_message,
24
+ )
25
+
26
+ logger = get_logger()
27
+
28
+
29
+ def _safe_int(value: object) -> int:
30
+ """Best-effort int conversion for usage counters."""
31
+ try:
32
+ if value is None:
33
+ return 0
34
+ if isinstance(value, bool):
35
+ return int(value)
36
+ if isinstance(value, (int, float)):
37
+ return int(value)
38
+ if isinstance(value, str):
39
+ return int(value)
40
+ if hasattr(value, "__int__"):
41
+ return int(value) # type: ignore[arg-type]
42
+ return 0
43
+ except (TypeError, ValueError):
44
+ return 0
45
+
46
+
47
+ def _get_usage_field(usage: Optional[Mapping[str, Any] | object], field: str) -> int:
48
+ """Fetch a usage field from either a dict or object."""
49
+ if usage is None:
50
+ return 0
51
+ if isinstance(usage, dict):
52
+ return _safe_int(usage.get(field))
53
+ return _safe_int(getattr(usage, field, 0))
54
+
55
+
56
+ def anthropic_usage_tokens(usage: Optional[Mapping[str, Any] | object]) -> Dict[str, int]:
57
+ """Extract token counts from an Anthropic response usage payload."""
58
+ return {
59
+ "input_tokens": _get_usage_field(usage, "input_tokens"),
60
+ "output_tokens": _get_usage_field(usage, "output_tokens"),
61
+ "cache_read_input_tokens": _get_usage_field(usage, "cache_read_input_tokens"),
62
+ "cache_creation_input_tokens": _get_usage_field(usage, "cache_creation_input_tokens"),
63
+ }
64
+
65
+
66
+ def openai_usage_tokens(usage: Optional[Mapping[str, Any] | object]) -> Dict[str, int]:
67
+ """Extract token counts from an OpenAI-compatible response usage payload."""
68
+ prompt_details = None
69
+ input_details = None
70
+ output_details = None
71
+ if isinstance(usage, dict):
72
+ prompt_details = usage.get("prompt_tokens_details")
73
+ input_details = usage.get("input_tokens_details")
74
+ output_details = usage.get("output_tokens_details")
75
+ else:
76
+ prompt_details = getattr(usage, "prompt_tokens_details", None)
77
+ input_details = getattr(usage, "input_tokens_details", None)
78
+ output_details = getattr(usage, "output_tokens_details", None)
79
+
80
+ cache_read_tokens = 0
81
+ if prompt_details:
82
+ cache_read_tokens = _get_usage_field(prompt_details, "cached_tokens")
83
+ if not cache_read_tokens and input_details:
84
+ cache_read_tokens = _get_usage_field(input_details, "cached_tokens")
85
+
86
+ input_tokens = _get_usage_field(usage, "prompt_tokens")
87
+ if not input_tokens:
88
+ input_tokens = _get_usage_field(usage, "input_tokens")
89
+
90
+ output_tokens = _get_usage_field(usage, "completion_tokens")
91
+ if not output_tokens:
92
+ output_tokens = _get_usage_field(usage, "output_tokens")
93
+
94
+ reasoning_tokens = _get_usage_field(output_details, "reasoning_tokens") if output_details else 0
95
+ if reasoning_tokens:
96
+ if output_tokens <= 0:
97
+ output_tokens = reasoning_tokens
98
+ elif output_tokens < reasoning_tokens:
99
+ output_tokens = output_tokens + reasoning_tokens
100
+ else:
101
+ output_tokens = max(output_tokens, reasoning_tokens)
102
+
103
+ return {
104
+ "input_tokens": input_tokens,
105
+ "output_tokens": output_tokens,
106
+ "cache_read_input_tokens": cache_read_tokens,
107
+ "cache_creation_input_tokens": 0,
108
+ }
109
+
110
+
111
+ def estimate_cost_usd(model_profile: ModelProfile, usage_tokens: Dict[str, int]) -> float:
112
+ """Compute USD cost using per-1M token pricing from the model profile."""
113
+ input_price = getattr(model_profile, "input_cost_per_million_tokens", 0.0) or 0.0
114
+ output_price = getattr(model_profile, "output_cost_per_million_tokens", 0.0) or 0.0
115
+
116
+ total_input_tokens = (
117
+ _safe_int(usage_tokens.get("input_tokens"))
118
+ + _safe_int(usage_tokens.get("cache_read_input_tokens"))
119
+ + _safe_int(usage_tokens.get("cache_creation_input_tokens"))
120
+ )
121
+ output_tokens = _safe_int(usage_tokens.get("output_tokens"))
122
+
123
+ cost = (total_input_tokens * input_price + output_tokens * output_price) / 1_000_000
124
+ return float(cost)
125
+
126
+
127
+ def resolve_model_profile(model: str) -> ModelProfile:
128
+ """Resolve a model pointer to a concrete profile, falling back to a safe default."""
129
+ config = get_global_config()
130
+ profile_name = getattr(config.model_pointers, model, None) or model
131
+ model_profile = config.model_profiles.get(profile_name)
132
+ if model_profile is None:
133
+ fallback_profile = getattr(config.model_pointers, "main", "default")
134
+ model_profile = config.model_profiles.get(fallback_profile)
135
+ if not model_profile:
136
+ logger.warning(
137
+ "[config] No model profile found; using built-in default profile",
138
+ extra={"model_pointer": model},
139
+ )
140
+ return ModelProfile(provider=ProviderType.OPENAI_COMPATIBLE, model="gpt-4o-mini")
141
+ return model_profile
142
+
143
+
144
+ def determine_tool_mode(model_profile: ModelProfile) -> str:
145
+ """Return configured tool mode for provider."""
146
+ if model_profile.provider != ProviderType.OPENAI_COMPATIBLE:
147
+ return "native"
148
+ configured = getattr(model_profile, "openai_tool_mode", "native") or "native"
149
+ configured = configured.lower()
150
+ if configured not in {"native", "text"}:
151
+ configured = getattr(get_global_config(), "openai_tool_mode", "native") or "native"
152
+ configured = configured.lower()
153
+ return configured if configured in {"native", "text"} else "native"
154
+
155
+
156
+ def _parse_text_mode_json_blocks(text: str) -> Optional[List[Dict[str, Any]]]:
157
+ """Parse a JSON code block or raw JSON string into content blocks for text mode."""
158
+ if not text or not isinstance(text, str):
159
+ return None
160
+
161
+ code_blocks = re.findall(r"```(?:\s*json)?\s*([\s\S]*?)\s*```", text, flags=re.IGNORECASE)
162
+ candidates = [blk.strip() for blk in code_blocks if blk.strip()]
163
+
164
+ def _normalize_blocks(parsed: object) -> Optional[List[Dict[str, Any]]]:
165
+ raw_blocks = parsed if isinstance(parsed, list) else [parsed]
166
+ normalized: List[Dict[str, Any]] = []
167
+ for raw in raw_blocks:
168
+ if not isinstance(raw, dict):
169
+ continue
170
+ block_type = raw.get("type")
171
+ if block_type == "text":
172
+ text_value = raw.get("text") or raw.get("content")
173
+ if isinstance(text_value, str) and text_value:
174
+ normalized.append({"type": "text", "text": text_value})
175
+ elif block_type == "tool_use":
176
+ tool_name = raw.get("tool") or raw.get("name")
177
+ if not isinstance(tool_name, str) or not tool_name:
178
+ continue
179
+ tool_use_id = raw.get("tool_use_id") or raw.get("id") or str(uuid4())
180
+ input_value = raw.get("input") or {}
181
+ if not isinstance(input_value, dict):
182
+ input_value = _normalize_tool_args(input_value)
183
+ normalized.append(
184
+ {
185
+ "type": "tool_use",
186
+ "tool_use_id": str(tool_use_id),
187
+ "name": tool_name,
188
+ "input": input_value,
189
+ }
190
+ )
191
+ return normalized if normalized else None
192
+
193
+ last_error: Optional[str] = None
194
+
195
+ for candidate in candidates:
196
+ if not candidate:
197
+ continue
198
+
199
+ parsed: Any = None
200
+ try:
201
+ parsed = json.loads(candidate)
202
+ except json.JSONDecodeError as exc:
203
+ last_error = str(exc)
204
+ parsed = repair_json(candidate, return_objects=True, ensure_ascii=False)
205
+
206
+ if parsed is None or parsed == "":
207
+ continue
208
+
209
+ normalized = _normalize_blocks(parsed)
210
+ if normalized:
211
+ return normalized
212
+
213
+ last_error = "Parsed JSON did not contain valid content blocks."
214
+
215
+ if last_error:
216
+ error_text = (
217
+ f"JSON parsing failed: {last_error} "
218
+ "Please resend a valid JSON array of content blocks inside a ```json``` code block."
219
+ )
220
+ return [{"type": "text", "text": error_text}]
221
+
222
+ return None
223
+
224
+
225
+ def _tool_prompt_for_text_mode(tools: List[Tool[Any, Any]]) -> str:
226
+ """Build a system hint describing available tools and the expected JSON format."""
227
+ if not tools:
228
+ return ""
229
+
230
+ lines = [
231
+ "You are in text-only tool mode. Tools are not auto-invoked by the API.",
232
+ "Respond with one Markdown `json` code block containing a JSON array of content blocks.",
233
+ 'Each block must include `type`; use {"type": "text", "text": "<message>"} for text and '
234
+ '{"type": "tool_use", "tool_use_id": "<tool_id>", "tool": "<tool_name>", "input": { ... required params ... }} '
235
+ "for tool calls. Add multiple `tool_use` blocks if you need multiple tools.",
236
+ "Include your natural language reply as a `text` block, followed by any `tool_use` blocks.",
237
+ "Only include the JSON array inside the code block - no extra prose.",
238
+ "Available tools:",
239
+ ]
240
+
241
+ for tool in tools:
242
+ required_fields: List[str] = []
243
+ try:
244
+ for fname, finfo in getattr(tool.input_schema, "model_fields", {}).items():
245
+ is_req = False
246
+ if hasattr(finfo, "is_required"):
247
+ try:
248
+ is_req = bool(finfo.is_required())
249
+ except (TypeError, AttributeError):
250
+ is_req = False
251
+ required_fields.append(f"{fname}{' (required)' if is_req else ''}")
252
+ except (AttributeError, TypeError):
253
+ required_fields = []
254
+
255
+ required_str = ", ".join(required_fields) if required_fields else "see input schema"
256
+ lines.append(f"- {tool.name}: fields {required_str}")
257
+
258
+ schema_json = ""
259
+ try:
260
+ schema_json = json.dumps(
261
+ tool.input_schema.model_json_schema(), ensure_ascii=False, indent=2
262
+ )
263
+ except (AttributeError, TypeError, ValueError) as exc:
264
+ logger.debug(
265
+ "[tool_prompt] Failed to render input_schema",
266
+ extra={"tool": getattr(tool, "name", None), "error": str(exc)},
267
+ )
268
+ if schema_json:
269
+ lines.append(" input schema (JSON):")
270
+ lines.append(" ```json")
271
+ lines.append(f" {schema_json}")
272
+ lines.append(" ```")
273
+
274
+ example_blocks = [
275
+ {"type": "text", "text": "好的,我来帮你查看一下README.md文件"},
276
+ {
277
+ "type": "tool_use",
278
+ "tool_use_id": "tool_id_000001",
279
+ "tool": "View",
280
+ "input": {"file_path": "README.md"},
281
+ },
282
+ ]
283
+ lines.append("Example:")
284
+ lines.append("```json")
285
+ lines.append(json.dumps(example_blocks, ensure_ascii=False, indent=2))
286
+ lines.append("```")
287
+
288
+ return "\n".join(lines)
289
+
290
+
291
+ def text_mode_history(
292
+ messages: List[Union[UserMessage, AssistantMessage, ProgressMessage]],
293
+ ) -> List[Union[UserMessage, AssistantMessage]]:
294
+ """Convert a message history into text-only form for text mode."""
295
+
296
+ def _normalize_block(block: Any) -> Optional[Dict[str, Any]]:
297
+ blk = MessageContent(**block) if isinstance(block, dict) else block
298
+ btype = getattr(blk, "type", None)
299
+ if btype == "text":
300
+ text_val = getattr(blk, "text", None) or getattr(blk, "content", None) or ""
301
+ return {"type": "text", "text": text_val}
302
+ if btype == "tool_use":
303
+ return {
304
+ "type": "tool_use",
305
+ "tool_use_id": getattr(blk, "tool_use_id", None) or getattr(blk, "id", None) or "",
306
+ "tool": getattr(blk, "name", None) or "",
307
+ "input": getattr(blk, "input", None) or {},
308
+ }
309
+ if btype == "tool_result":
310
+ result_block: Dict[str, Any] = {
311
+ "type": "tool_result",
312
+ "tool_use_id": getattr(blk, "tool_use_id", None) or getattr(blk, "id", None) or "",
313
+ "text": getattr(blk, "text", None) or getattr(blk, "content", None) or "",
314
+ }
315
+ is_error = getattr(blk, "is_error", None)
316
+ if is_error is not None:
317
+ result_block["is_error"] = is_error
318
+ return result_block
319
+ text_val = getattr(blk, "text", None) or getattr(blk, "content", None)
320
+ if text_val is not None:
321
+ return {"type": "text", "text": text_val}
322
+ return None
323
+
324
+ converted: List[Union[UserMessage, AssistantMessage]] = []
325
+ for msg in messages:
326
+ msg_type = getattr(msg, "type", None)
327
+ if msg_type == "progress" or msg_type is None:
328
+ continue
329
+ content = getattr(getattr(msg, "message", None), "content", None)
330
+ text_content: Optional[str] = None
331
+ if isinstance(content, list):
332
+ normalized_blocks = []
333
+ for block in content:
334
+ block_type = getattr(block, "type", None) or (
335
+ block.get("type") if isinstance(block, dict) else None
336
+ )
337
+ block_text = (
338
+ getattr(block, "text", None)
339
+ if hasattr(block, "text")
340
+ else (block.get("text") if isinstance(block, dict) else None)
341
+ )
342
+ if block_type == "text" and isinstance(block_text, str):
343
+ parsed_nested = _parse_text_mode_json_blocks(block_text)
344
+ if parsed_nested:
345
+ normalized_blocks.extend(parsed_nested)
346
+ continue
347
+ norm = _normalize_block(block)
348
+ if norm:
349
+ normalized_blocks.append(norm)
350
+ if normalized_blocks:
351
+ json_payload = json.dumps(normalized_blocks, ensure_ascii=False, indent=2)
352
+ text_content = f"```json\n{json_payload}\n```"
353
+ elif isinstance(content, str):
354
+ parsed_blocks = _parse_text_mode_json_blocks(content)
355
+ if parsed_blocks:
356
+ text_content = (
357
+ f"```json\n{json.dumps(parsed_blocks, ensure_ascii=False, indent=2)}\n```"
358
+ )
359
+ else:
360
+ text_content = content
361
+ else:
362
+ text_content = content if isinstance(content, str) else None
363
+ if not text_content:
364
+ continue
365
+ if msg_type == "user":
366
+ converted.append(create_user_message(text_content))
367
+ elif msg_type == "assistant":
368
+ converted.append(create_assistant_message(text_content))
369
+ return converted
370
+
371
+
372
+ def _maybe_convert_json_block_to_tool_use(
373
+ content_blocks: List[Dict[str, Any]],
374
+ ) -> List[Dict[str, Any]]:
375
+ """Convert any text blocks containing JSON content to structured content blocks."""
376
+ if not content_blocks:
377
+ return content_blocks
378
+
379
+ new_blocks: List[Dict[str, Any]] = []
380
+ converted_count = 0
381
+
382
+ for block in content_blocks:
383
+ if block.get("type") != "text":
384
+ new_blocks.append(block)
385
+ continue
386
+
387
+ text = block.get("text")
388
+ if not isinstance(text, str):
389
+ new_blocks.append(block)
390
+ continue
391
+
392
+ parsed_blocks = _parse_text_mode_json_blocks(text)
393
+ if not parsed_blocks:
394
+ new_blocks.append(block)
395
+ continue
396
+
397
+ for parsed in parsed_blocks:
398
+ if parsed.get("type") == "tool_use":
399
+ new_blocks.append(
400
+ {
401
+ "type": "tool_use",
402
+ "tool_use_id": parsed.get("tool_use_id") or str(uuid4()),
403
+ "name": parsed.get("name") or parsed.get("tool"),
404
+ "input": parsed.get("input") or {},
405
+ }
406
+ )
407
+ elif parsed.get("type") == "text":
408
+ new_blocks.append({"type": "text", "text": parsed.get("text") or ""})
409
+ converted_count += 1
410
+
411
+ if converted_count:
412
+ logger.debug(
413
+ "[query_llm] Converting JSON code block to structured content blocks",
414
+ extra={"block_count": len(new_blocks)},
415
+ )
416
+ return new_blocks
417
+
418
+
419
+ def _normalize_tool_args(raw_args: Any) -> Dict[str, Any]:
420
+ """Ensure tool arguments are returned as a dict, handling double-encoded strings."""
421
+ candidate = raw_args
422
+
423
+ for _ in range(2):
424
+ if isinstance(candidate, dict):
425
+ return candidate
426
+ if isinstance(candidate, str):
427
+ candidate = safe_parse_json(candidate, log_error=False)
428
+ continue
429
+ break
430
+
431
+ if isinstance(candidate, dict):
432
+ return candidate
433
+
434
+ preview = str(raw_args)
435
+ preview = preview[:200] if len(preview) > 200 else preview
436
+ logger.debug(
437
+ "[query_llm] Tool arguments not a dict; defaulting to empty object",
438
+ extra={"preview": preview},
439
+ )
440
+ return {}
441
+
442
+
443
+ def build_full_system_prompt(
444
+ system_prompt: str, context: Dict[str, str], tool_mode: str, tools: List[Tool[Any, Any]]
445
+ ) -> str:
446
+ """Compose the final system prompt including context and tool hints."""
447
+ full_prompt = system_prompt
448
+ if context:
449
+ context_str = "\n".join(f"{k}: {v}" for k, v in context.items())
450
+ full_prompt = f"{system_prompt}\n\nContext:\n{context_str}"
451
+ if tool_mode == "text":
452
+ tool_hint = _tool_prompt_for_text_mode(tools)
453
+ if tool_hint:
454
+ full_prompt = f"{full_prompt}\n\n{tool_hint}"
455
+ return full_prompt
456
+
457
+
458
+ def log_openai_messages(normalized_messages: List[Dict[str, Any]]) -> None:
459
+ """Trace normalized messages for OpenAI calls to simplify debugging."""
460
+ summary_parts = []
461
+ for idx, message in enumerate(normalized_messages):
462
+ role = message.get("role")
463
+ tool_calls = message.get("tool_calls")
464
+ tool_call_id = message.get("tool_call_id")
465
+ ids = [tc.get("id") for tc in tool_calls] if tool_calls else []
466
+ summary_parts.append(
467
+ f"{idx}:{role}"
468
+ + (f" tool_calls={ids}" if ids else "")
469
+ + (f" tool_call_id={tool_call_id}" if tool_call_id else "")
470
+ )
471
+ logger.debug(f"[query_llm] OpenAI normalized messages: {' | '.join(summary_parts)}")
472
+
473
+
474
+ async def build_anthropic_tool_schemas(tools: List[Tool[Any, Any]]) -> List[Dict[str, Any]]:
475
+ """Render tool schemas in Anthropic format."""
476
+ schemas = []
477
+ for tool in tools:
478
+ description = await build_tool_description(tool, include_examples=True, max_examples=2)
479
+ schema: Dict[str, Any] = {
480
+ "name": tool.name,
481
+ "description": description,
482
+ "input_schema": tool.input_schema.model_json_schema(),
483
+ "defer_loading": bool(getattr(tool, "defer_loading", lambda: False)()),
484
+ }
485
+ examples = tool_input_examples(tool, limit=5)
486
+ if examples:
487
+ schema["input_examples"] = examples
488
+ schemas.append(schema)
489
+ return schemas
490
+
491
+
492
+ async def build_openai_tool_schemas(tools: List[Tool[Any, Any]]) -> List[Dict[str, Any]]:
493
+ """Render tool schemas in OpenAI function-calling format."""
494
+ openai_tools = []
495
+ for tool in tools:
496
+ description = await build_tool_description(tool, include_examples=True, max_examples=2)
497
+ openai_tools.append(
498
+ {
499
+ "type": "function",
500
+ "function": {
501
+ "name": tool.name,
502
+ "description": description,
503
+ "parameters": tool.input_schema.model_json_schema(),
504
+ },
505
+ }
506
+ )
507
+ return openai_tools
508
+
509
+
510
+ def content_blocks_from_anthropic_response(response: Any, tool_mode: str) -> List[Dict[str, Any]]:
511
+ """Normalize Anthropic response content to our internal block format."""
512
+ blocks: List[Dict[str, Any]] = []
513
+ for block in getattr(response, "content", []) or []:
514
+ btype = getattr(block, "type", None)
515
+ if btype == "text":
516
+ blocks.append({"type": "text", "text": getattr(block, "text", "")})
517
+ elif btype == "thinking":
518
+ blocks.append(
519
+ {
520
+ "type": "thinking",
521
+ "thinking": getattr(block, "thinking", None) or "",
522
+ "signature": getattr(block, "signature", None),
523
+ }
524
+ )
525
+ elif btype == "redacted_thinking":
526
+ # Preserve encrypted payload for replay even if we don't display it.
527
+ blocks.append(
528
+ {
529
+ "type": "redacted_thinking",
530
+ "data": getattr(block, "data", None),
531
+ "signature": getattr(block, "signature", None),
532
+ }
533
+ )
534
+ elif btype == "tool_use":
535
+ raw_input = getattr(block, "input", {}) or {}
536
+ blocks.append(
537
+ {
538
+ "type": "tool_use",
539
+ "tool_use_id": getattr(block, "id", None) or str(uuid4()),
540
+ "name": getattr(block, "name", None),
541
+ "input": _normalize_tool_args(raw_input),
542
+ }
543
+ )
544
+
545
+ if tool_mode == "text":
546
+ blocks = _maybe_convert_json_block_to_tool_use(blocks)
547
+ return blocks
548
+
549
+
550
+ def content_blocks_from_openai_choice(choice: Any, tool_mode: str) -> List[Dict[str, Any]]:
551
+ """Normalize OpenAI-compatible choice to our internal block format."""
552
+ content_blocks = []
553
+ if getattr(choice.message, "content", None):
554
+ content_blocks.append({"type": "text", "text": choice.message.content})
555
+
556
+ if getattr(choice.message, "tool_calls", None):
557
+ for tool_call in choice.message.tool_calls:
558
+ raw_args = getattr(tool_call.function, "arguments", None)
559
+ parsed_args = safe_parse_json(raw_args)
560
+ if parsed_args is None and raw_args:
561
+ arg_preview = str(raw_args)
562
+ arg_preview = arg_preview[:200] if len(arg_preview) > 200 else arg_preview
563
+ logger.debug(
564
+ "[query_llm] Failed to parse tool arguments; falling back to empty dict",
565
+ extra={
566
+ "tool_call_id": getattr(tool_call, "id", None),
567
+ "tool_name": getattr(tool_call.function, "name", None),
568
+ "arguments_preview": arg_preview,
569
+ },
570
+ )
571
+ parsed_args = _normalize_tool_args(parsed_args if parsed_args is not None else raw_args)
572
+ content_blocks.append(
573
+ {
574
+ "type": "tool_use",
575
+ "tool_use_id": tool_call.id,
576
+ "name": tool_call.function.name,
577
+ "input": parsed_args,
578
+ }
579
+ )
580
+ elif tool_mode == "text":
581
+ content_blocks = _maybe_convert_json_block_to_tool_use(content_blocks)
582
+ return content_blocks
583
+
584
+
585
+ def extract_tool_use_blocks(
586
+ assistant_message: AssistantMessage,
587
+ ) -> List[MessageContent]:
588
+ """Return all tool_use blocks from an assistant message."""
589
+ content = getattr(assistant_message.message, "content", None)
590
+ if not isinstance(content, list):
591
+ return []
592
+
593
+ tool_blocks: List[MessageContent] = []
594
+ for block in content:
595
+ normalized = MessageContent(**block) if isinstance(block, dict) else block
596
+ if getattr(normalized, "type", None) == "tool_use":
597
+ tool_blocks.append(normalized)
598
+ return tool_blocks
599
+
600
+
601
+ def tool_result_message(
602
+ tool_use_id: str, text: str, is_error: bool = False, tool_use_result: Any = None
603
+ ) -> UserMessage:
604
+ """Build a user message representing a tool_result block."""
605
+ block: Dict[str, Any] = {"type": "tool_result", "tool_use_id": tool_use_id, "text": text}
606
+ if is_error:
607
+ block["is_error"] = True
608
+ return create_user_message([block], tool_use_result=tool_use_result)
609
+
610
+
611
+ def format_pydantic_errors(error: ValidationError) -> str:
612
+ """Render a compact validation error summary."""
613
+ details = []
614
+ for err in error.errors():
615
+ loc: list[Any] = list(err.get("loc") or [])
616
+ loc_str = ".".join(str(part) for part in loc) if loc else ""
617
+ msg = err.get("msg") or ""
618
+ if loc_str and msg:
619
+ details.append(f"{loc_str}: {msg}")
620
+ elif msg:
621
+ details.append(msg)
622
+ return "; ".join(details) or str(error)