ripperdoc 0.2.0__py3-none-any.whl → 0.2.2__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.
- ripperdoc/__init__.py +1 -1
- ripperdoc/cli/cli.py +66 -8
- ripperdoc/cli/commands/__init__.py +4 -0
- ripperdoc/cli/commands/agents_cmd.py +22 -0
- ripperdoc/cli/commands/context_cmd.py +11 -1
- ripperdoc/cli/commands/doctor_cmd.py +200 -0
- ripperdoc/cli/commands/memory_cmd.py +209 -0
- ripperdoc/cli/commands/models_cmd.py +25 -0
- ripperdoc/cli/commands/tasks_cmd.py +27 -0
- ripperdoc/cli/ui/rich_ui.py +156 -9
- ripperdoc/core/agents.py +4 -2
- ripperdoc/core/config.py +48 -3
- ripperdoc/core/default_tools.py +16 -2
- ripperdoc/core/permissions.py +19 -0
- ripperdoc/core/query.py +231 -297
- ripperdoc/core/query_utils.py +537 -0
- ripperdoc/core/system_prompt.py +2 -1
- ripperdoc/core/tool.py +13 -0
- ripperdoc/tools/background_shell.py +9 -3
- ripperdoc/tools/bash_tool.py +15 -0
- ripperdoc/tools/file_edit_tool.py +7 -0
- ripperdoc/tools/file_read_tool.py +7 -0
- ripperdoc/tools/file_write_tool.py +7 -0
- ripperdoc/tools/glob_tool.py +55 -15
- ripperdoc/tools/grep_tool.py +7 -0
- ripperdoc/tools/ls_tool.py +242 -73
- ripperdoc/tools/mcp_tools.py +32 -10
- ripperdoc/tools/multi_edit_tool.py +11 -0
- ripperdoc/tools/notebook_edit_tool.py +6 -3
- ripperdoc/tools/task_tool.py +7 -0
- ripperdoc/tools/todo_tool.py +159 -25
- ripperdoc/tools/tool_search_tool.py +9 -0
- ripperdoc/utils/git_utils.py +276 -0
- ripperdoc/utils/json_utils.py +28 -0
- ripperdoc/utils/log.py +130 -29
- ripperdoc/utils/mcp.py +71 -6
- ripperdoc/utils/memory.py +14 -1
- ripperdoc/utils/message_compaction.py +26 -5
- ripperdoc/utils/messages.py +63 -4
- ripperdoc/utils/output_utils.py +36 -9
- ripperdoc/utils/permissions/path_validation_utils.py +6 -0
- ripperdoc/utils/safe_get_cwd.py +4 -0
- ripperdoc/utils/session_history.py +27 -9
- ripperdoc/utils/todo.py +2 -2
- {ripperdoc-0.2.0.dist-info → ripperdoc-0.2.2.dist-info}/METADATA +4 -2
- ripperdoc-0.2.2.dist-info/RECORD +86 -0
- ripperdoc-0.2.0.dist-info/RECORD +0 -81
- {ripperdoc-0.2.0.dist-info → ripperdoc-0.2.2.dist-info}/WHEEL +0 -0
- {ripperdoc-0.2.0.dist-info → ripperdoc-0.2.2.dist-info}/entry_points.txt +0 -0
- {ripperdoc-0.2.0.dist-info → ripperdoc-0.2.2.dist-info}/licenses/LICENSE +0 -0
- {ripperdoc-0.2.0.dist-info → ripperdoc-0.2.2.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,537 @@
|
|
|
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, Optional, Union
|
|
8
|
+
from uuid import uuid4
|
|
9
|
+
|
|
10
|
+
from json_repair import repair_json
|
|
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: Any) -> int:
|
|
30
|
+
"""Best-effort int conversion for usage counters."""
|
|
31
|
+
try:
|
|
32
|
+
if value is None:
|
|
33
|
+
return 0
|
|
34
|
+
return int(value)
|
|
35
|
+
except (TypeError, ValueError):
|
|
36
|
+
return 0
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _get_usage_field(usage: Any, field: str) -> int:
|
|
40
|
+
"""Fetch a usage field from either a dict or object."""
|
|
41
|
+
if usage is None:
|
|
42
|
+
return 0
|
|
43
|
+
if isinstance(usage, dict):
|
|
44
|
+
return _safe_int(usage.get(field))
|
|
45
|
+
return _safe_int(getattr(usage, field, 0))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def anthropic_usage_tokens(usage: Any) -> Dict[str, int]:
|
|
49
|
+
"""Extract token counts from an Anthropic response usage payload."""
|
|
50
|
+
return {
|
|
51
|
+
"input_tokens": _get_usage_field(usage, "input_tokens"),
|
|
52
|
+
"output_tokens": _get_usage_field(usage, "output_tokens"),
|
|
53
|
+
"cache_read_input_tokens": _get_usage_field(usage, "cache_read_input_tokens"),
|
|
54
|
+
"cache_creation_input_tokens": _get_usage_field(usage, "cache_creation_input_tokens"),
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def openai_usage_tokens(usage: Any) -> Dict[str, int]:
|
|
59
|
+
"""Extract token counts from an OpenAI-compatible response usage payload."""
|
|
60
|
+
prompt_details = None
|
|
61
|
+
if isinstance(usage, dict):
|
|
62
|
+
prompt_details = usage.get("prompt_tokens_details")
|
|
63
|
+
else:
|
|
64
|
+
prompt_details = getattr(usage, "prompt_tokens_details", None)
|
|
65
|
+
|
|
66
|
+
cache_read_tokens = _get_usage_field(prompt_details, "cached_tokens") if prompt_details else 0
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
"input_tokens": _get_usage_field(usage, "prompt_tokens"),
|
|
70
|
+
"output_tokens": _get_usage_field(usage, "completion_tokens"),
|
|
71
|
+
"cache_read_input_tokens": cache_read_tokens,
|
|
72
|
+
"cache_creation_input_tokens": 0,
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def resolve_model_profile(model: str) -> ModelProfile:
|
|
77
|
+
"""Resolve a model pointer to a concrete profile or raise if missing."""
|
|
78
|
+
config = get_global_config()
|
|
79
|
+
profile_name = getattr(config.model_pointers, model, None) or model
|
|
80
|
+
model_profile = config.model_profiles.get(profile_name)
|
|
81
|
+
if model_profile is None:
|
|
82
|
+
fallback_profile = getattr(config.model_pointers, "main", "default")
|
|
83
|
+
model_profile = config.model_profiles.get(fallback_profile)
|
|
84
|
+
if not model_profile:
|
|
85
|
+
raise ValueError(f"No model profile found for pointer: {model}")
|
|
86
|
+
return model_profile
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def determine_tool_mode(model_profile: ModelProfile) -> str:
|
|
90
|
+
"""Return configured tool mode for provider."""
|
|
91
|
+
if model_profile.provider != ProviderType.OPENAI_COMPATIBLE:
|
|
92
|
+
return "native"
|
|
93
|
+
configured = getattr(model_profile, "openai_tool_mode", "native") or "native"
|
|
94
|
+
configured = configured.lower()
|
|
95
|
+
if configured not in {"native", "text"}:
|
|
96
|
+
configured = getattr(get_global_config(), "openai_tool_mode", "native") or "native"
|
|
97
|
+
configured = configured.lower()
|
|
98
|
+
return configured if configured in {"native", "text"} else "native"
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _parse_text_mode_json_blocks(text: str) -> Optional[List[Dict[str, Any]]]:
|
|
102
|
+
"""Parse a JSON code block or raw JSON string into content blocks for text mode."""
|
|
103
|
+
if not text or not isinstance(text, str):
|
|
104
|
+
return None
|
|
105
|
+
|
|
106
|
+
code_blocks = re.findall(
|
|
107
|
+
r"```(?:\s*json)?\s*([\s\S]*?)\s*```", text, flags=re.IGNORECASE
|
|
108
|
+
)
|
|
109
|
+
candidates = [blk.strip() for blk in code_blocks if blk.strip()]
|
|
110
|
+
|
|
111
|
+
def _normalize_blocks(parsed: Any) -> Optional[List[Dict[str, Any]]]:
|
|
112
|
+
raw_blocks = parsed if isinstance(parsed, list) else [parsed]
|
|
113
|
+
normalized: List[Dict[str, Any]] = []
|
|
114
|
+
for raw in raw_blocks:
|
|
115
|
+
if not isinstance(raw, dict):
|
|
116
|
+
continue
|
|
117
|
+
block_type = raw.get("type")
|
|
118
|
+
if block_type == "text":
|
|
119
|
+
text_value = raw.get("text") or raw.get("content")
|
|
120
|
+
if isinstance(text_value, str) and text_value:
|
|
121
|
+
normalized.append({"type": "text", "text": text_value})
|
|
122
|
+
elif block_type == "tool_use":
|
|
123
|
+
tool_name = raw.get("tool") or raw.get("name")
|
|
124
|
+
if not isinstance(tool_name, str) or not tool_name:
|
|
125
|
+
continue
|
|
126
|
+
tool_use_id = raw.get("tool_use_id") or raw.get("id") or str(uuid4())
|
|
127
|
+
input_value = raw.get("input") or {}
|
|
128
|
+
if not isinstance(input_value, dict):
|
|
129
|
+
input_value = _normalize_tool_args(input_value)
|
|
130
|
+
normalized.append(
|
|
131
|
+
{
|
|
132
|
+
"type": "tool_use",
|
|
133
|
+
"tool_use_id": str(tool_use_id),
|
|
134
|
+
"name": tool_name,
|
|
135
|
+
"input": input_value,
|
|
136
|
+
}
|
|
137
|
+
)
|
|
138
|
+
return normalized if normalized else None
|
|
139
|
+
|
|
140
|
+
last_error: Optional[str] = None
|
|
141
|
+
|
|
142
|
+
for candidate in candidates:
|
|
143
|
+
if not candidate:
|
|
144
|
+
continue
|
|
145
|
+
|
|
146
|
+
parsed: Any = None
|
|
147
|
+
try:
|
|
148
|
+
parsed = json.loads(candidate)
|
|
149
|
+
except json.JSONDecodeError as exc:
|
|
150
|
+
last_error = str(exc)
|
|
151
|
+
parsed = repair_json(candidate, return_objects=True, ensure_ascii=False)
|
|
152
|
+
|
|
153
|
+
if parsed is None or parsed == "":
|
|
154
|
+
continue
|
|
155
|
+
|
|
156
|
+
normalized = _normalize_blocks(parsed)
|
|
157
|
+
if normalized:
|
|
158
|
+
return normalized
|
|
159
|
+
|
|
160
|
+
last_error = "Parsed JSON did not contain valid content blocks."
|
|
161
|
+
|
|
162
|
+
if last_error:
|
|
163
|
+
error_text = (
|
|
164
|
+
f"JSON parsing failed: {last_error} "
|
|
165
|
+
"Please resend a valid JSON array of content blocks inside a ```json``` code block."
|
|
166
|
+
)
|
|
167
|
+
return [{"type": "text", "text": error_text}]
|
|
168
|
+
|
|
169
|
+
return None
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _tool_prompt_for_text_mode(tools: List[Tool[Any, Any]]) -> str:
|
|
173
|
+
"""Build a system hint describing available tools and the expected JSON format."""
|
|
174
|
+
if not tools:
|
|
175
|
+
return ""
|
|
176
|
+
|
|
177
|
+
lines = [
|
|
178
|
+
"You are in text-only tool mode. Tools are not auto-invoked by the API.",
|
|
179
|
+
"Respond with one Markdown `json` code block containing a JSON array of content blocks.",
|
|
180
|
+
'Each block must include `type`; use {"type": "text", "text": "<message>"} for text and '
|
|
181
|
+
'{"type": "tool_use", "tool_use_id": "<tool_id>", "tool": "<tool_name>", "input": { ... required params ... }} '
|
|
182
|
+
"for tool calls. Add multiple `tool_use` blocks if you need multiple tools.",
|
|
183
|
+
"Include your natural language reply as a `text` block, followed by any `tool_use` blocks.",
|
|
184
|
+
"Only include the JSON array inside the code block - no extra prose.",
|
|
185
|
+
"Available tools:",
|
|
186
|
+
]
|
|
187
|
+
|
|
188
|
+
for tool in tools:
|
|
189
|
+
required_fields: List[str] = []
|
|
190
|
+
try:
|
|
191
|
+
for fname, finfo in getattr(tool.input_schema, "model_fields", {}).items():
|
|
192
|
+
is_req = False
|
|
193
|
+
if hasattr(finfo, "is_required"):
|
|
194
|
+
try:
|
|
195
|
+
is_req = bool(finfo.is_required())
|
|
196
|
+
except Exception:
|
|
197
|
+
is_req = False
|
|
198
|
+
required_fields.append(f"{fname}{' (required)' if is_req else ''}")
|
|
199
|
+
except Exception:
|
|
200
|
+
required_fields = []
|
|
201
|
+
|
|
202
|
+
required_str = ", ".join(required_fields) if required_fields else "see input schema"
|
|
203
|
+
lines.append(f"- {tool.name}: fields {required_str}")
|
|
204
|
+
|
|
205
|
+
schema_json = ""
|
|
206
|
+
try:
|
|
207
|
+
schema_json = json.dumps(tool.input_schema.model_json_schema(), ensure_ascii=False, indent=2)
|
|
208
|
+
except (AttributeError, TypeError, ValueError) as exc:
|
|
209
|
+
logger.debug(
|
|
210
|
+
"[tool_prompt] Failed to render input_schema",
|
|
211
|
+
extra={"tool": getattr(tool, "name", None), "error": str(exc)},
|
|
212
|
+
)
|
|
213
|
+
if schema_json:
|
|
214
|
+
lines.append(" input schema (JSON):")
|
|
215
|
+
lines.append(" ```json")
|
|
216
|
+
lines.append(f" {schema_json}")
|
|
217
|
+
lines.append(" ```")
|
|
218
|
+
|
|
219
|
+
example_blocks = [
|
|
220
|
+
{"type": "text", "text": "好的,我来帮你查看一下README.md文件"},
|
|
221
|
+
{"type": "tool_use", "tool_use_id": "tool_id_000001", "tool": "View", "input": {"file_path": "README.md"}},
|
|
222
|
+
]
|
|
223
|
+
lines.append("Example:")
|
|
224
|
+
lines.append("```json")
|
|
225
|
+
lines.append(json.dumps(example_blocks, ensure_ascii=False, indent=2))
|
|
226
|
+
lines.append("```")
|
|
227
|
+
|
|
228
|
+
return "\n".join(lines)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def text_mode_history(messages: List[Union[UserMessage, AssistantMessage, ProgressMessage]]) -> List[Union[UserMessage, AssistantMessage]]:
|
|
232
|
+
"""Convert a message history into text-only form for text mode."""
|
|
233
|
+
|
|
234
|
+
def _normalize_block(block: Any) -> Optional[Dict[str, Any]]:
|
|
235
|
+
blk = MessageContent(**block) if isinstance(block, dict) else block
|
|
236
|
+
btype = getattr(blk, "type", None)
|
|
237
|
+
if btype == "text":
|
|
238
|
+
text_val = getattr(blk, "text", None) or getattr(blk, "content", None) or ""
|
|
239
|
+
return {"type": "text", "text": text_val}
|
|
240
|
+
if btype == "tool_use":
|
|
241
|
+
return {
|
|
242
|
+
"type": "tool_use",
|
|
243
|
+
"tool_use_id": getattr(blk, "tool_use_id", None) or getattr(blk, "id", None) or "",
|
|
244
|
+
"tool": getattr(blk, "name", None) or "",
|
|
245
|
+
"input": getattr(blk, "input", None) or {},
|
|
246
|
+
}
|
|
247
|
+
if btype == "tool_result":
|
|
248
|
+
result_block: Dict[str, Any] = {
|
|
249
|
+
"type": "tool_result",
|
|
250
|
+
"tool_use_id": getattr(blk, "tool_use_id", None) or getattr(blk, "id", None) or "",
|
|
251
|
+
"text": getattr(blk, "text", None) or getattr(blk, "content", None) or "",
|
|
252
|
+
}
|
|
253
|
+
is_error = getattr(blk, "is_error", None)
|
|
254
|
+
if is_error is not None:
|
|
255
|
+
result_block["is_error"] = is_error
|
|
256
|
+
return result_block
|
|
257
|
+
text_val = getattr(blk, "text", None) or getattr(blk, "content", None)
|
|
258
|
+
if text_val is not None:
|
|
259
|
+
return {"type": "text", "text": text_val}
|
|
260
|
+
return None
|
|
261
|
+
|
|
262
|
+
converted: List[Union[UserMessage, AssistantMessage]] = []
|
|
263
|
+
for msg in messages:
|
|
264
|
+
msg_type = getattr(msg, "type", None)
|
|
265
|
+
if msg_type == "progress" or msg_type is None:
|
|
266
|
+
continue
|
|
267
|
+
content = getattr(getattr(msg, "message", None), "content", None)
|
|
268
|
+
text_content: Optional[str] = None
|
|
269
|
+
if isinstance(content, list):
|
|
270
|
+
normalized_blocks = []
|
|
271
|
+
for block in content:
|
|
272
|
+
block_type = getattr(block, "type", None) or (block.get("type") if isinstance(block, dict) else None)
|
|
273
|
+
block_text = getattr(block, "text", None) if hasattr(block, "text") else (
|
|
274
|
+
block.get("text") if isinstance(block, dict) else None
|
|
275
|
+
)
|
|
276
|
+
if block_type == "text" and isinstance(block_text, str):
|
|
277
|
+
parsed_nested = _parse_text_mode_json_blocks(block_text)
|
|
278
|
+
if parsed_nested:
|
|
279
|
+
normalized_blocks.extend(parsed_nested)
|
|
280
|
+
continue
|
|
281
|
+
norm = _normalize_block(block)
|
|
282
|
+
if norm:
|
|
283
|
+
normalized_blocks.append(norm)
|
|
284
|
+
if normalized_blocks:
|
|
285
|
+
json_payload = json.dumps(normalized_blocks, ensure_ascii=False, indent=2)
|
|
286
|
+
text_content = f"```json\n{json_payload}\n```"
|
|
287
|
+
elif isinstance(content, str):
|
|
288
|
+
parsed_blocks = _parse_text_mode_json_blocks(content)
|
|
289
|
+
if parsed_blocks:
|
|
290
|
+
text_content = f"```json\n{json.dumps(parsed_blocks, ensure_ascii=False, indent=2)}\n```"
|
|
291
|
+
else:
|
|
292
|
+
text_content = content
|
|
293
|
+
else:
|
|
294
|
+
text_content = content if isinstance(content, str) else None
|
|
295
|
+
if not text_content:
|
|
296
|
+
continue
|
|
297
|
+
if msg_type == "user":
|
|
298
|
+
converted.append(create_user_message(text_content))
|
|
299
|
+
elif msg_type == "assistant":
|
|
300
|
+
converted.append(create_assistant_message(text_content))
|
|
301
|
+
return converted
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _maybe_convert_json_block_to_tool_use(content_blocks: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
305
|
+
"""Convert any text blocks containing JSON content to structured content blocks."""
|
|
306
|
+
if not content_blocks:
|
|
307
|
+
return content_blocks
|
|
308
|
+
|
|
309
|
+
new_blocks: List[Dict[str, Any]] = []
|
|
310
|
+
converted_count = 0
|
|
311
|
+
|
|
312
|
+
for block in content_blocks:
|
|
313
|
+
if block.get("type") != "text":
|
|
314
|
+
new_blocks.append(block)
|
|
315
|
+
continue
|
|
316
|
+
|
|
317
|
+
text = block.get("text")
|
|
318
|
+
if not isinstance(text, str):
|
|
319
|
+
new_blocks.append(block)
|
|
320
|
+
continue
|
|
321
|
+
|
|
322
|
+
parsed_blocks = _parse_text_mode_json_blocks(text)
|
|
323
|
+
if not parsed_blocks:
|
|
324
|
+
new_blocks.append(block)
|
|
325
|
+
continue
|
|
326
|
+
|
|
327
|
+
for parsed in parsed_blocks:
|
|
328
|
+
if parsed.get("type") == "tool_use":
|
|
329
|
+
new_blocks.append(
|
|
330
|
+
{
|
|
331
|
+
"type": "tool_use",
|
|
332
|
+
"tool_use_id": parsed.get("tool_use_id") or str(uuid4()),
|
|
333
|
+
"name": parsed.get("name") or parsed.get("tool"),
|
|
334
|
+
"input": parsed.get("input") or {},
|
|
335
|
+
}
|
|
336
|
+
)
|
|
337
|
+
elif parsed.get("type") == "text":
|
|
338
|
+
new_blocks.append({"type": "text", "text": parsed.get("text") or ""})
|
|
339
|
+
converted_count += 1
|
|
340
|
+
|
|
341
|
+
if converted_count:
|
|
342
|
+
logger.debug(
|
|
343
|
+
"[query_llm] Converting JSON code block to structured content blocks",
|
|
344
|
+
extra={"block_count": len(new_blocks)},
|
|
345
|
+
)
|
|
346
|
+
return new_blocks
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _normalize_tool_args(raw_args: Any) -> Dict[str, Any]:
|
|
350
|
+
"""Ensure tool arguments are returned as a dict, handling double-encoded strings."""
|
|
351
|
+
candidate = raw_args
|
|
352
|
+
|
|
353
|
+
for _ in range(2):
|
|
354
|
+
if isinstance(candidate, dict):
|
|
355
|
+
return candidate
|
|
356
|
+
if isinstance(candidate, str):
|
|
357
|
+
candidate = safe_parse_json(candidate, log_error=False)
|
|
358
|
+
continue
|
|
359
|
+
break
|
|
360
|
+
|
|
361
|
+
if isinstance(candidate, dict):
|
|
362
|
+
return candidate
|
|
363
|
+
|
|
364
|
+
preview = str(raw_args)
|
|
365
|
+
preview = preview[:200] if len(preview) > 200 else preview
|
|
366
|
+
logger.debug(
|
|
367
|
+
"[query_llm] Tool arguments not a dict; defaulting to empty object",
|
|
368
|
+
extra={"preview": preview},
|
|
369
|
+
)
|
|
370
|
+
return {}
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def build_full_system_prompt(
|
|
374
|
+
system_prompt: str, context: Dict[str, str], tool_mode: str, tools: List[Tool[Any, Any]]
|
|
375
|
+
) -> str:
|
|
376
|
+
"""Compose the final system prompt including context and tool hints."""
|
|
377
|
+
full_prompt = system_prompt
|
|
378
|
+
if context:
|
|
379
|
+
context_str = "\n".join(f"{k}: {v}" for k, v in context.items())
|
|
380
|
+
full_prompt = f"{system_prompt}\n\nContext:\n{context_str}"
|
|
381
|
+
if tool_mode == "text":
|
|
382
|
+
tool_hint = _tool_prompt_for_text_mode(tools)
|
|
383
|
+
if tool_hint:
|
|
384
|
+
full_prompt = f"{full_prompt}\n\n{tool_hint}"
|
|
385
|
+
return full_prompt
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def log_openai_messages(normalized_messages: List[Dict[str, Any]]) -> None:
|
|
389
|
+
"""Trace normalized messages for OpenAI calls to simplify debugging."""
|
|
390
|
+
summary_parts = []
|
|
391
|
+
for idx, message in enumerate(normalized_messages):
|
|
392
|
+
role = message.get("role")
|
|
393
|
+
tool_calls = message.get("tool_calls")
|
|
394
|
+
tool_call_id = message.get("tool_call_id")
|
|
395
|
+
ids = [tc.get("id") for tc in tool_calls] if tool_calls else []
|
|
396
|
+
summary_parts.append(
|
|
397
|
+
f"{idx}:{role}"
|
|
398
|
+
+ (f" tool_calls={ids}" if ids else "")
|
|
399
|
+
+ (f" tool_call_id={tool_call_id}" if tool_call_id else "")
|
|
400
|
+
)
|
|
401
|
+
logger.debug(f"[query_llm] OpenAI normalized messages: {' | '.join(summary_parts)}")
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
async def build_anthropic_tool_schemas(tools: List[Tool[Any, Any]]) -> List[Dict[str, Any]]:
|
|
405
|
+
"""Render tool schemas in Anthropic format."""
|
|
406
|
+
schemas = []
|
|
407
|
+
for tool in tools:
|
|
408
|
+
description = await build_tool_description(tool, include_examples=True, max_examples=2)
|
|
409
|
+
schema: Dict[str, Any] = {
|
|
410
|
+
"name": tool.name,
|
|
411
|
+
"description": description,
|
|
412
|
+
"input_schema": tool.input_schema.model_json_schema(),
|
|
413
|
+
"defer_loading": bool(getattr(tool, "defer_loading", lambda: False)()),
|
|
414
|
+
}
|
|
415
|
+
examples = tool_input_examples(tool, limit=5)
|
|
416
|
+
if examples:
|
|
417
|
+
schema["input_examples"] = examples
|
|
418
|
+
schemas.append(schema)
|
|
419
|
+
return schemas
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
async def build_openai_tool_schemas(tools: List[Tool[Any, Any]]) -> List[Dict[str, Any]]:
|
|
423
|
+
"""Render tool schemas in OpenAI function-calling format."""
|
|
424
|
+
openai_tools = []
|
|
425
|
+
for tool in tools:
|
|
426
|
+
description = await build_tool_description(tool, include_examples=True, max_examples=2)
|
|
427
|
+
openai_tools.append(
|
|
428
|
+
{
|
|
429
|
+
"type": "function",
|
|
430
|
+
"function": {
|
|
431
|
+
"name": tool.name,
|
|
432
|
+
"description": description,
|
|
433
|
+
"parameters": tool.input_schema.model_json_schema(),
|
|
434
|
+
},
|
|
435
|
+
}
|
|
436
|
+
)
|
|
437
|
+
return openai_tools
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def content_blocks_from_anthropic_response(
|
|
441
|
+
response: Any, tool_mode: str
|
|
442
|
+
) -> List[Dict[str, Any]]:
|
|
443
|
+
"""Normalize Anthropic response content to our internal block format."""
|
|
444
|
+
blocks: List[Dict[str, Any]] = []
|
|
445
|
+
for block in getattr(response, "content", []) or []:
|
|
446
|
+
btype = getattr(block, "type", None)
|
|
447
|
+
if btype == "text":
|
|
448
|
+
blocks.append({"type": "text", "text": getattr(block, "text", "")})
|
|
449
|
+
elif btype == "tool_use":
|
|
450
|
+
raw_input = getattr(block, "input", {}) or {}
|
|
451
|
+
blocks.append(
|
|
452
|
+
{
|
|
453
|
+
"type": "tool_use",
|
|
454
|
+
"tool_use_id": getattr(block, "id", None) or str(uuid4()),
|
|
455
|
+
"name": getattr(block, "name", None),
|
|
456
|
+
"input": _normalize_tool_args(raw_input),
|
|
457
|
+
}
|
|
458
|
+
)
|
|
459
|
+
|
|
460
|
+
if tool_mode == "text":
|
|
461
|
+
blocks = _maybe_convert_json_block_to_tool_use(blocks)
|
|
462
|
+
return blocks
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def content_blocks_from_openai_choice(choice: Any, tool_mode: str) -> List[Dict[str, Any]]:
|
|
466
|
+
"""Normalize OpenAI-compatible choice to our internal block format."""
|
|
467
|
+
content_blocks = []
|
|
468
|
+
if getattr(choice.message, "content", None):
|
|
469
|
+
content_blocks.append({"type": "text", "text": choice.message.content})
|
|
470
|
+
|
|
471
|
+
if getattr(choice.message, "tool_calls", None):
|
|
472
|
+
for tool_call in choice.message.tool_calls:
|
|
473
|
+
raw_args = getattr(tool_call.function, "arguments", None)
|
|
474
|
+
parsed_args = safe_parse_json(raw_args)
|
|
475
|
+
if parsed_args is None and raw_args:
|
|
476
|
+
arg_preview = str(raw_args)
|
|
477
|
+
arg_preview = arg_preview[:200] if len(arg_preview) > 200 else arg_preview
|
|
478
|
+
logger.debug(
|
|
479
|
+
"[query_llm] Failed to parse tool arguments; falling back to empty dict",
|
|
480
|
+
extra={
|
|
481
|
+
"tool_call_id": getattr(tool_call, "id", None),
|
|
482
|
+
"tool_name": getattr(tool_call.function, "name", None),
|
|
483
|
+
"arguments_preview": arg_preview,
|
|
484
|
+
},
|
|
485
|
+
)
|
|
486
|
+
parsed_args = _normalize_tool_args(parsed_args if parsed_args is not None else raw_args)
|
|
487
|
+
content_blocks.append(
|
|
488
|
+
{
|
|
489
|
+
"type": "tool_use",
|
|
490
|
+
"tool_use_id": tool_call.id,
|
|
491
|
+
"name": tool_call.function.name,
|
|
492
|
+
"input": parsed_args,
|
|
493
|
+
}
|
|
494
|
+
)
|
|
495
|
+
elif tool_mode == "text":
|
|
496
|
+
content_blocks = _maybe_convert_json_block_to_tool_use(content_blocks)
|
|
497
|
+
return content_blocks
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def extract_tool_use_blocks(
|
|
501
|
+
assistant_message: AssistantMessage,
|
|
502
|
+
) -> List[MessageContent]:
|
|
503
|
+
"""Return all tool_use blocks from an assistant message."""
|
|
504
|
+
content = getattr(assistant_message.message, "content", None)
|
|
505
|
+
if not isinstance(content, list):
|
|
506
|
+
return []
|
|
507
|
+
|
|
508
|
+
tool_blocks: List[MessageContent] = []
|
|
509
|
+
for block in content:
|
|
510
|
+
normalized = MessageContent(**block) if isinstance(block, dict) else block
|
|
511
|
+
if getattr(normalized, "type", None) == "tool_use":
|
|
512
|
+
tool_blocks.append(normalized)
|
|
513
|
+
return tool_blocks
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def tool_result_message(
|
|
517
|
+
tool_use_id: str, text: str, is_error: bool = False, tool_use_result: Any = None
|
|
518
|
+
) -> UserMessage:
|
|
519
|
+
"""Build a user message representing a tool_result block."""
|
|
520
|
+
block: Dict[str, Any] = {"type": "tool_result", "tool_use_id": tool_use_id, "text": text}
|
|
521
|
+
if is_error:
|
|
522
|
+
block["is_error"] = True
|
|
523
|
+
return create_user_message([block], tool_use_result=tool_use_result)
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def format_pydantic_errors(error: ValidationError) -> str:
|
|
527
|
+
"""Render a compact validation error summary."""
|
|
528
|
+
details = []
|
|
529
|
+
for err in error.errors():
|
|
530
|
+
loc: list[Any] = list(err.get("loc") or [])
|
|
531
|
+
loc_str = ".".join(str(part) for part in loc) if loc else ""
|
|
532
|
+
msg = err.get("msg") or ""
|
|
533
|
+
if loc_str and msg:
|
|
534
|
+
details.append(f"{loc_str}: {msg}")
|
|
535
|
+
elif msg:
|
|
536
|
+
details.append(msg)
|
|
537
|
+
return "; ".join(details) or str(error)
|
ripperdoc/core/system_prompt.py
CHANGED
|
@@ -35,6 +35,7 @@ def _detect_git_repo(cwd: Path) -> bool:
|
|
|
35
35
|
)
|
|
36
36
|
return result.returncode == 0 and result.stdout.strip().lower() == "true"
|
|
37
37
|
except Exception:
|
|
38
|
+
logger.exception("[system_prompt] Failed to detect git repository", extra={"cwd": str(cwd)})
|
|
38
39
|
return False
|
|
39
40
|
|
|
40
41
|
|
|
@@ -381,7 +382,7 @@ def build_system_prompt(
|
|
|
381
382
|
Provide detailed prompts so the agent can work autonomously and return a concise report."""
|
|
382
383
|
).strip()
|
|
383
384
|
except Exception as exc:
|
|
384
|
-
logger.
|
|
385
|
+
logger.exception("Failed to load agent definitions", extra={"error": str(exc)})
|
|
385
386
|
agent_section = (
|
|
386
387
|
"# Subagents\nTask tool available, but agent definitions could not be loaded."
|
|
387
388
|
)
|
ripperdoc/core/tool.py
CHANGED
|
@@ -8,6 +8,10 @@ import json
|
|
|
8
8
|
from abc import ABC, abstractmethod
|
|
9
9
|
from typing import Any, AsyncGenerator, Dict, List, Optional, TypeVar, Generic, Union
|
|
10
10
|
from pydantic import BaseModel, ConfigDict, Field
|
|
11
|
+
from ripperdoc.utils.log import get_logger
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
logger = get_logger()
|
|
11
15
|
|
|
12
16
|
|
|
13
17
|
class ToolResult(BaseModel):
|
|
@@ -37,6 +41,7 @@ class ToolUseContext(BaseModel):
|
|
|
37
41
|
permission_checker: Optional[Any] = None
|
|
38
42
|
read_file_timestamps: Dict[str, float] = {}
|
|
39
43
|
tool_registry: Optional[Any] = None
|
|
44
|
+
abort_signal: Optional[Any] = None
|
|
40
45
|
model_config = ConfigDict(arbitrary_types_allowed=True)
|
|
41
46
|
|
|
42
47
|
|
|
@@ -195,6 +200,10 @@ async def build_tool_description(
|
|
|
195
200
|
if parts:
|
|
196
201
|
return f"{description_text}\n\nInput examples:\n" + "\n\n".join(parts)
|
|
197
202
|
except Exception:
|
|
203
|
+
logger.exception(
|
|
204
|
+
"[tool] Failed to build input example section",
|
|
205
|
+
extra={"tool": getattr(tool, 'name', None)},
|
|
206
|
+
)
|
|
198
207
|
return description_text
|
|
199
208
|
|
|
200
209
|
return description_text
|
|
@@ -210,5 +219,9 @@ def tool_input_examples(tool: Tool[Any, Any], limit: int = 5) -> List[Dict[str,
|
|
|
210
219
|
try:
|
|
211
220
|
results.append(example.example)
|
|
212
221
|
except Exception:
|
|
222
|
+
logger.exception(
|
|
223
|
+
"[tool] Failed to format tool input example",
|
|
224
|
+
extra={"tool": getattr(tool, 'name', None)},
|
|
225
|
+
)
|
|
213
226
|
continue
|
|
214
227
|
return results
|
|
@@ -95,7 +95,10 @@ async def _pump_stream(stream: asyncio.StreamReader, sink: List[str]) -> None:
|
|
|
95
95
|
sink.append(text)
|
|
96
96
|
except Exception as exc:
|
|
97
97
|
# Best effort; ignore stream read errors to avoid leaking tasks.
|
|
98
|
-
logger.debug(
|
|
98
|
+
logger.debug(
|
|
99
|
+
f"Stream pump error for background task: {exc}",
|
|
100
|
+
exc_info=True,
|
|
101
|
+
)
|
|
99
102
|
|
|
100
103
|
|
|
101
104
|
async def _finalize_reader_tasks(reader_tasks: List[asyncio.Task], timeout: float = 1.0) -> None:
|
|
@@ -133,8 +136,11 @@ async def _monitor_task(task: BackgroundTask) -> None:
|
|
|
133
136
|
task.exit_code = -1
|
|
134
137
|
except asyncio.CancelledError:
|
|
135
138
|
return
|
|
136
|
-
except Exception
|
|
137
|
-
logger.
|
|
139
|
+
except Exception:
|
|
140
|
+
logger.exception(
|
|
141
|
+
"Error monitoring background task",
|
|
142
|
+
extra={"task_id": task.id, "command": task.command},
|
|
143
|
+
)
|
|
138
144
|
with _tasks_lock:
|
|
139
145
|
task.exit_code = -1
|
|
140
146
|
finally:
|
ripperdoc/tools/bash_tool.py
CHANGED
|
@@ -49,6 +49,9 @@ from ripperdoc.utils.permissions.tool_permission_utils import (
|
|
|
49
49
|
from ripperdoc.utils.permissions import PermissionDecision
|
|
50
50
|
from ripperdoc.utils.sandbox_utils import create_sandbox_wrapper, is_sandbox_available
|
|
51
51
|
from ripperdoc.utils.safe_get_cwd import get_original_cwd, safe_get_cwd
|
|
52
|
+
from ripperdoc.utils.log import get_logger
|
|
53
|
+
|
|
54
|
+
logger = get_logger()
|
|
52
55
|
|
|
53
56
|
|
|
54
57
|
DEFAULT_TIMEOUT_MS = get_bash_default_timeout_ms()
|
|
@@ -516,6 +519,10 @@ build projects, run tests, and interact with the file system."""
|
|
|
516
519
|
final_command = wrapper.final_command
|
|
517
520
|
sandbox_cleanup = wrapper.cleanup
|
|
518
521
|
except Exception as exc:
|
|
522
|
+
logger.exception(
|
|
523
|
+
"[bash_tool] Failed to enable sandbox",
|
|
524
|
+
extra={"command": effective_command, "error": str(exc)},
|
|
525
|
+
)
|
|
519
526
|
error_output = BashToolOutput(
|
|
520
527
|
stdout="",
|
|
521
528
|
stderr=f"Failed to enable sandbox: {exc}",
|
|
@@ -561,6 +568,10 @@ build projects, run tests, and interact with the file system."""
|
|
|
561
568
|
try:
|
|
562
569
|
from ripperdoc.tools.background_shell import start_background_command
|
|
563
570
|
except Exception as e: # pragma: no cover - defensive import
|
|
571
|
+
logger.exception(
|
|
572
|
+
"[bash_tool] Failed to import background shell runner",
|
|
573
|
+
extra={"command": effective_command},
|
|
574
|
+
)
|
|
564
575
|
error_output = BashToolOutput(
|
|
565
576
|
stdout="",
|
|
566
577
|
stderr=f"Failed to start background task: {str(e)}",
|
|
@@ -767,6 +778,10 @@ build projects, run tests, and interact with the file system."""
|
|
|
767
778
|
)
|
|
768
779
|
|
|
769
780
|
except Exception as e:
|
|
781
|
+
logger.exception(
|
|
782
|
+
"[bash_tool] Error executing command",
|
|
783
|
+
extra={"command": effective_command, "error": str(e)},
|
|
784
|
+
)
|
|
770
785
|
error_output = BashToolOutput(
|
|
771
786
|
stdout="",
|
|
772
787
|
stderr=f"Error executing command: {str(e)}",
|
|
@@ -15,6 +15,9 @@ from ripperdoc.core.tool import (
|
|
|
15
15
|
ToolUseExample,
|
|
16
16
|
ValidationResult,
|
|
17
17
|
)
|
|
18
|
+
from ripperdoc.utils.log import get_logger
|
|
19
|
+
|
|
20
|
+
logger = get_logger()
|
|
18
21
|
|
|
19
22
|
|
|
20
23
|
class FileEditToolInput(BaseModel):
|
|
@@ -268,6 +271,10 @@ match exactly (including whitespace and indentation)."""
|
|
|
268
271
|
)
|
|
269
272
|
|
|
270
273
|
except Exception as e:
|
|
274
|
+
logger.exception(
|
|
275
|
+
"[file_edit_tool] Error editing file",
|
|
276
|
+
extra={"file_path": input_data.file_path, "error": str(e)},
|
|
277
|
+
)
|
|
271
278
|
error_output = FileEditToolOutput(
|
|
272
279
|
file_path=input_data.file_path,
|
|
273
280
|
replacements_made=0,
|