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.
- ripperdoc/__init__.py +1 -1
- ripperdoc/__main__.py +0 -5
- ripperdoc/cli/cli.py +37 -16
- ripperdoc/cli/commands/__init__.py +2 -0
- ripperdoc/cli/commands/agents_cmd.py +12 -9
- ripperdoc/cli/commands/compact_cmd.py +7 -3
- ripperdoc/cli/commands/context_cmd.py +33 -13
- ripperdoc/cli/commands/doctor_cmd.py +27 -14
- ripperdoc/cli/commands/exit_cmd.py +1 -1
- ripperdoc/cli/commands/mcp_cmd.py +13 -8
- ripperdoc/cli/commands/memory_cmd.py +5 -5
- ripperdoc/cli/commands/models_cmd.py +47 -16
- ripperdoc/cli/commands/permissions_cmd.py +302 -0
- ripperdoc/cli/commands/resume_cmd.py +1 -2
- ripperdoc/cli/commands/tasks_cmd.py +24 -13
- ripperdoc/cli/ui/rich_ui.py +500 -406
- ripperdoc/cli/ui/tool_renderers.py +298 -0
- ripperdoc/core/agents.py +17 -9
- ripperdoc/core/config.py +130 -6
- ripperdoc/core/default_tools.py +7 -2
- ripperdoc/core/permissions.py +20 -14
- ripperdoc/core/providers/anthropic.py +107 -4
- ripperdoc/core/providers/base.py +33 -4
- ripperdoc/core/providers/gemini.py +169 -50
- ripperdoc/core/providers/openai.py +257 -23
- ripperdoc/core/query.py +294 -61
- ripperdoc/core/query_utils.py +50 -6
- ripperdoc/core/skills.py +295 -0
- ripperdoc/core/system_prompt.py +13 -7
- ripperdoc/core/tool.py +8 -6
- ripperdoc/sdk/client.py +14 -1
- ripperdoc/tools/ask_user_question_tool.py +20 -22
- ripperdoc/tools/background_shell.py +19 -13
- ripperdoc/tools/bash_tool.py +356 -209
- ripperdoc/tools/dynamic_mcp_tool.py +428 -0
- ripperdoc/tools/enter_plan_mode_tool.py +5 -2
- ripperdoc/tools/exit_plan_mode_tool.py +6 -3
- ripperdoc/tools/file_edit_tool.py +53 -10
- ripperdoc/tools/file_read_tool.py +17 -7
- ripperdoc/tools/file_write_tool.py +49 -13
- ripperdoc/tools/glob_tool.py +10 -9
- ripperdoc/tools/grep_tool.py +182 -51
- ripperdoc/tools/ls_tool.py +6 -6
- ripperdoc/tools/mcp_tools.py +106 -456
- ripperdoc/tools/multi_edit_tool.py +49 -9
- ripperdoc/tools/notebook_edit_tool.py +57 -13
- ripperdoc/tools/skill_tool.py +205 -0
- ripperdoc/tools/task_tool.py +7 -8
- ripperdoc/tools/todo_tool.py +12 -12
- ripperdoc/tools/tool_search_tool.py +5 -6
- ripperdoc/utils/coerce.py +34 -0
- ripperdoc/utils/context_length_errors.py +252 -0
- ripperdoc/utils/file_watch.py +5 -4
- ripperdoc/utils/json_utils.py +4 -4
- ripperdoc/utils/log.py +3 -3
- ripperdoc/utils/mcp.py +36 -15
- ripperdoc/utils/memory.py +9 -6
- ripperdoc/utils/message_compaction.py +16 -11
- ripperdoc/utils/messages.py +73 -8
- ripperdoc/utils/path_ignore.py +677 -0
- ripperdoc/utils/permissions/__init__.py +7 -1
- ripperdoc/utils/permissions/path_validation_utils.py +5 -3
- ripperdoc/utils/permissions/shell_command_validation.py +496 -18
- ripperdoc/utils/prompt.py +1 -1
- ripperdoc/utils/safe_get_cwd.py +5 -2
- ripperdoc/utils/session_history.py +38 -19
- ripperdoc/utils/todo.py +6 -2
- ripperdoc/utils/token_estimation.py +4 -3
- {ripperdoc-0.2.4.dist-info → ripperdoc-0.2.5.dist-info}/METADATA +12 -1
- ripperdoc-0.2.5.dist-info/RECORD +107 -0
- ripperdoc-0.2.4.dist-info/RECORD +0 -99
- {ripperdoc-0.2.4.dist-info → ripperdoc-0.2.5.dist-info}/WHEEL +0 -0
- {ripperdoc-0.2.4.dist-info → ripperdoc-0.2.5.dist-info}/entry_points.txt +0 -0
- {ripperdoc-0.2.4.dist-info → ripperdoc-0.2.5.dist-info}/licenses/LICENSE +0 -0
- {ripperdoc-0.2.4.dist-info → ripperdoc-0.2.5.dist-info}/top_level.txt +0 -0
ripperdoc/core/skills.py
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
"""Agent Skill loading helpers for Ripperdoc.
|
|
2
|
+
|
|
3
|
+
Skills are small capability bundles defined by SKILL.md files that live under
|
|
4
|
+
`~/.ripperdoc/skills` or `.ripperdoc/skills` in a project. Only the skill
|
|
5
|
+
metadata (name + description) should be added to the system prompt up front;
|
|
6
|
+
the full content is loaded on demand via the Skill tool. Optional frontmatter
|
|
7
|
+
fields include:
|
|
8
|
+
- allowed-tools: Comma-separated list of tools that are allowed/preferred.
|
|
9
|
+
- model: Model pointer hint for this skill.
|
|
10
|
+
- max-thinking-tokens: Reasoning budget hint for this skill.
|
|
11
|
+
- disable-model-invocation: If true, block the Skill tool from loading this
|
|
12
|
+
skill.
|
|
13
|
+
- type: Skill kind (defaults to "prompt").
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import re
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from enum import Enum
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple
|
|
23
|
+
|
|
24
|
+
import yaml
|
|
25
|
+
|
|
26
|
+
from ripperdoc.utils.coerce import parse_boolish, parse_optional_int
|
|
27
|
+
from ripperdoc.utils.log import get_logger
|
|
28
|
+
|
|
29
|
+
logger = get_logger()
|
|
30
|
+
|
|
31
|
+
SKILL_DIR_NAME = "skills"
|
|
32
|
+
SKILL_FILE_NAME = "SKILL.md"
|
|
33
|
+
_SKILL_NAME_RE = re.compile(r"^[a-z0-9-]{1,64}$")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class SkillLocation(str, Enum):
|
|
37
|
+
"""Where a skill definition is sourced from."""
|
|
38
|
+
|
|
39
|
+
USER = "user"
|
|
40
|
+
PROJECT = "project"
|
|
41
|
+
OTHER = "other"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class SkillDefinition:
|
|
46
|
+
"""Parsed representation of a skill."""
|
|
47
|
+
|
|
48
|
+
name: str
|
|
49
|
+
description: str
|
|
50
|
+
content: str
|
|
51
|
+
path: Path
|
|
52
|
+
base_dir: Path
|
|
53
|
+
location: SkillLocation
|
|
54
|
+
allowed_tools: List[str]
|
|
55
|
+
model: Optional[str] = None
|
|
56
|
+
max_thinking_tokens: Optional[int] = None
|
|
57
|
+
skill_type: str = "prompt"
|
|
58
|
+
disable_model_invocation: bool = False
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass
|
|
62
|
+
class SkillLoadError:
|
|
63
|
+
"""Error encountered while loading a skill file."""
|
|
64
|
+
|
|
65
|
+
path: Path
|
|
66
|
+
reason: str
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass
|
|
70
|
+
class SkillLoadResult:
|
|
71
|
+
"""Aggregated result of loading skills."""
|
|
72
|
+
|
|
73
|
+
skills: List[SkillDefinition]
|
|
74
|
+
errors: List[SkillLoadError]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _split_frontmatter(raw_text: str) -> Tuple[Dict[str, Any], str]:
|
|
78
|
+
"""Extract YAML frontmatter and body content from a markdown file."""
|
|
79
|
+
lines = raw_text.splitlines()
|
|
80
|
+
if len(lines) >= 3 and lines[0].strip() == "---":
|
|
81
|
+
for idx in range(1, len(lines)):
|
|
82
|
+
if lines[idx].strip() == "---":
|
|
83
|
+
frontmatter_text = "\n".join(lines[1:idx])
|
|
84
|
+
body = "\n".join(lines[idx + 1 :])
|
|
85
|
+
try:
|
|
86
|
+
frontmatter = yaml.safe_load(frontmatter_text) or {}
|
|
87
|
+
except (yaml.YAMLError, ValueError, TypeError) as exc: # pragma: no cover - defensive
|
|
88
|
+
logger.warning(
|
|
89
|
+
"[skills] Invalid frontmatter in SKILL.md: %s: %s",
|
|
90
|
+
type(exc).__name__, exc,
|
|
91
|
+
)
|
|
92
|
+
return {"__error__": f"Invalid frontmatter: {exc}"}, body
|
|
93
|
+
return frontmatter, body
|
|
94
|
+
return {}, raw_text
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _normalize_allowed_tools(value: object) -> List[str]:
|
|
98
|
+
"""Normalize allowed-tools values to a clean list of tool names."""
|
|
99
|
+
if value is None:
|
|
100
|
+
return []
|
|
101
|
+
if isinstance(value, str):
|
|
102
|
+
return [item.strip() for item in value.split(",") if item.strip()]
|
|
103
|
+
if isinstance(value, Iterable):
|
|
104
|
+
tools: List[str] = []
|
|
105
|
+
for item in value:
|
|
106
|
+
if isinstance(item, str) and item.strip():
|
|
107
|
+
tools.append(item.strip())
|
|
108
|
+
return tools
|
|
109
|
+
return []
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _load_skill_file(
|
|
113
|
+
path: Path, location: SkillLocation
|
|
114
|
+
) -> Tuple[Optional[SkillDefinition], Optional[SkillLoadError]]:
|
|
115
|
+
"""Parse a single SKILL.md file."""
|
|
116
|
+
try:
|
|
117
|
+
text = path.read_text(encoding="utf-8")
|
|
118
|
+
except (OSError, IOError, UnicodeDecodeError) as exc:
|
|
119
|
+
logger.warning(
|
|
120
|
+
"[skills] Failed to read skill file: %s: %s",
|
|
121
|
+
type(exc).__name__, exc,
|
|
122
|
+
extra={"path": str(path)},
|
|
123
|
+
)
|
|
124
|
+
return None, SkillLoadError(path=path, reason=f"Failed to read file: {exc}")
|
|
125
|
+
|
|
126
|
+
frontmatter, body = _split_frontmatter(text)
|
|
127
|
+
if "__error__" in frontmatter:
|
|
128
|
+
return None, SkillLoadError(path=path, reason=str(frontmatter["__error__"]))
|
|
129
|
+
|
|
130
|
+
raw_name = frontmatter.get("name")
|
|
131
|
+
raw_description = frontmatter.get("description")
|
|
132
|
+
if not isinstance(raw_name, str) or not raw_name.strip():
|
|
133
|
+
return None, SkillLoadError(path=path, reason='Missing required "name" field')
|
|
134
|
+
if not _SKILL_NAME_RE.match(raw_name.strip()):
|
|
135
|
+
return None, SkillLoadError(
|
|
136
|
+
path=path,
|
|
137
|
+
reason='Invalid "name" format. Use lowercase letters, numbers, and hyphens only (max 64 chars).',
|
|
138
|
+
)
|
|
139
|
+
if not isinstance(raw_description, str) or not raw_description.strip():
|
|
140
|
+
return None, SkillLoadError(path=path, reason='Missing required "description" field')
|
|
141
|
+
|
|
142
|
+
allowed_tools = _normalize_allowed_tools(
|
|
143
|
+
frontmatter.get("allowed-tools") or frontmatter.get("allowed_tools")
|
|
144
|
+
)
|
|
145
|
+
model_value = frontmatter.get("model")
|
|
146
|
+
model = model_value if isinstance(model_value, str) and model_value.strip() else None
|
|
147
|
+
max_thinking_tokens = parse_optional_int(
|
|
148
|
+
frontmatter.get("max-thinking-tokens") or frontmatter.get("max_thinking_tokens")
|
|
149
|
+
)
|
|
150
|
+
raw_type = (
|
|
151
|
+
frontmatter.get("type")
|
|
152
|
+
or frontmatter.get("skill-type")
|
|
153
|
+
or frontmatter.get("skill_type")
|
|
154
|
+
or "prompt"
|
|
155
|
+
)
|
|
156
|
+
skill_type = str(raw_type).strip().lower() if isinstance(raw_type, str) else "prompt"
|
|
157
|
+
disable_model_invocation = parse_boolish(
|
|
158
|
+
frontmatter.get("disable-model-invocation") or frontmatter.get("disable_model_invocation")
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
skill = SkillDefinition(
|
|
162
|
+
name=raw_name.strip(),
|
|
163
|
+
description=raw_description.strip(),
|
|
164
|
+
content=body.strip(),
|
|
165
|
+
path=path,
|
|
166
|
+
base_dir=path.parent,
|
|
167
|
+
location=location,
|
|
168
|
+
allowed_tools=allowed_tools,
|
|
169
|
+
model=model,
|
|
170
|
+
max_thinking_tokens=max_thinking_tokens,
|
|
171
|
+
skill_type=skill_type or "prompt",
|
|
172
|
+
disable_model_invocation=disable_model_invocation,
|
|
173
|
+
)
|
|
174
|
+
return skill, None
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _load_skill_dir(
|
|
178
|
+
path: Path, location: SkillLocation
|
|
179
|
+
) -> Tuple[List[SkillDefinition], List[SkillLoadError]]:
|
|
180
|
+
"""Load skills from a directory that either contains SKILL.md or subdirectories."""
|
|
181
|
+
skills: List[SkillDefinition] = []
|
|
182
|
+
errors: List[SkillLoadError] = []
|
|
183
|
+
if not path.exists() or not path.is_dir():
|
|
184
|
+
return skills, errors
|
|
185
|
+
|
|
186
|
+
single_skill = path / SKILL_FILE_NAME
|
|
187
|
+
if single_skill.exists():
|
|
188
|
+
skill, error = _load_skill_file(single_skill, location)
|
|
189
|
+
if skill:
|
|
190
|
+
skills.append(skill)
|
|
191
|
+
elif error:
|
|
192
|
+
errors.append(error)
|
|
193
|
+
return skills, errors
|
|
194
|
+
|
|
195
|
+
for entry in sorted(path.iterdir()):
|
|
196
|
+
try:
|
|
197
|
+
if not entry.is_dir() and not entry.is_symlink():
|
|
198
|
+
continue
|
|
199
|
+
except OSError:
|
|
200
|
+
continue
|
|
201
|
+
|
|
202
|
+
candidate = entry / SKILL_FILE_NAME
|
|
203
|
+
if not candidate.exists():
|
|
204
|
+
continue
|
|
205
|
+
skill, error = _load_skill_file(candidate, location)
|
|
206
|
+
if skill:
|
|
207
|
+
skills.append(skill)
|
|
208
|
+
elif error:
|
|
209
|
+
errors.append(error)
|
|
210
|
+
return skills, errors
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def skill_directories(
|
|
214
|
+
project_path: Optional[Path] = None, home: Optional[Path] = None
|
|
215
|
+
) -> List[Tuple[Path, SkillLocation]]:
|
|
216
|
+
"""Return the standard skill directories for user and project scopes."""
|
|
217
|
+
home_dir = (home or Path.home()).expanduser()
|
|
218
|
+
project_dir = (project_path or Path.cwd()).resolve()
|
|
219
|
+
return [
|
|
220
|
+
(home_dir / ".ripperdoc" / SKILL_DIR_NAME, SkillLocation.USER),
|
|
221
|
+
(project_dir / ".ripperdoc" / SKILL_DIR_NAME, SkillLocation.PROJECT),
|
|
222
|
+
]
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def load_all_skills(
|
|
226
|
+
project_path: Optional[Path] = None, home: Optional[Path] = None
|
|
227
|
+
) -> SkillLoadResult:
|
|
228
|
+
"""Load skills from user and project directories.
|
|
229
|
+
|
|
230
|
+
Project skills override user skills with the same name.
|
|
231
|
+
"""
|
|
232
|
+
skills_by_name: Dict[str, SkillDefinition] = {}
|
|
233
|
+
errors: List[SkillLoadError] = []
|
|
234
|
+
|
|
235
|
+
# Load user first so project overrides take precedence.
|
|
236
|
+
for directory, location in skill_directories(project_path=project_path, home=home):
|
|
237
|
+
loaded, dir_errors = _load_skill_dir(directory, location)
|
|
238
|
+
errors.extend(dir_errors)
|
|
239
|
+
for skill in loaded:
|
|
240
|
+
if skill.name in skills_by_name:
|
|
241
|
+
logger.debug(
|
|
242
|
+
"[skills] Overriding skill",
|
|
243
|
+
extra={
|
|
244
|
+
"skill_name": skill.name,
|
|
245
|
+
"previous_location": str(skills_by_name[skill.name].location),
|
|
246
|
+
"new_location": str(location),
|
|
247
|
+
},
|
|
248
|
+
)
|
|
249
|
+
skills_by_name[skill.name] = skill
|
|
250
|
+
return SkillLoadResult(skills=list(skills_by_name.values()), errors=errors)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def find_skill(
|
|
254
|
+
skill_name: str, project_path: Optional[Path] = None, home: Optional[Path] = None
|
|
255
|
+
) -> Optional[SkillDefinition]:
|
|
256
|
+
"""Find a skill by name (case-sensitive match)."""
|
|
257
|
+
normalized = skill_name.strip().lstrip("/")
|
|
258
|
+
if not normalized:
|
|
259
|
+
return None
|
|
260
|
+
result = load_all_skills(project_path=project_path, home=home)
|
|
261
|
+
return next((skill for skill in result.skills if skill.name == normalized), None)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def build_skill_summary(skills: Sequence[SkillDefinition]) -> str:
|
|
265
|
+
"""Render a concise instruction block listing available skills."""
|
|
266
|
+
if not skills:
|
|
267
|
+
return (
|
|
268
|
+
"# Skills\n"
|
|
269
|
+
"No skills detected. Add SKILL.md under ~/.ripperdoc/skills or ./.ripperdoc/skills "
|
|
270
|
+
"to extend capabilities, then load them with the Skill tool when relevant."
|
|
271
|
+
)
|
|
272
|
+
lines = [
|
|
273
|
+
"# Skills",
|
|
274
|
+
"Skills extend your capabilities with reusable instructions stored in SKILL.md files.",
|
|
275
|
+
'Call the Skill tool with {"skill": "<name>"} to load a skill when it matches the user request.',
|
|
276
|
+
"Available skills:",
|
|
277
|
+
]
|
|
278
|
+
for skill in skills:
|
|
279
|
+
location = f" ({skill.location.value})" if skill.location else ""
|
|
280
|
+
lines.append(f"- {skill.name}{location}: {skill.description}")
|
|
281
|
+
return "\n".join(lines)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
__all__ = [
|
|
285
|
+
"SkillDefinition",
|
|
286
|
+
"SkillLoadError",
|
|
287
|
+
"SkillLoadResult",
|
|
288
|
+
"SkillLocation",
|
|
289
|
+
"SKILL_DIR_NAME",
|
|
290
|
+
"SKILL_FILE_NAME",
|
|
291
|
+
"load_all_skills",
|
|
292
|
+
"find_skill",
|
|
293
|
+
"build_skill_summary",
|
|
294
|
+
"skill_directories",
|
|
295
|
+
]
|
ripperdoc/core/system_prompt.py
CHANGED
|
@@ -32,7 +32,7 @@ DEFENSIVE_SECURITY_GUIDELINE = (
|
|
|
32
32
|
"IMPORTANT: Assist with defensive security tasks only. Refuse to create, modify, or improve code that may be used maliciously. "
|
|
33
33
|
"Allow security analysis, detection rules, vulnerability explanations, defensive tools, and security documentation."
|
|
34
34
|
)
|
|
35
|
-
FEEDBACK_URL = "https://github.com/
|
|
35
|
+
FEEDBACK_URL = "https://github.com/quantmew/Ripperdoc/issues"
|
|
36
36
|
|
|
37
37
|
|
|
38
38
|
def _detect_git_repo(cwd: Path) -> bool:
|
|
@@ -46,8 +46,12 @@ def _detect_git_repo(cwd: Path) -> bool:
|
|
|
46
46
|
check=False,
|
|
47
47
|
)
|
|
48
48
|
return result.returncode == 0 and result.stdout.strip().lower() == "true"
|
|
49
|
-
except
|
|
50
|
-
logger.
|
|
49
|
+
except (OSError, subprocess.SubprocessError) as exc:
|
|
50
|
+
logger.warning(
|
|
51
|
+
"[system_prompt] Failed to detect git repository: %s: %s",
|
|
52
|
+
type(exc).__name__, exc,
|
|
53
|
+
extra={"cwd": str(cwd)},
|
|
54
|
+
)
|
|
51
55
|
return False
|
|
52
56
|
|
|
53
57
|
|
|
@@ -220,7 +224,7 @@ def build_system_prompt(
|
|
|
220
224
|
- NEVER create files unless they're absolutely necessary for achieving your goal. ALWAYS prefer editing an existing file to creating a new one. This includes markdown files.
|
|
221
225
|
|
|
222
226
|
# Professional objectivity
|
|
223
|
-
Prioritize technical accuracy and truthfulness over validating the user's beliefs. Focus on facts and problem-solving, providing direct, objective technical info without any unnecessary superlatives, praise, or emotional validation. It is best for the user if
|
|
227
|
+
Prioritize technical accuracy and truthfulness over validating the user's beliefs. Focus on facts and problem-solving, providing direct, objective technical info without any unnecessary superlatives, praise, or emotional validation. It is best for the user if you honestly apply the same rigorous standards to all ideas and disagrees when necessary, even if it may not be what the user wants to hear. Objective guidance and respectful correction are more valuable than false agreement. Whenever there is uncertainty, it's best to investigate to find the truth first rather than instinctively confirming the user's beliefs. Avoid using over-the-top validation or excessive praise when responding to users such as "You're absolutely right" or similar phrases.
|
|
224
228
|
|
|
225
229
|
# Planning without timelines
|
|
226
230
|
When planning tasks, provide concrete implementation steps without time estimates. Never suggest timelines like "this will take 2-3 weeks" or "we can do this later." Focus on what needs to be done, not when. Break work into actionable steps and let users decide scheduling.
|
|
@@ -386,8 +390,11 @@ def build_system_prompt(
|
|
|
386
390
|
|
|
387
391
|
Provide detailed prompts so the agent can work autonomously and return a concise report."""
|
|
388
392
|
).strip()
|
|
389
|
-
except
|
|
390
|
-
logger.
|
|
393
|
+
except (OSError, ValueError, RuntimeError) as exc:
|
|
394
|
+
logger.warning(
|
|
395
|
+
"Failed to load agent definitions: %s: %s",
|
|
396
|
+
type(exc).__name__, exc,
|
|
397
|
+
)
|
|
391
398
|
agent_section = (
|
|
392
399
|
"# Subagents\nTask tool available, but agent definitions could not be loaded."
|
|
393
400
|
)
|
|
@@ -413,7 +420,6 @@ def build_system_prompt(
|
|
|
413
420
|
tool_usage_section,
|
|
414
421
|
agent_section,
|
|
415
422
|
build_environment_prompt(),
|
|
416
|
-
DEFENSIVE_SECURITY_GUIDELINE,
|
|
417
423
|
always_use_todo,
|
|
418
424
|
build_commit_workflow_prompt(shell_tool_name, todo_tool_name, TASK_TOOL_NAME),
|
|
419
425
|
code_references,
|
ripperdoc/core/tool.py
CHANGED
|
@@ -208,9 +208,10 @@ async def build_tool_description(
|
|
|
208
208
|
|
|
209
209
|
if parts:
|
|
210
210
|
return f"{description_text}\n\nInput examples:\n" + "\n\n".join(parts)
|
|
211
|
-
except
|
|
212
|
-
logger.
|
|
213
|
-
"[tool] Failed to build input example section",
|
|
211
|
+
except (TypeError, ValueError, AttributeError, KeyError) as exc:
|
|
212
|
+
logger.warning(
|
|
213
|
+
"[tool] Failed to build input example section: %s: %s",
|
|
214
|
+
type(exc).__name__, exc,
|
|
214
215
|
extra={"tool": getattr(tool, "name", None)},
|
|
215
216
|
)
|
|
216
217
|
return description_text
|
|
@@ -227,9 +228,10 @@ def tool_input_examples(tool: Tool[Any, Any], limit: int = 5) -> List[Dict[str,
|
|
|
227
228
|
for example in examples[:limit]:
|
|
228
229
|
try:
|
|
229
230
|
results.append(example.example)
|
|
230
|
-
except
|
|
231
|
-
logger.
|
|
232
|
-
"[tool] Failed to format tool input example",
|
|
231
|
+
except (TypeError, ValueError, AttributeError) as exc:
|
|
232
|
+
logger.warning(
|
|
233
|
+
"[tool] Failed to format tool input example: %s: %s",
|
|
234
|
+
type(exc).__name__, exc,
|
|
233
235
|
extra={"tool": getattr(tool, "name", None)},
|
|
234
236
|
)
|
|
235
237
|
continue
|
ripperdoc/sdk/client.py
CHANGED
|
@@ -27,6 +27,7 @@ from ripperdoc.core.default_tools import get_default_tools
|
|
|
27
27
|
from ripperdoc.core.query import QueryContext, query as _core_query
|
|
28
28
|
from ripperdoc.core.permissions import PermissionResult
|
|
29
29
|
from ripperdoc.core.system_prompt import build_system_prompt
|
|
30
|
+
from ripperdoc.core.skills import build_skill_summary, load_all_skills
|
|
30
31
|
from ripperdoc.core.tool import Tool
|
|
31
32
|
from ripperdoc.tools.task_tool import TaskTool
|
|
32
33
|
from ripperdoc.tools.mcp_tools import load_dynamic_mcp_tools_async, merge_tools_with_dynamic
|
|
@@ -42,6 +43,7 @@ from ripperdoc.utils.mcp import (
|
|
|
42
43
|
load_mcp_servers_async,
|
|
43
44
|
shutdown_mcp_runtime,
|
|
44
45
|
)
|
|
46
|
+
from ripperdoc.utils.log import get_logger
|
|
45
47
|
|
|
46
48
|
MessageType = Union[UserMessage, AssistantMessage, ProgressMessage]
|
|
47
49
|
PermissionChecker = Callable[
|
|
@@ -67,6 +69,8 @@ QueryRunner = Callable[
|
|
|
67
69
|
|
|
68
70
|
_END_OF_STREAM = object()
|
|
69
71
|
|
|
72
|
+
logger = get_logger()
|
|
73
|
+
|
|
70
74
|
|
|
71
75
|
def _coerce_to_path(path: Union[str, Path]) -> Path:
|
|
72
76
|
return path if isinstance(path, Path) else Path(path)
|
|
@@ -281,12 +285,21 @@ class RipperdocClient:
|
|
|
281
285
|
return self.options.system_prompt
|
|
282
286
|
|
|
283
287
|
instructions: List[str] = []
|
|
288
|
+
project_path = _coerce_to_path(self.options.cwd or Path.cwd())
|
|
289
|
+
skill_result = load_all_skills(project_path)
|
|
290
|
+
for err in skill_result.errors:
|
|
291
|
+
logger.warning(
|
|
292
|
+
"[skills] Failed to load skill",
|
|
293
|
+
extra={"path": str(err.path), "reason": err.reason},
|
|
294
|
+
)
|
|
295
|
+
skill_instructions = build_skill_summary(skill_result.skills)
|
|
296
|
+
if skill_instructions:
|
|
297
|
+
instructions.append(skill_instructions)
|
|
284
298
|
instructions.extend(self.options.extra_instructions())
|
|
285
299
|
memory = build_memory_instructions()
|
|
286
300
|
if memory:
|
|
287
301
|
instructions.append(memory)
|
|
288
302
|
|
|
289
|
-
project_path = _coerce_to_path(self.options.cwd or Path.cwd())
|
|
290
303
|
dynamic_tools = await load_dynamic_mcp_tools_async(project_path)
|
|
291
304
|
if dynamic_tools:
|
|
292
305
|
self._tools = merge_tools_with_dynamic(self._tools, dynamic_tools)
|
|
@@ -117,9 +117,7 @@ def format_option_display(option: OptionInput, index: int) -> str:
|
|
|
117
117
|
return f" {index}. {option.label}{desc}"
|
|
118
118
|
|
|
119
119
|
|
|
120
|
-
def format_question_prompt(
|
|
121
|
-
question: QuestionInput, question_num: int, total: int
|
|
122
|
-
) -> str:
|
|
120
|
+
def format_question_prompt(question: QuestionInput, question_num: int, total: int) -> str:
|
|
123
121
|
"""Format a question for terminal display."""
|
|
124
122
|
header = truncate_header(question.header)
|
|
125
123
|
lines = [
|
|
@@ -137,9 +135,7 @@ def format_question_prompt(
|
|
|
137
135
|
|
|
138
136
|
if question.multiSelect:
|
|
139
137
|
lines.append("")
|
|
140
|
-
lines.append(
|
|
141
|
-
" Enter numbers separated by commas (e.g., 1,3), or 'o' for other: "
|
|
142
|
-
)
|
|
138
|
+
lines.append(" Enter numbers separated by commas (e.g., 1,3), or 'o' for other: ")
|
|
143
139
|
else:
|
|
144
140
|
lines.append("")
|
|
145
141
|
lines.append(" Enter choice (1-{}) or 'o' for other: ".format(len(question.options) + 1))
|
|
@@ -204,9 +200,7 @@ async def prompt_user_for_answer(
|
|
|
204
200
|
f" Invalid selection. Enter numbers from 1 to {len(question.options) + 1}."
|
|
205
201
|
)
|
|
206
202
|
except ValueError:
|
|
207
|
-
print(
|
|
208
|
-
" Invalid input. Enter numbers separated by commas."
|
|
209
|
-
)
|
|
203
|
+
print(" Invalid input. Enter numbers separated by commas.")
|
|
210
204
|
else:
|
|
211
205
|
# Single selection
|
|
212
206
|
try:
|
|
@@ -231,8 +225,11 @@ async def prompt_user_for_answer(
|
|
|
231
225
|
return None
|
|
232
226
|
except EOFError:
|
|
233
227
|
return None
|
|
234
|
-
except
|
|
235
|
-
logger.
|
|
228
|
+
except (OSError, RuntimeError, ValueError) as e:
|
|
229
|
+
logger.warning(
|
|
230
|
+
"[ask_user_question_tool] Error during prompt: %s: %s",
|
|
231
|
+
type(e).__name__, e,
|
|
232
|
+
)
|
|
236
233
|
return None
|
|
237
234
|
|
|
238
235
|
return await loop.run_in_executor(None, _prompt)
|
|
@@ -291,7 +288,8 @@ class AskUserQuestionTool(Tool[AskUserQuestionToolInput, AskUserQuestionToolOutp
|
|
|
291
288
|
return True
|
|
292
289
|
|
|
293
290
|
def needs_permissions(
|
|
294
|
-
self,
|
|
291
|
+
self,
|
|
292
|
+
input_data: Optional[AskUserQuestionToolInput] = None, # noqa: ARG002
|
|
295
293
|
) -> bool:
|
|
296
294
|
return False
|
|
297
295
|
|
|
@@ -305,9 +303,7 @@ class AskUserQuestionTool(Tool[AskUserQuestionToolInput, AskUserQuestionToolOutp
|
|
|
305
303
|
|
|
306
304
|
for question in input_data.questions:
|
|
307
305
|
if question.question in seen_questions:
|
|
308
|
-
return ValidationResult(
|
|
309
|
-
result=False, message="Question texts must be unique"
|
|
310
|
-
)
|
|
306
|
+
return ValidationResult(result=False, message="Question texts must be unique")
|
|
311
307
|
seen_questions.add(question.question)
|
|
312
308
|
|
|
313
309
|
option_labels: set[str] = set()
|
|
@@ -338,7 +334,9 @@ class AskUserQuestionTool(Tool[AskUserQuestionToolInput, AskUserQuestionToolOutp
|
|
|
338
334
|
)
|
|
339
335
|
|
|
340
336
|
def render_tool_use_message(
|
|
341
|
-
self,
|
|
337
|
+
self,
|
|
338
|
+
input_data: AskUserQuestionToolInput,
|
|
339
|
+
verbose: bool = False, # noqa: ARG002
|
|
342
340
|
) -> str:
|
|
343
341
|
"""Render the tool use message for display."""
|
|
344
342
|
question_count = len(input_data.questions)
|
|
@@ -359,7 +357,7 @@ class AskUserQuestionTool(Tool[AskUserQuestionToolInput, AskUserQuestionToolOutp
|
|
|
359
357
|
if context.pause_ui:
|
|
360
358
|
try:
|
|
361
359
|
context.pause_ui()
|
|
362
|
-
except
|
|
360
|
+
except (RuntimeError, ValueError, OSError):
|
|
363
361
|
logger.debug("[ask_user_question_tool] Failed to pause UI")
|
|
364
362
|
|
|
365
363
|
try:
|
|
@@ -409,10 +407,10 @@ class AskUserQuestionTool(Tool[AskUserQuestionToolInput, AskUserQuestionToolOutp
|
|
|
409
407
|
result_for_assistant=self.render_result_for_assistant(output),
|
|
410
408
|
)
|
|
411
409
|
|
|
412
|
-
except
|
|
413
|
-
logger.
|
|
414
|
-
"[ask_user_question_tool] Error collecting answers",
|
|
415
|
-
|
|
410
|
+
except (OSError, RuntimeError, ValueError, KeyError) as exc:
|
|
411
|
+
logger.warning(
|
|
412
|
+
"[ask_user_question_tool] Error collecting answers: %s: %s",
|
|
413
|
+
type(exc).__name__, exc,
|
|
416
414
|
)
|
|
417
415
|
output = AskUserQuestionToolOutput(
|
|
418
416
|
questions=questions,
|
|
@@ -429,5 +427,5 @@ class AskUserQuestionTool(Tool[AskUserQuestionToolInput, AskUserQuestionToolOutp
|
|
|
429
427
|
if context.resume_ui:
|
|
430
428
|
try:
|
|
431
429
|
context.resume_ui()
|
|
432
|
-
except
|
|
430
|
+
except (RuntimeError, ValueError, OSError):
|
|
433
431
|
logger.debug("[ask_user_question_tool] Failed to resume UI")
|
|
@@ -47,11 +47,13 @@ _background_loop: Optional[asyncio.AbstractEventLoop] = None
|
|
|
47
47
|
_background_thread: Optional[threading.Thread] = None
|
|
48
48
|
_loop_lock = threading.Lock()
|
|
49
49
|
_shutdown_registered = False
|
|
50
|
+
|
|
51
|
+
|
|
50
52
|
def _safe_log_exception(message: str, **extra: Any) -> None:
|
|
51
53
|
"""Log an exception but never let logging failures bubble up."""
|
|
52
54
|
try:
|
|
53
55
|
logger.exception(message, extra=extra)
|
|
54
|
-
except
|
|
56
|
+
except (OSError, RuntimeError, ValueError):
|
|
55
57
|
pass
|
|
56
58
|
|
|
57
59
|
|
|
@@ -112,7 +114,9 @@ async def _pump_stream(stream: asyncio.StreamReader, sink: List[str]) -> None:
|
|
|
112
114
|
text = chunk.decode("utf-8", errors="replace")
|
|
113
115
|
with _tasks_lock:
|
|
114
116
|
sink.append(text)
|
|
115
|
-
except
|
|
117
|
+
except (OSError, RuntimeError, asyncio.CancelledError) as exc:
|
|
118
|
+
if isinstance(exc, asyncio.CancelledError):
|
|
119
|
+
return # Normal cancellation
|
|
116
120
|
# Best effort; ignore stream read errors to avoid leaking tasks.
|
|
117
121
|
logger.debug(
|
|
118
122
|
f"Stream pump error for background task: {exc}",
|
|
@@ -155,9 +159,10 @@ async def _monitor_task(task: BackgroundTask) -> None:
|
|
|
155
159
|
task.exit_code = -1
|
|
156
160
|
except asyncio.CancelledError:
|
|
157
161
|
return
|
|
158
|
-
except
|
|
159
|
-
logger.
|
|
160
|
-
"Error monitoring background task",
|
|
162
|
+
except (OSError, RuntimeError, ProcessLookupError) as exc:
|
|
163
|
+
logger.warning(
|
|
164
|
+
"Error monitoring background task: %s: %s",
|
|
165
|
+
type(exc).__name__, exc,
|
|
161
166
|
extra={"task_id": task.id, "command": task.command},
|
|
162
167
|
)
|
|
163
168
|
with _tasks_lock:
|
|
@@ -327,12 +332,13 @@ async def _shutdown_loop(loop: asyncio.AbstractEventLoop) -> None:
|
|
|
327
332
|
with contextlib.suppress(asyncio.TimeoutError, ProcessLookupError):
|
|
328
333
|
await asyncio.wait_for(task.process.wait(), timeout=0.5)
|
|
329
334
|
task.exit_code = task.process.returncode or -1
|
|
330
|
-
except
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
335
|
+
except (OSError, RuntimeError, asyncio.CancelledError) as exc:
|
|
336
|
+
if not isinstance(exc, asyncio.CancelledError):
|
|
337
|
+
_safe_log_exception(
|
|
338
|
+
"Error shutting down background task",
|
|
339
|
+
task_id=task.id,
|
|
340
|
+
command=task.command,
|
|
341
|
+
)
|
|
336
342
|
finally:
|
|
337
343
|
await _finalize_reader_tasks(task.reader_tasks)
|
|
338
344
|
task.done_event.set()
|
|
@@ -366,11 +372,11 @@ def shutdown_background_shell() -> None:
|
|
|
366
372
|
try:
|
|
367
373
|
fut = asyncio.run_coroutine_threadsafe(_shutdown_loop(loop), loop)
|
|
368
374
|
fut.result(timeout=3)
|
|
369
|
-
except
|
|
375
|
+
except (RuntimeError, TimeoutError, concurrent.futures.TimeoutError):
|
|
370
376
|
logger.debug("Failed to cleanly shutdown background loop", exc_info=True)
|
|
371
377
|
try:
|
|
372
378
|
loop.call_soon_threadsafe(loop.stop)
|
|
373
|
-
except
|
|
379
|
+
except (RuntimeError, OSError):
|
|
374
380
|
logger.debug("Failed to stop background loop", exc_info=True)
|
|
375
381
|
else:
|
|
376
382
|
loop.run_until_complete(_shutdown_loop(loop))
|