voidx 1.0.0__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.
- voidx/__init__.py +3 -0
- voidx/agent/__init__.py +0 -0
- voidx/agent/agents.py +439 -0
- voidx/agent/attachments.py +235 -0
- voidx/agent/graph.py +463 -0
- voidx/agent/graph_components/__init__.py +1 -0
- voidx/agent/graph_components/compaction.py +268 -0
- voidx/agent/graph_components/permissions.py +139 -0
- voidx/agent/graph_components/run_loop.py +532 -0
- voidx/agent/graph_components/runtime.py +14 -0
- voidx/agent/graph_components/streaming.py +351 -0
- voidx/agent/graph_components/subagent.py +278 -0
- voidx/agent/graph_components/tool_execution.py +208 -0
- voidx/agent/runtime_context.py +368 -0
- voidx/agent/slash.py +466 -0
- voidx/agent/slash_components/__init__.py +1 -0
- voidx/agent/slash_components/code_ide.py +68 -0
- voidx/agent/slash_components/lsp.py +105 -0
- voidx/agent/slash_components/mcp.py +332 -0
- voidx/agent/slash_components/model.py +419 -0
- voidx/agent/slash_components/runtime.py +55 -0
- voidx/agent/slash_components/skills.py +94 -0
- voidx/agent/state.py +32 -0
- voidx/agent/task_state.py +278 -0
- voidx/agent/tool_filters.py +27 -0
- voidx/config.py +707 -0
- voidx/llm/__init__.py +0 -0
- voidx/llm/catalog.py +188 -0
- voidx/llm/compaction.py +267 -0
- voidx/llm/context.py +43 -0
- voidx/llm/instruction.py +220 -0
- voidx/llm/provider.py +312 -0
- voidx/llm/usage.py +341 -0
- voidx/lsp/__init__.py +30 -0
- voidx/lsp/client.py +259 -0
- voidx/lsp/config.py +172 -0
- voidx/lsp/detector.py +512 -0
- voidx/lsp/errors.py +19 -0
- voidx/lsp/manager.py +280 -0
- voidx/lsp/schema.py +179 -0
- voidx/lsp/service.py +103 -0
- voidx/main.py +154 -0
- voidx/mcp/__init__.py +33 -0
- voidx/mcp/client.py +458 -0
- voidx/mcp/manager.py +267 -0
- voidx/mcp/schema.py +112 -0
- voidx/mcp/tool.py +122 -0
- voidx/mcp_servers/__init__.py +1 -0
- voidx/mcp_servers/web.py +104 -0
- voidx/memory/__init__.py +0 -0
- voidx/memory/context_frames.py +188 -0
- voidx/memory/model_profiles.py +98 -0
- voidx/memory/runtime_state.py +240 -0
- voidx/memory/session.py +272 -0
- voidx/memory/store.py +245 -0
- voidx/memory/transcript.py +137 -0
- voidx/permission/__init__.py +28 -0
- voidx/permission/engine.py +430 -0
- voidx/permission/evaluate.py +114 -0
- voidx/permission/sandbox.py +280 -0
- voidx/permission/schema.py +24 -0
- voidx/permission/service.py +314 -0
- voidx/permission/wildcard.py +34 -0
- voidx/skills/__init__.py +18 -0
- voidx/skills/bundled/superpowers/receiving-code-review/SKILL.md +30 -0
- voidx/skills/bundled/superpowers/requesting-code-review/SKILL.md +27 -0
- voidx/skills/bundled/superpowers/systematic-debugging/SKILL.md +36 -0
- voidx/skills/bundled/superpowers/test-driven-development/SKILL.md +33 -0
- voidx/skills/bundled/superpowers/verification-before-completion/SKILL.md +31 -0
- voidx/skills/bundled/superpowers/writing-plans/SKILL.md +27 -0
- voidx/skills/policy.py +97 -0
- voidx/skills/registry.py +162 -0
- voidx/skills/schema.py +47 -0
- voidx/skills/service.py +199 -0
- voidx/tools/__init__.py +0 -0
- voidx/tools/agent.py +81 -0
- voidx/tools/base.py +86 -0
- voidx/tools/bash.py +105 -0
- voidx/tools/file_ops.py +193 -0
- voidx/tools/lsp.py +155 -0
- voidx/tools/registry.py +104 -0
- voidx/tools/repomap.py +238 -0
- voidx/tools/search.py +162 -0
- voidx/tools/task_status.py +57 -0
- voidx/tools/task_tracker.py +81 -0
- voidx/tools/todo.py +82 -0
- voidx/tools/web_content.py +357 -0
- voidx/tools/web_mcp.py +107 -0
- voidx/tools/webfetch.py +155 -0
- voidx/tools/websearch.py +276 -0
- voidx/ui/__init__.py +0 -0
- voidx/ui/app.py +1033 -0
- voidx/ui/app_components/__init__.py +1 -0
- voidx/ui/app_components/clipboard_image.py +245 -0
- voidx/ui/app_components/commands.py +18 -0
- voidx/ui/app_components/controls.py +29 -0
- voidx/ui/app_components/file_picker.py +115 -0
- voidx/ui/app_components/formatting.py +187 -0
- voidx/ui/app_components/git_changes.py +51 -0
- voidx/ui/app_components/rendering.py +1169 -0
- voidx/ui/browse.py +160 -0
- voidx/ui/capture.py +169 -0
- voidx/ui/code_ide.py +251 -0
- voidx/ui/commands.py +83 -0
- voidx/ui/console.py +381 -0
- voidx/ui/console_components/__init__.py +1 -0
- voidx/ui/console_components/formatting.py +96 -0
- voidx/ui/console_components/streaming.py +253 -0
- voidx/ui/diff.py +331 -0
- voidx/ui/dock.py +372 -0
- voidx/ui/dock_components/__init__.py +1 -0
- voidx/ui/dock_components/formatting.py +123 -0
- voidx/ui/dock_components/nodes.py +401 -0
- voidx/ui/dock_components/state.py +51 -0
- voidx/ui/event_components/__init__.py +1 -0
- voidx/ui/event_components/schema.py +249 -0
- voidx/ui/events.py +341 -0
- voidx/ui/session_changes.py +163 -0
- voidx/ui/startup.py +161 -0
- voidx/ui/transcript.py +148 -0
- voidx/ui/tree.py +316 -0
- voidx-1.0.0.dist-info/METADATA +59 -0
- voidx-1.0.0.dist-info/RECORD +126 -0
- voidx-1.0.0.dist-info/WHEEL +5 -0
- voidx-1.0.0.dist-info/entry_points.txt +2 -0
- voidx-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
"""Context compaction methods for the agent graph."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage, ToolMessage
|
|
6
|
+
|
|
7
|
+
from voidx.agent.graph_components.runtime import console, ui
|
|
8
|
+
from voidx.agent.graph_components.streaming import extract_text, stream_llm
|
|
9
|
+
from voidx.llm.provider import resolve_protocol
|
|
10
|
+
from voidx.llm.usage import estimate_context_tokens, estimate_message_tokens, extract_token_usage
|
|
11
|
+
from voidx.memory.context_frames import save_context_frame_from_messages
|
|
12
|
+
from voidx.ui.console import StreamingRenderer
|
|
13
|
+
from voidx.ui.dock import dock
|
|
14
|
+
from voidx.ui.events import StatusFinished, StatusUpdated, ui_events
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class GraphCompactionMixin:
|
|
18
|
+
async def _maybe_compact(
|
|
19
|
+
self,
|
|
20
|
+
messages: list,
|
|
21
|
+
session_msgs: list,
|
|
22
|
+
*,
|
|
23
|
+
force: bool = False,
|
|
24
|
+
ask: bool = True,
|
|
25
|
+
) -> tuple[list | None, str | None]:
|
|
26
|
+
"""Check overflow and compact if needed."""
|
|
27
|
+
total_tokens = estimate_context_tokens(messages, self.config.model.model)
|
|
28
|
+
tokens = {"total": total_tokens, "input": total_tokens, "output": 0, "reasoning": 0}
|
|
29
|
+
|
|
30
|
+
if not force and not self._compaction.is_overflow(tokens):
|
|
31
|
+
return None, None
|
|
32
|
+
|
|
33
|
+
if not force and ask and getattr(self.config, "ask_compact", False):
|
|
34
|
+
should_compact = await self._ask_compact(total_tokens)
|
|
35
|
+
if not should_compact:
|
|
36
|
+
if dock.active and ui_events.is_running:
|
|
37
|
+
await ui_events.emit(StatusFinished(
|
|
38
|
+
status_id="compaction",
|
|
39
|
+
label="Compaction skipped",
|
|
40
|
+
remove=False,
|
|
41
|
+
))
|
|
42
|
+
else:
|
|
43
|
+
ui.print("[dim]Compaction skipped[/dim]")
|
|
44
|
+
return None, None
|
|
45
|
+
|
|
46
|
+
if dock.active and ui_events.is_running:
|
|
47
|
+
await ui_events.emit(StatusUpdated(
|
|
48
|
+
status_id="compaction",
|
|
49
|
+
label="Compacting context",
|
|
50
|
+
detail=(
|
|
51
|
+
f"{total_tokens} tokens exceed the active context budget"
|
|
52
|
+
if not force
|
|
53
|
+
else f"manual compaction of {len(messages)} messages"
|
|
54
|
+
),
|
|
55
|
+
stage="compacting",
|
|
56
|
+
))
|
|
57
|
+
else:
|
|
58
|
+
ui.print(
|
|
59
|
+
"[yellow]Context overflow — compacting...[/yellow]"
|
|
60
|
+
if not force
|
|
61
|
+
else "[yellow]Compacting context...[/yellow]"
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
head_msgs, tail_id = self._compaction.select(messages)
|
|
65
|
+
|
|
66
|
+
if not head_msgs or not tail_id:
|
|
67
|
+
# Hard fallback: keep only last 6 messages
|
|
68
|
+
keep = min(6, len(messages))
|
|
69
|
+
if dock.active and ui_events.is_running:
|
|
70
|
+
await ui_events.emit(StatusUpdated(
|
|
71
|
+
status_id="compaction",
|
|
72
|
+
label="Compacting context",
|
|
73
|
+
detail=f"fallback truncation, keeping last {keep} messages",
|
|
74
|
+
stage="compacting",
|
|
75
|
+
))
|
|
76
|
+
await ui_events.emit(StatusFinished(
|
|
77
|
+
status_id="compaction",
|
|
78
|
+
label=f"Compaction fallback kept last {keep} messages",
|
|
79
|
+
remove=False,
|
|
80
|
+
))
|
|
81
|
+
else:
|
|
82
|
+
ui.print(f"[dim]Aggressive truncation: keeping last {keep} messages[/dim]")
|
|
83
|
+
# Remove old messages, keep system + last N
|
|
84
|
+
system_msgs = [m for m in messages if isinstance(m, SystemMessage)]
|
|
85
|
+
other_msgs = [m for m in messages if not isinstance(m, SystemMessage)]
|
|
86
|
+
messages.clear()
|
|
87
|
+
messages.extend(system_msgs)
|
|
88
|
+
messages.extend(other_msgs[-keep:])
|
|
89
|
+
return messages[:max(0, len(messages) - keep)], None
|
|
90
|
+
|
|
91
|
+
# Run compaction agent
|
|
92
|
+
try:
|
|
93
|
+
if dock.active and ui_events.is_running:
|
|
94
|
+
await ui_events.emit(StatusUpdated(
|
|
95
|
+
status_id="compaction",
|
|
96
|
+
label="Compacting context",
|
|
97
|
+
detail=f"summarizing {len(head_msgs)} old messages",
|
|
98
|
+
stage="compacting",
|
|
99
|
+
))
|
|
100
|
+
previous_summary = getattr(self, "_compaction_summary", "") or None
|
|
101
|
+
summary = await self._run_compaction_agent(head_msgs, previous_summary)
|
|
102
|
+
except Exception as e:
|
|
103
|
+
if dock.active and ui_events.is_running:
|
|
104
|
+
await ui_events.emit(StatusUpdated(
|
|
105
|
+
status_id="compaction",
|
|
106
|
+
label="Compaction agent failed",
|
|
107
|
+
detail=f"{e}; falling back to truncation",
|
|
108
|
+
stage="compacting",
|
|
109
|
+
))
|
|
110
|
+
else:
|
|
111
|
+
ui.print(f"[dim]Compaction agent failed ({e}) — aggressive truncation[/dim]")
|
|
112
|
+
keep = min(6, len(messages))
|
|
113
|
+
system_msgs = [m for m in messages if isinstance(m, SystemMessage)]
|
|
114
|
+
other_msgs = [m for m in messages if not isinstance(m, SystemMessage)]
|
|
115
|
+
messages.clear()
|
|
116
|
+
messages.extend(system_msgs)
|
|
117
|
+
messages.extend(other_msgs[-keep:])
|
|
118
|
+
if dock.active and ui_events.is_running:
|
|
119
|
+
await ui_events.emit(StatusFinished(
|
|
120
|
+
status_id="compaction",
|
|
121
|
+
label=f"Compaction fallback kept last {keep} messages",
|
|
122
|
+
ok=False,
|
|
123
|
+
remove=False,
|
|
124
|
+
))
|
|
125
|
+
return messages[:max(0, len(messages) - keep)], None
|
|
126
|
+
|
|
127
|
+
if summary:
|
|
128
|
+
keep_from = len(head_msgs)
|
|
129
|
+
tail_msgs = messages[keep_from:]
|
|
130
|
+
messages.clear()
|
|
131
|
+
messages.extend(tail_msgs)
|
|
132
|
+
self._pending_summary = summary
|
|
133
|
+
self._compaction_summary = summary
|
|
134
|
+
self._compaction.compaction_count += 1
|
|
135
|
+
await self._persist_compaction(head_msgs)
|
|
136
|
+
if dock.active and ui_events.is_running:
|
|
137
|
+
await ui_events.emit(StatusFinished(
|
|
138
|
+
status_id="compaction",
|
|
139
|
+
label=f"Compacted {len(head_msgs)} messages into summary",
|
|
140
|
+
remove=False,
|
|
141
|
+
))
|
|
142
|
+
else:
|
|
143
|
+
ui.print(f"[dim]Compacted: {len(head_msgs)} messages → summary[/dim]")
|
|
144
|
+
elif dock.active and ui_events.is_running:
|
|
145
|
+
await ui_events.emit(StatusFinished(
|
|
146
|
+
status_id="compaction",
|
|
147
|
+
label="Compaction produced no summary",
|
|
148
|
+
ok=False,
|
|
149
|
+
remove=False,
|
|
150
|
+
))
|
|
151
|
+
return None, None
|
|
152
|
+
else:
|
|
153
|
+
return None, None
|
|
154
|
+
|
|
155
|
+
return head_msgs, tail_id
|
|
156
|
+
|
|
157
|
+
async def _ask_compact(self, total_tokens: int) -> bool:
|
|
158
|
+
choices = [
|
|
159
|
+
("Compact", "compact", "Summarize older context and continue"),
|
|
160
|
+
("Skip once", "skip", "Continue without compacting this turn"),
|
|
161
|
+
]
|
|
162
|
+
app = getattr(self, "_app", None)
|
|
163
|
+
if app:
|
|
164
|
+
choice = await app.ask_choice("Compact context?", choices)
|
|
165
|
+
return choice == "compact"
|
|
166
|
+
ui.print("")
|
|
167
|
+
ui.print(f" [yellow]Context is large ({total_tokens} tokens); compacting automatically.[/yellow]")
|
|
168
|
+
return True
|
|
169
|
+
|
|
170
|
+
async def _persist_compaction(self, head_messages: list) -> None:
|
|
171
|
+
if getattr(self, "_session", None) is None:
|
|
172
|
+
return
|
|
173
|
+
if hasattr(self, "_persist_runtime_state"):
|
|
174
|
+
await self._persist_runtime_state()
|
|
175
|
+
last_message_id = _max_persisted_message_id(head_messages)
|
|
176
|
+
if last_message_id is None:
|
|
177
|
+
return
|
|
178
|
+
from voidx.memory.session import delete_messages_through
|
|
179
|
+
|
|
180
|
+
await delete_messages_through(self._session.id, last_message_id)
|
|
181
|
+
|
|
182
|
+
async def _compact_session_history(self, *, force: bool = True) -> bool:
|
|
183
|
+
if getattr(self, "_session", None) is None:
|
|
184
|
+
ui.print("[dim]No active session to compact.[/dim]")
|
|
185
|
+
return False
|
|
186
|
+
|
|
187
|
+
from voidx.agent.attachments import parse_structured_content
|
|
188
|
+
from voidx.memory.session import load_messages
|
|
189
|
+
|
|
190
|
+
rows = await load_messages(self._session.id)
|
|
191
|
+
messages = []
|
|
192
|
+
for row in rows:
|
|
193
|
+
msg_id = str(row.id) if row.id is not None else None
|
|
194
|
+
if row.role == "system":
|
|
195
|
+
messages.append(SystemMessage(content=row.content, id=msg_id))
|
|
196
|
+
elif row.role == "user":
|
|
197
|
+
messages.append(HumanMessage(
|
|
198
|
+
content=parse_structured_content(row.content, row.content_format),
|
|
199
|
+
id=msg_id,
|
|
200
|
+
))
|
|
201
|
+
elif row.role == "assistant":
|
|
202
|
+
messages.append(AIMessage(
|
|
203
|
+
content=parse_structured_content(row.content, row.content_format),
|
|
204
|
+
tool_calls=row.tool_calls or [],
|
|
205
|
+
id=msg_id,
|
|
206
|
+
))
|
|
207
|
+
elif row.role == "tool":
|
|
208
|
+
messages.append(ToolMessage(
|
|
209
|
+
content=row.content,
|
|
210
|
+
tool_call_id=row.tool_call_id or "",
|
|
211
|
+
id=msg_id,
|
|
212
|
+
))
|
|
213
|
+
|
|
214
|
+
head, _tail_id = await self._maybe_compact(messages, rows, force=force, ask=False)
|
|
215
|
+
return bool(head)
|
|
216
|
+
|
|
217
|
+
async def _run_compaction_agent(self, head_messages: list, previous_summary: str | None) -> str | None:
|
|
218
|
+
"""Run the compaction agent to generate a structured summary."""
|
|
219
|
+
from voidx.agent.agents import COMPACTION_PROMPT
|
|
220
|
+
|
|
221
|
+
if self.model is None:
|
|
222
|
+
return None
|
|
223
|
+
|
|
224
|
+
prompt = self._compaction.build_prompt(head_messages, previous_summary)
|
|
225
|
+
renderer = StreamingRenderer(console, debug=self._debug, stream_to_dock=False)
|
|
226
|
+
|
|
227
|
+
messages = [SystemMessage(content=COMPACTION_PROMPT)]
|
|
228
|
+
messages.append(HumanMessage(content=prompt))
|
|
229
|
+
|
|
230
|
+
# Use a cheap/fast call for compaction — no tools
|
|
231
|
+
context_tokens = estimate_context_tokens(messages, self.config.model.model)
|
|
232
|
+
self._usage_stats.update_context(context_tokens)
|
|
233
|
+
if self._session is not None:
|
|
234
|
+
await save_context_frame_from_messages(
|
|
235
|
+
session_id=self._session.id,
|
|
236
|
+
frame_kind="compaction",
|
|
237
|
+
agent_role="compaction",
|
|
238
|
+
provider=self.config.model.provider,
|
|
239
|
+
model=self.config.model.model,
|
|
240
|
+
messages=messages,
|
|
241
|
+
token_estimate=context_tokens,
|
|
242
|
+
metadata={
|
|
243
|
+
"head_message_count": len(head_messages),
|
|
244
|
+
"has_previous_summary": previous_summary is not None,
|
|
245
|
+
},
|
|
246
|
+
)
|
|
247
|
+
assistant_msg = await stream_llm(self.model, messages, renderer, resolve_protocol(self.config.model))
|
|
248
|
+
self._usage_stats.record_call(
|
|
249
|
+
extract_token_usage(assistant_msg),
|
|
250
|
+
fallback_input_tokens=context_tokens,
|
|
251
|
+
fallback_output_tokens=estimate_message_tokens(assistant_msg, self.config.model.model),
|
|
252
|
+
messages=messages,
|
|
253
|
+
model=self.config.model.model,
|
|
254
|
+
cache_key=f"{self.config.model.provider}/{self.config.model.model}",
|
|
255
|
+
)
|
|
256
|
+
text = extract_text(assistant_msg)
|
|
257
|
+
return text if text else None
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _max_persisted_message_id(messages: list) -> int | None:
|
|
261
|
+
ids: list[int] = []
|
|
262
|
+
for message in messages:
|
|
263
|
+
raw = getattr(message, "id", None)
|
|
264
|
+
try:
|
|
265
|
+
ids.append(int(raw))
|
|
266
|
+
except (TypeError, ValueError):
|
|
267
|
+
continue
|
|
268
|
+
return max(ids) if ids else None
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Tool permission UI adapter for the agent graph."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from voidx.agent.graph_components.runtime import ui
|
|
6
|
+
from voidx.permission.engine import (
|
|
7
|
+
PermissionContext,
|
|
8
|
+
authorize_tool_call,
|
|
9
|
+
build_pattern,
|
|
10
|
+
classify_tool_call,
|
|
11
|
+
)
|
|
12
|
+
from voidx.ui.events import PermissionToolDetail
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class GraphPermissionMixin:
|
|
16
|
+
_needs_failure_check: dict[str, dict]
|
|
17
|
+
|
|
18
|
+
async def _authorize_tool_calls(
|
|
19
|
+
self,
|
|
20
|
+
tool_calls: list[dict],
|
|
21
|
+
agent_name: str,
|
|
22
|
+
plan_mode: bool,
|
|
23
|
+
session_id: str,
|
|
24
|
+
interaction_mode: str | None = None,
|
|
25
|
+
) -> tuple[list[dict], list[tuple[dict, str]]]:
|
|
26
|
+
approved: list[dict] = []
|
|
27
|
+
denied: list[tuple[dict, str]] = []
|
|
28
|
+
need_ask: list[dict] = []
|
|
29
|
+
|
|
30
|
+
if not hasattr(self, '_needs_failure_check'):
|
|
31
|
+
self._needs_failure_check = {}
|
|
32
|
+
|
|
33
|
+
context = PermissionContext.from_service(
|
|
34
|
+
self._permission,
|
|
35
|
+
workspace=self._workspace,
|
|
36
|
+
interaction_mode=interaction_mode,
|
|
37
|
+
plan_mode=plan_mode,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
for tc in tool_calls:
|
|
41
|
+
decision = authorize_tool_call(tc, context)
|
|
42
|
+
if decision.action == "allow":
|
|
43
|
+
approved.append(decision.tool_call)
|
|
44
|
+
if decision.failure_check:
|
|
45
|
+
self._needs_failure_check[decision.tool_call.get("id", "")] = decision.tool_call
|
|
46
|
+
elif decision.action == "deny":
|
|
47
|
+
denied.append((decision.tool_call, decision.reason))
|
|
48
|
+
else:
|
|
49
|
+
need_ask.append(decision.tool_call)
|
|
50
|
+
|
|
51
|
+
if need_ask:
|
|
52
|
+
await self._ask_and_apply_permission(need_ask, approved, denied)
|
|
53
|
+
|
|
54
|
+
return approved, denied
|
|
55
|
+
|
|
56
|
+
async def _ask_and_apply_permission(
|
|
57
|
+
self,
|
|
58
|
+
need_ask: list[dict],
|
|
59
|
+
approved: list[dict],
|
|
60
|
+
denied: list[tuple[dict, str]],
|
|
61
|
+
) -> None:
|
|
62
|
+
choice = await self._ask_tool_permission(need_ask)
|
|
63
|
+
if choice is None:
|
|
64
|
+
choice = "n"
|
|
65
|
+
|
|
66
|
+
if choice == "a":
|
|
67
|
+
for tc in need_ask:
|
|
68
|
+
self._permission.allow_silent(tc["name"])
|
|
69
|
+
self._notice_permission_result(f"{len(need_ask)} tools allowed for this session")
|
|
70
|
+
approved.extend(need_ask)
|
|
71
|
+
elif choice == "y":
|
|
72
|
+
self._notice_permission_result(f"{len(need_ask)} tools allowed once")
|
|
73
|
+
approved.extend(need_ask)
|
|
74
|
+
else:
|
|
75
|
+
self._notice_permission_result(f"{len(need_ask)} tools denied")
|
|
76
|
+
for tc in need_ask:
|
|
77
|
+
denied.append((tc, f"User denied: {tc['name']}"))
|
|
78
|
+
|
|
79
|
+
async def _ask_tool_permission(self, tool_calls: list[dict]) -> str | None:
|
|
80
|
+
tool_list = ", ".join(t["name"] for t in tool_calls)
|
|
81
|
+
choices = [
|
|
82
|
+
("Yes, always", "a", "Allow these tools for this session"),
|
|
83
|
+
("Yes", "y", "Allow this tool use once"),
|
|
84
|
+
("No", "n", "Deny these tools"),
|
|
85
|
+
]
|
|
86
|
+
details = [item.model_dump() for item in self._permission_tool_details(tool_calls)]
|
|
87
|
+
|
|
88
|
+
if not self._app:
|
|
89
|
+
ui.print("")
|
|
90
|
+
ui.print(f" [yellow]Allow tools: [bold]{tool_list}[/bold]?[/yellow]")
|
|
91
|
+
|
|
92
|
+
if self._app:
|
|
93
|
+
return await self._app.ask_choice("Allow tool use?", choices, details=details)
|
|
94
|
+
return "n"
|
|
95
|
+
|
|
96
|
+
def _notice_permission_result(self, message: str) -> None:
|
|
97
|
+
if self._show_permission_output(message):
|
|
98
|
+
return
|
|
99
|
+
ui.print(f"[dim]✓ {message}[/dim]")
|
|
100
|
+
|
|
101
|
+
def _notify_tool_failure(self, tc: dict, result) -> None:
|
|
102
|
+
"""Notify user when an auto-approved tool (on-failure policy) fails.
|
|
103
|
+
|
|
104
|
+
The tool was auto-allowed by the on-failure policy and then
|
|
105
|
+
actually failed. Let the user know so they can decide whether
|
|
106
|
+
to abort or let the agent retry.
|
|
107
|
+
"""
|
|
108
|
+
tool_name = tc.get("name", "unknown")
|
|
109
|
+
error_preview = str(result.output)[:200]
|
|
110
|
+
message = f"[on-failure] '{tool_name}' failed: {error_preview}"
|
|
111
|
+
if not self._show_permission_output(message):
|
|
112
|
+
ui.print(f"\n[yellow]{message}[/yellow]")
|
|
113
|
+
|
|
114
|
+
def _show_permission_output(self, message: str) -> bool:
|
|
115
|
+
app = self._app
|
|
116
|
+
if not app:
|
|
117
|
+
return False
|
|
118
|
+
show_transient = getattr(app, "show_transient_output", None)
|
|
119
|
+
if callable(show_transient):
|
|
120
|
+
show_transient(message, title="Permission")
|
|
121
|
+
return True
|
|
122
|
+
app.begin_command_output("Permission")
|
|
123
|
+
app.append_command_output(message)
|
|
124
|
+
return True
|
|
125
|
+
|
|
126
|
+
def _clear_failure_check(self, cid: str) -> None:
|
|
127
|
+
"""Remove a tool call ID from on-failure tracking (used on success)."""
|
|
128
|
+
self._needs_failure_check.pop(cid, None)
|
|
129
|
+
|
|
130
|
+
def _permission_tool_details(self, tool_calls: list[dict]) -> list[PermissionToolDetail]:
|
|
131
|
+
details: list[PermissionToolDetail] = []
|
|
132
|
+
for call in tool_calls:
|
|
133
|
+
classified = classify_tool_call(call)
|
|
134
|
+
details.append(PermissionToolDetail(
|
|
135
|
+
name=classified.name,
|
|
136
|
+
pattern=build_pattern(classified.name, classified.args),
|
|
137
|
+
args=classified.args,
|
|
138
|
+
))
|
|
139
|
+
return details
|