ripperdoc 0.2.9__py3-none-any.whl → 0.2.10__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 +235 -14
- ripperdoc/cli/commands/__init__.py +2 -0
- ripperdoc/cli/commands/agents_cmd.py +132 -5
- ripperdoc/cli/commands/clear_cmd.py +8 -0
- ripperdoc/cli/commands/exit_cmd.py +1 -0
- ripperdoc/cli/commands/models_cmd.py +3 -3
- ripperdoc/cli/commands/resume_cmd.py +4 -0
- ripperdoc/cli/commands/stats_cmd.py +244 -0
- ripperdoc/cli/ui/panels.py +1 -0
- ripperdoc/cli/ui/rich_ui.py +295 -24
- ripperdoc/cli/ui/spinner.py +30 -18
- ripperdoc/cli/ui/thinking_spinner.py +1 -2
- ripperdoc/cli/ui/wizard.py +6 -8
- ripperdoc/core/agents.py +10 -3
- ripperdoc/core/config.py +3 -6
- ripperdoc/core/default_tools.py +90 -10
- ripperdoc/core/hooks/events.py +4 -0
- ripperdoc/core/hooks/llm_callback.py +59 -0
- ripperdoc/core/permissions.py +78 -4
- ripperdoc/core/providers/openai.py +29 -19
- ripperdoc/core/query.py +192 -31
- ripperdoc/core/tool.py +9 -4
- ripperdoc/sdk/client.py +77 -2
- ripperdoc/tools/background_shell.py +305 -134
- ripperdoc/tools/bash_tool.py +42 -13
- ripperdoc/tools/file_edit_tool.py +159 -50
- ripperdoc/tools/file_read_tool.py +20 -0
- ripperdoc/tools/file_write_tool.py +7 -8
- ripperdoc/tools/lsp_tool.py +615 -0
- ripperdoc/tools/task_tool.py +514 -65
- ripperdoc/utils/conversation_compaction.py +1 -1
- ripperdoc/utils/file_watch.py +206 -3
- ripperdoc/utils/lsp.py +806 -0
- ripperdoc/utils/message_formatting.py +5 -2
- ripperdoc/utils/messages.py +21 -1
- ripperdoc/utils/permissions/tool_permission_utils.py +174 -15
- ripperdoc/utils/session_heatmap.py +244 -0
- ripperdoc/utils/session_stats.py +293 -0
- {ripperdoc-0.2.9.dist-info → ripperdoc-0.2.10.dist-info}/METADATA +8 -2
- {ripperdoc-0.2.9.dist-info → ripperdoc-0.2.10.dist-info}/RECORD +45 -39
- {ripperdoc-0.2.9.dist-info → ripperdoc-0.2.10.dist-info}/WHEEL +0 -0
- {ripperdoc-0.2.9.dist-info → ripperdoc-0.2.10.dist-info}/entry_points.txt +0 -0
- {ripperdoc-0.2.9.dist-info → ripperdoc-0.2.10.dist-info}/licenses/LICENSE +0 -0
- {ripperdoc-0.2.9.dist-info → ripperdoc-0.2.10.dist-info}/top_level.txt +0 -0
ripperdoc/core/agents.py
CHANGED
|
@@ -10,6 +10,7 @@ from typing import Any, Dict, Iterable, List, Optional, Tuple
|
|
|
10
10
|
|
|
11
11
|
import yaml
|
|
12
12
|
|
|
13
|
+
from ripperdoc.utils.coerce import parse_boolish
|
|
13
14
|
from ripperdoc.utils.log import get_logger
|
|
14
15
|
from ripperdoc.tools.ask_user_question_tool import AskUserQuestionTool
|
|
15
16
|
from ripperdoc.tools.bash_output_tool import BashOutputTool
|
|
@@ -91,6 +92,7 @@ class AgentDefinition:
|
|
|
91
92
|
model: Optional[str] = None
|
|
92
93
|
color: Optional[str] = None
|
|
93
94
|
filename: Optional[str] = None
|
|
95
|
+
fork_context: bool = False
|
|
94
96
|
|
|
95
97
|
|
|
96
98
|
@dataclass
|
|
@@ -234,7 +236,7 @@ def _built_in_agents() -> List[AgentDefinition]:
|
|
|
234
236
|
system_prompt=EXPLORE_AGENT_PROMPT,
|
|
235
237
|
location=AgentLocation.BUILT_IN,
|
|
236
238
|
color="green",
|
|
237
|
-
model="
|
|
239
|
+
model="main",
|
|
238
240
|
),
|
|
239
241
|
AgentDefinition(
|
|
240
242
|
agent_type="plan",
|
|
@@ -324,8 +326,9 @@ def _parse_agent_file(
|
|
|
324
326
|
return None, f"Failed to read agent file {path}: {exc}"
|
|
325
327
|
|
|
326
328
|
frontmatter, body = _split_frontmatter(text)
|
|
327
|
-
|
|
328
|
-
|
|
329
|
+
error = frontmatter.get("__error__")
|
|
330
|
+
if error is not None:
|
|
331
|
+
return None, str(error)
|
|
329
332
|
|
|
330
333
|
agent_name = frontmatter.get("name")
|
|
331
334
|
description = frontmatter.get("description")
|
|
@@ -339,6 +342,7 @@ def _parse_agent_file(
|
|
|
339
342
|
color_value = frontmatter.get("color")
|
|
340
343
|
model = model_value if isinstance(model_value, str) else None
|
|
341
344
|
color = color_value if isinstance(color_value, str) else None
|
|
345
|
+
fork_context = parse_boolish(frontmatter.get("fork_context") or frontmatter.get("fork-context"))
|
|
342
346
|
|
|
343
347
|
agent = AgentDefinition(
|
|
344
348
|
agent_type=agent_name.strip(),
|
|
@@ -349,6 +353,7 @@ def _parse_agent_file(
|
|
|
349
353
|
model=model,
|
|
350
354
|
color=color,
|
|
351
355
|
filename=path.stem,
|
|
356
|
+
fork_context=fork_context,
|
|
352
357
|
)
|
|
353
358
|
return agent, None
|
|
354
359
|
|
|
@@ -404,6 +409,8 @@ def summarize_agent(agent: AgentDefinition) -> str:
|
|
|
404
409
|
tool_label = "all tools" if "*" in agent.tools else ", ".join(agent.tools)
|
|
405
410
|
location = getattr(agent.location, "value", agent.location)
|
|
406
411
|
details = [f"tools: {tool_label}"]
|
|
412
|
+
if agent.fork_context:
|
|
413
|
+
details.append("context: forked")
|
|
407
414
|
if agent.model:
|
|
408
415
|
details.append(f"model: {agent.model}")
|
|
409
416
|
return f"- {agent.agent_type} ({location}): {agent.when_to_use} [{'; '.join(details)}]"
|
ripperdoc/core/config.py
CHANGED
|
@@ -8,7 +8,7 @@ import json
|
|
|
8
8
|
import os
|
|
9
9
|
from pathlib import Path
|
|
10
10
|
from typing import Any, Dict, Optional, Literal
|
|
11
|
-
from pydantic import BaseModel, Field,
|
|
11
|
+
from pydantic import BaseModel, Field, model_validator
|
|
12
12
|
from enum import Enum
|
|
13
13
|
|
|
14
14
|
from ripperdoc.utils.log import get_logger
|
|
@@ -122,8 +122,6 @@ class ModelPointers(BaseModel):
|
|
|
122
122
|
"""Pointers to different model profiles for different purposes."""
|
|
123
123
|
|
|
124
124
|
main: str = "default"
|
|
125
|
-
task: str = "default"
|
|
126
|
-
reasoning: str = "default"
|
|
127
125
|
quick: str = "default"
|
|
128
126
|
|
|
129
127
|
|
|
@@ -192,7 +190,6 @@ class ProjectConfig(BaseModel):
|
|
|
192
190
|
|
|
193
191
|
# Project settings
|
|
194
192
|
dont_crawl_directory: bool = False
|
|
195
|
-
enable_architect_tool: bool = False
|
|
196
193
|
|
|
197
194
|
# Trust
|
|
198
195
|
has_trust_dialog_accepted: bool = False
|
|
@@ -517,7 +514,7 @@ class ConfigManager:
|
|
|
517
514
|
return config
|
|
518
515
|
|
|
519
516
|
def set_model_pointer(self, pointer: str, profile_name: str) -> GlobalConfig:
|
|
520
|
-
"""Point a logical model slot (e.g., main/
|
|
517
|
+
"""Point a logical model slot (e.g., main/quick) to a profile name."""
|
|
521
518
|
if pointer not in ModelPointers.model_fields:
|
|
522
519
|
raise ValueError(f"Unknown model pointer '{pointer}'.")
|
|
523
520
|
|
|
@@ -575,7 +572,7 @@ def delete_model_profile(name: str) -> GlobalConfig:
|
|
|
575
572
|
|
|
576
573
|
|
|
577
574
|
def set_model_pointer(pointer: str, profile_name: str) -> GlobalConfig:
|
|
578
|
-
"""Update a model pointer (e.g., main/
|
|
575
|
+
"""Update a model pointer (e.g., main/quick) to target a profile."""
|
|
579
576
|
return config_manager.set_model_pointer(pointer, profile_name)
|
|
580
577
|
|
|
581
578
|
|
ripperdoc/core/default_tools.py
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
from typing import Any, List
|
|
5
|
+
from typing import Any, List, Optional
|
|
6
6
|
|
|
7
7
|
from ripperdoc.core.tool import Tool
|
|
8
8
|
|
|
@@ -17,6 +17,7 @@ from ripperdoc.tools.file_write_tool import FileWriteTool
|
|
|
17
17
|
from ripperdoc.tools.glob_tool import GlobTool
|
|
18
18
|
from ripperdoc.tools.ls_tool import LSTool
|
|
19
19
|
from ripperdoc.tools.grep_tool import GrepTool
|
|
20
|
+
from ripperdoc.tools.lsp_tool import LspTool
|
|
20
21
|
from ripperdoc.tools.skill_tool import SkillTool
|
|
21
22
|
from ripperdoc.tools.todo_tool import TodoReadTool, TodoWriteTool
|
|
22
23
|
from ripperdoc.tools.ask_user_question_tool import AskUserQuestionTool
|
|
@@ -34,8 +35,73 @@ from ripperdoc.utils.log import get_logger
|
|
|
34
35
|
|
|
35
36
|
logger = get_logger()
|
|
36
37
|
|
|
38
|
+
# Canonical tool names for --tools filtering
|
|
39
|
+
BUILTIN_TOOL_NAMES = [
|
|
40
|
+
"Bash",
|
|
41
|
+
"BashOutput",
|
|
42
|
+
"KillBash",
|
|
43
|
+
"Read",
|
|
44
|
+
"Edit",
|
|
45
|
+
"MultiEdit",
|
|
46
|
+
"NotebookEdit",
|
|
47
|
+
"Write",
|
|
48
|
+
"Glob",
|
|
49
|
+
"LS",
|
|
50
|
+
"Grep",
|
|
51
|
+
"LSP",
|
|
52
|
+
"Skill",
|
|
53
|
+
"TodoRead",
|
|
54
|
+
"TodoWrite",
|
|
55
|
+
"AskUserQuestion",
|
|
56
|
+
"EnterPlanMode",
|
|
57
|
+
"ExitPlanMode",
|
|
58
|
+
"ToolSearch",
|
|
59
|
+
"ListMcpServers",
|
|
60
|
+
"ListMcpResources",
|
|
61
|
+
"ReadMcpResource",
|
|
62
|
+
"Task",
|
|
63
|
+
]
|
|
37
64
|
|
|
38
|
-
|
|
65
|
+
|
|
66
|
+
def filter_tools_by_names(
|
|
67
|
+
tools: List[Tool[Any, Any]], tool_names: List[str]
|
|
68
|
+
) -> List[Tool[Any, Any]]:
|
|
69
|
+
"""Filter a tool list to only include tools with matching names.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
tools: The full list of tools to filter.
|
|
73
|
+
tool_names: List of tool names to include.
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
Filtered list of tools. If Task is included, it's recreated with
|
|
77
|
+
the filtered base tools.
|
|
78
|
+
"""
|
|
79
|
+
if not tool_names:
|
|
80
|
+
return []
|
|
81
|
+
|
|
82
|
+
name_set = set(tool_names)
|
|
83
|
+
filtered: List[Tool[Any, Any]] = []
|
|
84
|
+
has_task = False
|
|
85
|
+
|
|
86
|
+
for tool in tools:
|
|
87
|
+
tool_name = getattr(tool, "name", tool.__class__.__name__)
|
|
88
|
+
if tool_name in name_set:
|
|
89
|
+
if tool_name == "Task":
|
|
90
|
+
has_task = True
|
|
91
|
+
else:
|
|
92
|
+
filtered.append(tool)
|
|
93
|
+
|
|
94
|
+
# If Task is requested, recreate it with the filtered base tools
|
|
95
|
+
if has_task:
|
|
96
|
+
def _filtered_base_provider() -> List[Tool[Any, Any]]:
|
|
97
|
+
return [t for t in filtered if getattr(t, "name", None) != "Task"]
|
|
98
|
+
|
|
99
|
+
filtered.append(TaskTool(_filtered_base_provider))
|
|
100
|
+
|
|
101
|
+
return filtered
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def get_default_tools(allowed_tools: Optional[List[str]] = None) -> List[Tool[Any, Any]]:
|
|
39
105
|
"""Construct the default tool set (base tools + Task subagent launcher)."""
|
|
40
106
|
base_tools: List[Tool[Any, Any]] = [
|
|
41
107
|
BashTool(),
|
|
@@ -49,6 +115,7 @@ def get_default_tools() -> List[Tool[Any, Any]]:
|
|
|
49
115
|
GlobTool(),
|
|
50
116
|
LSTool(),
|
|
51
117
|
GrepTool(),
|
|
118
|
+
LspTool(),
|
|
52
119
|
SkillTool(),
|
|
53
120
|
TodoReadTool(),
|
|
54
121
|
TodoWriteTool(),
|
|
@@ -86,12 +153,25 @@ def get_default_tools() -> List[Tool[Any, Any]]:
|
|
|
86
153
|
|
|
87
154
|
task_tool = TaskTool(lambda: base_tools)
|
|
88
155
|
all_tools = base_tools + [task_tool]
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
"
|
|
95
|
-
|
|
96
|
-
|
|
156
|
+
|
|
157
|
+
# Apply allowed_tools filter if specified
|
|
158
|
+
if allowed_tools is not None:
|
|
159
|
+
all_tools = filter_tools_by_names(all_tools, allowed_tools)
|
|
160
|
+
logger.debug(
|
|
161
|
+
"[default_tools] Filtered tool inventory",
|
|
162
|
+
extra={
|
|
163
|
+
"allowed_tools": allowed_tools,
|
|
164
|
+
"filtered_tools": len(all_tools),
|
|
165
|
+
},
|
|
166
|
+
)
|
|
167
|
+
else:
|
|
168
|
+
logger.debug(
|
|
169
|
+
"[default_tools] Built tool inventory",
|
|
170
|
+
extra={
|
|
171
|
+
"base_tools": len(base_tools),
|
|
172
|
+
"dynamic_mcp_tools": len(dynamic_tools),
|
|
173
|
+
"total_tools": len(all_tools),
|
|
174
|
+
},
|
|
175
|
+
)
|
|
176
|
+
|
|
97
177
|
return all_tools
|
ripperdoc/core/hooks/events.py
CHANGED
|
@@ -515,6 +515,10 @@ class HookOutput(BaseModel):
|
|
|
515
515
|
"""Get updated input from PreToolUse hook."""
|
|
516
516
|
if isinstance(self.hook_specific_output, PreToolUseHookOutput):
|
|
517
517
|
return self.hook_specific_output.updated_input
|
|
518
|
+
if isinstance(self.hook_specific_output, PermissionRequestHookOutput):
|
|
519
|
+
decision = self.hook_specific_output.decision
|
|
520
|
+
if decision and decision.updated_input:
|
|
521
|
+
return decision.updated_input
|
|
518
522
|
if isinstance(self.hook_specific_output, dict):
|
|
519
523
|
return self.hook_specific_output.get("updatedInput")
|
|
520
524
|
return None
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""LLM callback helper for prompt-based hooks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
from ripperdoc.core.hooks.executor import LLMCallback
|
|
8
|
+
from ripperdoc.core.query import query_llm
|
|
9
|
+
from ripperdoc.utils.log import get_logger
|
|
10
|
+
from ripperdoc.utils.messages import AssistantMessage, create_user_message
|
|
11
|
+
|
|
12
|
+
logger = get_logger()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _extract_text(message: AssistantMessage) -> str:
|
|
16
|
+
content = message.message.content
|
|
17
|
+
if isinstance(content, str):
|
|
18
|
+
return content
|
|
19
|
+
if isinstance(content, list):
|
|
20
|
+
parts = []
|
|
21
|
+
for block in content:
|
|
22
|
+
text = getattr(block, "text", None) or (
|
|
23
|
+
block.get("text") if isinstance(block, dict) else None
|
|
24
|
+
)
|
|
25
|
+
if text:
|
|
26
|
+
parts.append(str(text))
|
|
27
|
+
return "\n".join(parts)
|
|
28
|
+
return ""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def build_hook_llm_callback(
|
|
32
|
+
*,
|
|
33
|
+
model: str = "quick",
|
|
34
|
+
max_thinking_tokens: int = 0,
|
|
35
|
+
system_prompt: Optional[str] = None,
|
|
36
|
+
) -> LLMCallback:
|
|
37
|
+
"""Build an async callback for prompt hooks using the configured model."""
|
|
38
|
+
|
|
39
|
+
async def _callback(prompt: str) -> str:
|
|
40
|
+
try:
|
|
41
|
+
assistant = await query_llm(
|
|
42
|
+
[create_user_message(prompt)],
|
|
43
|
+
system_prompt or "",
|
|
44
|
+
[],
|
|
45
|
+
max_thinking_tokens=max_thinking_tokens,
|
|
46
|
+
model=model,
|
|
47
|
+
stream=False,
|
|
48
|
+
)
|
|
49
|
+
return _extract_text(assistant).strip()
|
|
50
|
+
except Exception as exc:
|
|
51
|
+
logger.warning(
|
|
52
|
+
"[hooks] Prompt hook LLM callback failed: %s: %s",
|
|
53
|
+
type(exc).__name__,
|
|
54
|
+
exc,
|
|
55
|
+
)
|
|
56
|
+
return f"Prompt hook evaluation failed: {exc}"
|
|
57
|
+
|
|
58
|
+
return _callback
|
|
59
|
+
|
ripperdoc/core/permissions.py
CHANGED
|
@@ -9,6 +9,7 @@ from pathlib import Path
|
|
|
9
9
|
from typing import Any, Awaitable, Callable, Optional, Set
|
|
10
10
|
|
|
11
11
|
from ripperdoc.core.config import config_manager
|
|
12
|
+
from ripperdoc.core.hooks.manager import hook_manager
|
|
12
13
|
from ripperdoc.core.tool import Tool
|
|
13
14
|
from ripperdoc.utils.permissions import PermissionDecision, ToolRule
|
|
14
15
|
from ripperdoc.utils.log import get_logger
|
|
@@ -148,8 +149,9 @@ def make_permission_checker(
|
|
|
148
149
|
return PermissionResult(result=True)
|
|
149
150
|
|
|
150
151
|
try:
|
|
151
|
-
|
|
152
|
-
|
|
152
|
+
needs_permission = True
|
|
153
|
+
if hasattr(tool, "needs_permissions"):
|
|
154
|
+
needs_permission = tool.needs_permissions(parsed_input)
|
|
153
155
|
except (TypeError, AttributeError, ValueError) as exc:
|
|
154
156
|
# Tool implementation error - log and deny for safety
|
|
155
157
|
logger.warning(
|
|
@@ -166,10 +168,25 @@ def make_permission_checker(
|
|
|
166
168
|
)
|
|
167
169
|
|
|
168
170
|
allowed_tools = set(config.allowed_tools or [])
|
|
171
|
+
|
|
172
|
+
global_config = config_manager.get_global_config()
|
|
173
|
+
local_config = config_manager.get_project_local_config(project_path)
|
|
174
|
+
|
|
169
175
|
allow_rules = {
|
|
170
|
-
"Bash":
|
|
176
|
+
"Bash": (
|
|
177
|
+
set(config.bash_allow_rules or [])
|
|
178
|
+
| set(global_config.user_allow_rules or [])
|
|
179
|
+
| set(local_config.local_allow_rules or [])
|
|
180
|
+
| session_tool_rules.get("Bash", set())
|
|
181
|
+
)
|
|
182
|
+
}
|
|
183
|
+
deny_rules = {
|
|
184
|
+
"Bash": (
|
|
185
|
+
set(config.bash_deny_rules or [])
|
|
186
|
+
| set(global_config.user_deny_rules or [])
|
|
187
|
+
| set(local_config.local_deny_rules or [])
|
|
188
|
+
)
|
|
171
189
|
}
|
|
172
|
-
deny_rules = {"Bash": set(config.bash_deny_rules or [])}
|
|
173
190
|
allowed_working_dirs = {
|
|
174
191
|
str(project_path.resolve()),
|
|
175
192
|
*[str(Path(p).resolve()) for p in config.working_directories or []],
|
|
@@ -219,6 +236,22 @@ def make_permission_checker(
|
|
|
219
236
|
rule_suggestions=[ToolRule(tool_name=tool.name, rule_content=tool.name)],
|
|
220
237
|
)
|
|
221
238
|
|
|
239
|
+
# If tool doesn't normally require permission (e.g., read-only Bash),
|
|
240
|
+
# enforce deny rules but otherwise skip prompting.
|
|
241
|
+
if not needs_permission:
|
|
242
|
+
if decision.behavior == "deny":
|
|
243
|
+
return PermissionResult(
|
|
244
|
+
result=False,
|
|
245
|
+
message=decision.message or f"Permission denied for tool '{tool.name}'.",
|
|
246
|
+
decision=decision,
|
|
247
|
+
)
|
|
248
|
+
return PermissionResult(
|
|
249
|
+
result=True,
|
|
250
|
+
message=decision.message,
|
|
251
|
+
updated_input=decision.updated_input,
|
|
252
|
+
decision=decision,
|
|
253
|
+
)
|
|
254
|
+
|
|
222
255
|
if decision.behavior == "allow":
|
|
223
256
|
return PermissionResult(
|
|
224
257
|
result=True,
|
|
@@ -235,6 +268,47 @@ def make_permission_checker(
|
|
|
235
268
|
)
|
|
236
269
|
|
|
237
270
|
# Ask/passthrough flows prompt the user.
|
|
271
|
+
tool_input_dict = (
|
|
272
|
+
parsed_input.model_dump()
|
|
273
|
+
if hasattr(parsed_input, "model_dump")
|
|
274
|
+
else dict(parsed_input)
|
|
275
|
+
if isinstance(parsed_input, dict)
|
|
276
|
+
else {}
|
|
277
|
+
)
|
|
278
|
+
try:
|
|
279
|
+
hook_result = await hook_manager.run_permission_request_async(
|
|
280
|
+
tool.name, tool_input_dict
|
|
281
|
+
)
|
|
282
|
+
if hook_result.outputs:
|
|
283
|
+
updated_input = hook_result.updated_input or decision.updated_input
|
|
284
|
+
if hook_result.should_allow:
|
|
285
|
+
return PermissionResult(
|
|
286
|
+
result=True,
|
|
287
|
+
message=decision.message,
|
|
288
|
+
updated_input=updated_input,
|
|
289
|
+
decision=decision,
|
|
290
|
+
)
|
|
291
|
+
if hook_result.should_block or not hook_result.should_continue:
|
|
292
|
+
reason = (
|
|
293
|
+
hook_result.block_reason
|
|
294
|
+
or hook_result.stop_reason
|
|
295
|
+
or decision.message
|
|
296
|
+
or f"Permission denied for tool '{tool.name}'."
|
|
297
|
+
)
|
|
298
|
+
return PermissionResult(
|
|
299
|
+
result=False,
|
|
300
|
+
message=reason,
|
|
301
|
+
updated_input=updated_input,
|
|
302
|
+
decision=decision,
|
|
303
|
+
)
|
|
304
|
+
except (RuntimeError, ValueError, TypeError, OSError) as exc:
|
|
305
|
+
logger.warning(
|
|
306
|
+
"[permissions] PermissionRequest hook failed: %s: %s",
|
|
307
|
+
type(exc).__name__,
|
|
308
|
+
exc,
|
|
309
|
+
extra={"tool": getattr(tool, "name", None)},
|
|
310
|
+
)
|
|
311
|
+
|
|
238
312
|
input_preview = _format_input_preview(parsed_input, tool_name=tool.name)
|
|
239
313
|
prompt_lines = [
|
|
240
314
|
f"{tool.name}",
|
|
@@ -300,9 +300,10 @@ class OpenAIClient(ProviderClient):
|
|
|
300
300
|
if getattr(chunk, "usage", None):
|
|
301
301
|
streamed_usage.update(openai_usage_tokens(chunk.usage))
|
|
302
302
|
|
|
303
|
-
|
|
303
|
+
choices = getattr(chunk, "choices", None)
|
|
304
|
+
if not choices or len(choices) == 0:
|
|
304
305
|
continue
|
|
305
|
-
delta = getattr(
|
|
306
|
+
delta = getattr(choices[0], "delta", None)
|
|
306
307
|
if not delta:
|
|
307
308
|
continue
|
|
308
309
|
|
|
@@ -486,23 +487,32 @@ class OpenAIClient(ProviderClient):
|
|
|
486
487
|
)
|
|
487
488
|
finish_reason = "stream"
|
|
488
489
|
else:
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
)
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
490
|
+
response_choices = getattr(openai_response, "choices", None)
|
|
491
|
+
if not response_choices or len(response_choices) == 0:
|
|
492
|
+
logger.warning(
|
|
493
|
+
"[openai_client] Empty choices in response",
|
|
494
|
+
extra={"model": model_profile.model},
|
|
495
|
+
)
|
|
496
|
+
content_blocks = [{"type": "text", "text": ""}]
|
|
497
|
+
finish_reason = "error"
|
|
498
|
+
else:
|
|
499
|
+
choice = response_choices[0]
|
|
500
|
+
content_blocks = content_blocks_from_openai_choice(choice, tool_mode)
|
|
501
|
+
finish_reason = cast(Optional[str], getattr(choice, "finish_reason", None))
|
|
502
|
+
message_obj = getattr(choice, "message", None) or choice
|
|
503
|
+
reasoning_content = getattr(message_obj, "reasoning_content", None)
|
|
504
|
+
if reasoning_content:
|
|
505
|
+
response_metadata["reasoning_content"] = reasoning_content
|
|
506
|
+
reasoning_field = getattr(message_obj, "reasoning", None)
|
|
507
|
+
if reasoning_field:
|
|
508
|
+
response_metadata["reasoning"] = reasoning_field
|
|
509
|
+
if "reasoning_content" not in response_metadata and isinstance(
|
|
510
|
+
reasoning_field, str
|
|
511
|
+
):
|
|
512
|
+
response_metadata["reasoning_content"] = reasoning_field
|
|
513
|
+
reasoning_details = getattr(message_obj, "reasoning_details", None)
|
|
514
|
+
if reasoning_details:
|
|
515
|
+
response_metadata["reasoning_details"] = reasoning_details
|
|
506
516
|
|
|
507
517
|
if can_stream:
|
|
508
518
|
if stream_reasoning_text:
|