kiwimatecoder 0.1.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.
- kiwimatecoder/__init__.py +8 -0
- kiwimatecoder/__main__.py +12 -0
- kiwimatecoder/acp/__init__.py +13 -0
- kiwimatecoder/acp/protocol.py +115 -0
- kiwimatecoder/acp/server.py +823 -0
- kiwimatecoder/acp/stdio.py +32 -0
- kiwimatecoder/agent.py +1162 -0
- kiwimatecoder/ai.py +68 -0
- kiwimatecoder/audit.py +65 -0
- kiwimatecoder/browser.py +261 -0
- kiwimatecoder/catalog.py +445 -0
- kiwimatecoder/checkpoints.py +103 -0
- kiwimatecoder/client.py +820 -0
- kiwimatecoder/clipboard.py +99 -0
- kiwimatecoder/commands.py +6757 -0
- kiwimatecoder/config.py +5335 -0
- kiwimatecoder/diagnostics.py +295 -0
- kiwimatecoder/documents.py +299 -0
- kiwimatecoder/evals/__init__.py +39 -0
- kiwimatecoder/evals/cases.py +230 -0
- kiwimatecoder/evals/render.py +46 -0
- kiwimatecoder/evals/runner.py +319 -0
- kiwimatecoder/events.py +95 -0
- kiwimatecoder/forge.py +172 -0
- kiwimatecoder/git.py +164 -0
- kiwimatecoder/headless.py +255 -0
- kiwimatecoder/hooks.py +170 -0
- kiwimatecoder/hunks.py +260 -0
- kiwimatecoder/i18n.py +368 -0
- kiwimatecoder/images.py +195 -0
- kiwimatecoder/index/__init__.py +14 -0
- kiwimatecoder/index/builder.py +376 -0
- kiwimatecoder/index/embeddings.py +151 -0
- kiwimatecoder/index/search.py +247 -0
- kiwimatecoder/index/store.py +251 -0
- kiwimatecoder/instructions.py +83 -0
- kiwimatecoder/jobs.py +594 -0
- kiwimatecoder/lsp/__init__.py +56 -0
- kiwimatecoder/lsp/client.py +548 -0
- kiwimatecoder/lsp/manager.py +353 -0
- kiwimatecoder/lsp/protocol.py +79 -0
- kiwimatecoder/main.py +4258 -0
- kiwimatecoder/mcp/__init__.py +45 -0
- kiwimatecoder/mcp/client.py +385 -0
- kiwimatecoder/mcp/errors.py +7 -0
- kiwimatecoder/mcp/manager.py +253 -0
- kiwimatecoder/mcp/protocol.py +132 -0
- kiwimatecoder/media.py +904 -0
- kiwimatecoder/memory.py +141 -0
- kiwimatecoder/network.py +112 -0
- kiwimatecoder/notify.py +139 -0
- kiwimatecoder/permissions.py +171 -0
- kiwimatecoder/pickers.py +417 -0
- kiwimatecoder/plugins.py +245 -0
- kiwimatecoder/pricing.py +167 -0
- kiwimatecoder/prompts.py +213 -0
- kiwimatecoder/providers.py +342 -0
- kiwimatecoder/redaction.py +66 -0
- kiwimatecoder/remote.py +294 -0
- kiwimatecoder/repl.py +1388 -0
- kiwimatecoder/routing.py +65 -0
- kiwimatecoder/sandbox.py +169 -0
- kiwimatecoder/sdk.py +146 -0
- kiwimatecoder/session.py +574 -0
- kiwimatecoder/share.py +386 -0
- kiwimatecoder/shell.py +660 -0
- kiwimatecoder/skills.py +138 -0
- kiwimatecoder/sync.py +513 -0
- kiwimatecoder/team.py +249 -0
- kiwimatecoder/telemetry.py +392 -0
- kiwimatecoder/templates.py +108 -0
- kiwimatecoder/tools/__init__.py +190 -0
- kiwimatecoder/tools/ask.py +62 -0
- kiwimatecoder/tools/base.py +69 -0
- kiwimatecoder/tools/browser.py +261 -0
- kiwimatecoder/tools/edit_file.py +143 -0
- kiwimatecoder/tools/forge.py +207 -0
- kiwimatecoder/tools/git.py +158 -0
- kiwimatecoder/tools/image.py +300 -0
- kiwimatecoder/tools/io.py +346 -0
- kiwimatecoder/tools/list_dir.py +67 -0
- kiwimatecoder/tools/lsp.py +254 -0
- kiwimatecoder/tools/memory.py +115 -0
- kiwimatecoder/tools/paths.py +234 -0
- kiwimatecoder/tools/read_file.py +113 -0
- kiwimatecoder/tools/registry.py +91 -0
- kiwimatecoder/tools/run_bash.py +189 -0
- kiwimatecoder/tools/search.py +224 -0
- kiwimatecoder/tools/selection.py +108 -0
- kiwimatecoder/tools/shell.py +158 -0
- kiwimatecoder/tools/skill.py +46 -0
- kiwimatecoder/tools/task.py +61 -0
- kiwimatecoder/tools/todo.py +77 -0
- kiwimatecoder/tools/web.py +146 -0
- kiwimatecoder/tools/write_file.py +83 -0
- kiwimatecoder/ui.py +189 -0
- kiwimatecoder/updater.py +502 -0
- kiwimatecoder/web.py +499 -0
- kiwimatecoder-0.1.0.dist-info/METADATA +2016 -0
- kiwimatecoder-0.1.0.dist-info/RECORD +104 -0
- kiwimatecoder-0.1.0.dist-info/WHEEL +5 -0
- kiwimatecoder-0.1.0.dist-info/entry_points.txt +2 -0
- kiwimatecoder-0.1.0.dist-info/licenses/LICENSE +21 -0
- kiwimatecoder-0.1.0.dist-info/top_level.txt +1 -0
kiwimatecoder/agent.py
ADDED
|
@@ -0,0 +1,1162 @@
|
|
|
1
|
+
"""The agentic tool-calling loop."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import time
|
|
9
|
+
from contextlib import nullcontext
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any, Callable
|
|
13
|
+
|
|
14
|
+
from rich.console import Console
|
|
15
|
+
from rich.markup import escape
|
|
16
|
+
from rich.text import Text
|
|
17
|
+
|
|
18
|
+
from kiwimatecoder import audit, events, hooks, images, tools, ui
|
|
19
|
+
from kiwimatecoder.client import (
|
|
20
|
+
AssembledToolCall,
|
|
21
|
+
Done,
|
|
22
|
+
ProviderError,
|
|
23
|
+
TextDelta,
|
|
24
|
+
ToolCallAssembler,
|
|
25
|
+
ToolCallDelta,
|
|
26
|
+
UnifiedClient,
|
|
27
|
+
Usage,
|
|
28
|
+
)
|
|
29
|
+
from kiwimatecoder.i18n import t
|
|
30
|
+
from kiwimatecoder.permissions import ConfirmFn, PermissionMode, gate
|
|
31
|
+
from kiwimatecoder.prompts import build_system_prompt
|
|
32
|
+
from kiwimatecoder.redaction import redact
|
|
33
|
+
from kiwimatecoder.session import Session
|
|
34
|
+
from kiwimatecoder.tools.base import FunctionTool, ToolResult
|
|
35
|
+
|
|
36
|
+
POST_EDIT_DIAGNOSTICS_TIMEOUT = 3.0
|
|
37
|
+
MAX_EDIT_DIAGNOSTICS = 10
|
|
38
|
+
TASK_RESULT_MAX_CHARS = 8000
|
|
39
|
+
|
|
40
|
+
EventCallback = Callable[[str, dict[str, Any]], None]
|
|
41
|
+
|
|
42
|
+
_log = logging.getLogger(__name__)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class TaskConsole(Console):
|
|
46
|
+
"""Console wrapper that tags every child line with dim ``[task]``.
|
|
47
|
+
|
|
48
|
+
Prints are delegated to the parent console so subagent output stays in the
|
|
49
|
+
same stream (and under the same test harness); the tag is emitted once per
|
|
50
|
+
line even when text is streamed with ``end=""``.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
def __init__(self, parent: Console, label: str = "task") -> None:
|
|
54
|
+
super().__init__(quiet=True)
|
|
55
|
+
self._parent = parent
|
|
56
|
+
self._tag = f"[dim]\\[{label}][/dim]"
|
|
57
|
+
self._at_line_start = True
|
|
58
|
+
|
|
59
|
+
def print(self, *objects: Any, **kwargs: Any) -> None:
|
|
60
|
+
end = str(kwargs.get("end", "\n"))
|
|
61
|
+
if objects and self._at_line_start:
|
|
62
|
+
self._parent.print(self._tag, end=" ", markup=True, highlight=False)
|
|
63
|
+
self._at_line_start = False
|
|
64
|
+
self._parent.print(*objects, **kwargs)
|
|
65
|
+
if end.endswith("\n"):
|
|
66
|
+
self._at_line_start = True
|
|
67
|
+
|
|
68
|
+
def status(self, status: Any, **kwargs: Any) -> Any:
|
|
69
|
+
return self._parent.status(f"{self._tag} {status}", **kwargs)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class _NoopStatus:
|
|
73
|
+
"""Status stand-in that does nothing when spinners are disabled."""
|
|
74
|
+
|
|
75
|
+
def start(self) -> None:
|
|
76
|
+
pass
|
|
77
|
+
|
|
78
|
+
def stop(self) -> None:
|
|
79
|
+
pass
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass
|
|
83
|
+
class _PreparedCall:
|
|
84
|
+
"""A tool call that passed parsing, approval, hooks, and checkpointing."""
|
|
85
|
+
|
|
86
|
+
call: AssembledToolCall
|
|
87
|
+
tool: FunctionTool
|
|
88
|
+
args: dict[str, Any]
|
|
89
|
+
original_args: dict[str, Any]
|
|
90
|
+
summary: str
|
|
91
|
+
partial_hunks: tuple[int, ...] | None = None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class Agent:
|
|
95
|
+
"""Drives one conversational turn, including any tool calls it triggers."""
|
|
96
|
+
|
|
97
|
+
session: Session
|
|
98
|
+
console: Console
|
|
99
|
+
confirm: ConfirmFn
|
|
100
|
+
|
|
101
|
+
def __init__(
|
|
102
|
+
self,
|
|
103
|
+
session: Session,
|
|
104
|
+
console: Console,
|
|
105
|
+
confirm: ConfirmFn,
|
|
106
|
+
bus: events.EventBus | None = None,
|
|
107
|
+
ascii_mode: bool = False,
|
|
108
|
+
output_mode: str = "normal",
|
|
109
|
+
event_handler: EventCallback | None = None,
|
|
110
|
+
max_turns: int | None = None,
|
|
111
|
+
render_text: bool = True,
|
|
112
|
+
spinner: str = "auto",
|
|
113
|
+
) -> None:
|
|
114
|
+
self.session = session
|
|
115
|
+
self.console = console
|
|
116
|
+
self.confirm = confirm
|
|
117
|
+
self.bus = bus if bus is not None else events.BUS
|
|
118
|
+
self._budget_warned = False
|
|
119
|
+
self.ascii_mode = bool(ascii_mode)
|
|
120
|
+
self.output_mode = output_mode if output_mode in ui.OUTPUT_MODES else "normal"
|
|
121
|
+
self.event_handler = event_handler
|
|
122
|
+
self.max_turns = max_turns
|
|
123
|
+
self.render_text = render_text
|
|
124
|
+
self.spinner = spinner if spinner in ui.SPINNER_MODES else "auto"
|
|
125
|
+
self.spinner_enabled = self.spinner != "off"
|
|
126
|
+
|
|
127
|
+
def _emit(self, name: str, **payload: Any) -> None:
|
|
128
|
+
"""Forward one render event, never letting a bad subscriber break a run."""
|
|
129
|
+
if self.event_handler is None:
|
|
130
|
+
return
|
|
131
|
+
try:
|
|
132
|
+
self.event_handler(name, payload)
|
|
133
|
+
except Exception:
|
|
134
|
+
_log.debug("event handler failed for %s", name, exc_info=True)
|
|
135
|
+
|
|
136
|
+
def _redacted_args(self, args: dict[str, Any]) -> dict[str, Any]:
|
|
137
|
+
"""Return a JSON-safe copy of tool arguments with secrets redacted."""
|
|
138
|
+
try:
|
|
139
|
+
rendered = redact(
|
|
140
|
+
json.dumps(args, ensure_ascii=False, sort_keys=True, default=str)
|
|
141
|
+
)
|
|
142
|
+
parsed = json.loads(rendered)
|
|
143
|
+
return parsed if isinstance(parsed, dict) else {"value": parsed}
|
|
144
|
+
except (TypeError, ValueError):
|
|
145
|
+
return {"value": redact(str(args))}
|
|
146
|
+
|
|
147
|
+
def _glyph(self, name: str) -> str:
|
|
148
|
+
"""Active glyph escaped for direct use inside Rich markup."""
|
|
149
|
+
return escape(ui.glyph(name, {"ui": {"ascii": self.ascii_mode}}))
|
|
150
|
+
|
|
151
|
+
def _print_markup(self, markup: str) -> None:
|
|
152
|
+
"""Print styled markup as ``Text`` so logs keep the plain glyph text."""
|
|
153
|
+
self.console.print(Text.from_markup(markup))
|
|
154
|
+
|
|
155
|
+
def _client(self, provider_id: str | None = None) -> UnifiedClient:
|
|
156
|
+
from kiwimatecoder.config import (
|
|
157
|
+
get_key,
|
|
158
|
+
get_prompt_cache,
|
|
159
|
+
get_provider_config,
|
|
160
|
+
get_sampling,
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
provider = get_provider_config(provider_id) if provider_id else self.session.provider
|
|
164
|
+
key = get_key(provider.id)
|
|
165
|
+
if not key and provider.needs_key:
|
|
166
|
+
raise ProviderError(
|
|
167
|
+
t(
|
|
168
|
+
"error.no_key",
|
|
169
|
+
provider=provider.name,
|
|
170
|
+
provider_id=provider.id,
|
|
171
|
+
env=provider.key_env,
|
|
172
|
+
)
|
|
173
|
+
)
|
|
174
|
+
return UnifiedClient(
|
|
175
|
+
provider,
|
|
176
|
+
key or "",
|
|
177
|
+
sampling=get_sampling(),
|
|
178
|
+
prompt_cache=get_prompt_cache(),
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
def _request_messages(self) -> list[dict[str, Any]]:
|
|
182
|
+
self.session.trim_history(max_tokens=self.session.compact_at_tokens)
|
|
183
|
+
return [build_system_prompt(self.session)] + self.session.messages
|
|
184
|
+
|
|
185
|
+
def _drain_steering(self) -> bool:
|
|
186
|
+
"""Inject every queued steering message as a user message.
|
|
187
|
+
|
|
188
|
+
Returns True when at least one message was queued and appended.
|
|
189
|
+
"""
|
|
190
|
+
appended = False
|
|
191
|
+
while self.session.steering:
|
|
192
|
+
message = self.session.steering.popleft()
|
|
193
|
+
self.session.messages.append({"role": "user", "content": message})
|
|
194
|
+
appended = True
|
|
195
|
+
return appended
|
|
196
|
+
|
|
197
|
+
def _flush_pending_images(self) -> None:
|
|
198
|
+
"""Attach queued images as one user message and clear the queue.
|
|
199
|
+
|
|
200
|
+
Called after the user message (for ``@path`` attachments) and after a
|
|
201
|
+
tool batch that attached images, so the message always follows the
|
|
202
|
+
content it belongs to and tool results keep their ordering.
|
|
203
|
+
"""
|
|
204
|
+
if not self.session.pending_images:
|
|
205
|
+
return
|
|
206
|
+
pending = list(self.session.pending_images)
|
|
207
|
+
self.session.pending_images = []
|
|
208
|
+
self.session.messages.append(
|
|
209
|
+
images.image_message(pending, note="Images attached for analysis:")
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
async def run_turn(self, user_input: str) -> None:
|
|
213
|
+
"""Process one user message, looping over tool calls until the model stops."""
|
|
214
|
+
blocked, reason = self._budget_exceeded()
|
|
215
|
+
if blocked:
|
|
216
|
+
self.console.print(f"[red]{escape(reason)}[/red]")
|
|
217
|
+
self._emit("done", reason="budget", error=reason)
|
|
218
|
+
return
|
|
219
|
+
|
|
220
|
+
from kiwimatecoder.config import get_model_routing
|
|
221
|
+
from kiwimatecoder.routing import choose_turn_model
|
|
222
|
+
|
|
223
|
+
routed_model = choose_turn_model(
|
|
224
|
+
user_input, self.session, get_model_routing()
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
self._budget_warned = False
|
|
228
|
+
self.session.messages.append({"role": "user", "content": user_input})
|
|
229
|
+
self._flush_pending_images()
|
|
230
|
+
|
|
231
|
+
edited = False
|
|
232
|
+
verified = False
|
|
233
|
+
first_pass = True
|
|
234
|
+
tool_turns = 0
|
|
235
|
+
while True:
|
|
236
|
+
if first_pass:
|
|
237
|
+
first_pass = False
|
|
238
|
+
else:
|
|
239
|
+
# Steering queued while the previous response streamed is
|
|
240
|
+
# injected before the next model call.
|
|
241
|
+
self._drain_steering()
|
|
242
|
+
try:
|
|
243
|
+
assistant_msg, tool_calls = await self._stream_once(
|
|
244
|
+
model_override=routed_model
|
|
245
|
+
)
|
|
246
|
+
except ProviderError as exc:
|
|
247
|
+
self.console.print(f"\n[red]{escape(str(exc))}[/red]")
|
|
248
|
+
self._emit("error", kind="provider", error=str(exc))
|
|
249
|
+
self._emit("done", reason="provider_error")
|
|
250
|
+
return
|
|
251
|
+
|
|
252
|
+
self.session.messages.append(assistant_msg)
|
|
253
|
+
|
|
254
|
+
if not tool_calls:
|
|
255
|
+
if edited and not verified and self._should_verify():
|
|
256
|
+
verified = True
|
|
257
|
+
await self._run_verification()
|
|
258
|
+
continue
|
|
259
|
+
if self._drain_steering():
|
|
260
|
+
# The model must answer the steered message.
|
|
261
|
+
continue
|
|
262
|
+
self._warn_budget()
|
|
263
|
+
self._emit("done", reason="stop")
|
|
264
|
+
return
|
|
265
|
+
|
|
266
|
+
if self.max_turns is not None and tool_turns >= self.max_turns:
|
|
267
|
+
self._emit("done", reason="max_turns", limit=self.max_turns)
|
|
268
|
+
return
|
|
269
|
+
edited = await self._handle_tool_calls(tool_calls) or edited
|
|
270
|
+
tool_turns += 1
|
|
271
|
+
self._warn_budget()
|
|
272
|
+
|
|
273
|
+
def _current_cost(self) -> float | None:
|
|
274
|
+
from kiwimatecoder.pricing import estimate_cost
|
|
275
|
+
|
|
276
|
+
return estimate_cost(
|
|
277
|
+
self.session.prompt_tokens,
|
|
278
|
+
self.session.completion_tokens,
|
|
279
|
+
self.session.model,
|
|
280
|
+
self.session.provider_id,
|
|
281
|
+
is_local=self.session.provider.is_local,
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
def _budget_exceeded(self) -> tuple[bool, str]:
|
|
285
|
+
"""Whether a configured token/cost budget has already been spent."""
|
|
286
|
+
from kiwimatecoder.config import get_budget
|
|
287
|
+
|
|
288
|
+
budget = get_budget()
|
|
289
|
+
max_tokens = budget.get("max_tokens")
|
|
290
|
+
if max_tokens and self.session.total_tokens >= max_tokens:
|
|
291
|
+
return True, (
|
|
292
|
+
f"Budget reached: {self.session.total_tokens:,} tokens used "
|
|
293
|
+
f"(limit {int(max_tokens):,}). Raise it with "
|
|
294
|
+
"`/config budget tokens <n>` or clear it with `/config budget clear`."
|
|
295
|
+
)
|
|
296
|
+
max_cost = budget.get("max_cost_usd")
|
|
297
|
+
if max_cost:
|
|
298
|
+
cost = self._current_cost()
|
|
299
|
+
if cost is not None and cost >= max_cost:
|
|
300
|
+
return True, (
|
|
301
|
+
f"Budget reached: ~${cost:.4f} spent "
|
|
302
|
+
f"(limit ${max_cost:.4f}). Raise it with "
|
|
303
|
+
"`/config budget cost <usd>` or clear it with `/config budget clear`."
|
|
304
|
+
)
|
|
305
|
+
return False, ""
|
|
306
|
+
|
|
307
|
+
def _warn_budget(self) -> None:
|
|
308
|
+
"""Print one warning when usage crosses 80% of a configured budget."""
|
|
309
|
+
if self._budget_warned:
|
|
310
|
+
return
|
|
311
|
+
from kiwimatecoder.config import get_budget
|
|
312
|
+
|
|
313
|
+
budget = get_budget()
|
|
314
|
+
max_tokens = budget.get("max_tokens")
|
|
315
|
+
if max_tokens and self.session.total_tokens >= 0.8 * max_tokens:
|
|
316
|
+
self._budget_warned = True
|
|
317
|
+
self.console.print(
|
|
318
|
+
f"[yellow]Budget warning: {self.session.total_tokens:,} of "
|
|
319
|
+
f"{int(max_tokens):,} tokens used.[/yellow]"
|
|
320
|
+
)
|
|
321
|
+
return
|
|
322
|
+
max_cost = budget.get("max_cost_usd")
|
|
323
|
+
if max_cost:
|
|
324
|
+
cost = self._current_cost()
|
|
325
|
+
if cost is not None and cost >= 0.8 * max_cost:
|
|
326
|
+
self._budget_warned = True
|
|
327
|
+
self.console.print(
|
|
328
|
+
f"[yellow]Budget warning: ~${cost:.4f} of "
|
|
329
|
+
f"${max_cost:.4f} spent.[/yellow]"
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
def _should_verify(self) -> bool:
|
|
333
|
+
return bool(
|
|
334
|
+
self.session.verify_command.strip()
|
|
335
|
+
and self.session.mode is not PermissionMode.PLAN
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
async def _run_verification(self) -> None:
|
|
339
|
+
"""Run the configured verify command and feed its output back."""
|
|
340
|
+
command = self.session.verify_command.strip()
|
|
341
|
+
verify_tool = tools.get_tool("run_bash")
|
|
342
|
+
if verify_tool is None:
|
|
343
|
+
return
|
|
344
|
+
self.console.print(
|
|
345
|
+
f"\n[bold]Auto-verify:[/bold] [cyan]{escape(command)}[/cyan]"
|
|
346
|
+
)
|
|
347
|
+
t0 = time.perf_counter()
|
|
348
|
+
result = await asyncio.to_thread(
|
|
349
|
+
verify_tool.execute, {"command": command}, self.session
|
|
350
|
+
)
|
|
351
|
+
duration_ms = int((time.perf_counter() - t0) * 1000)
|
|
352
|
+
audit.record_tool_event(
|
|
353
|
+
tool="run_bash",
|
|
354
|
+
args={"command": command},
|
|
355
|
+
decision="auto_verify",
|
|
356
|
+
duration_ms=duration_ms,
|
|
357
|
+
ok=result.ok,
|
|
358
|
+
)
|
|
359
|
+
status = "[green]passed[/green]" if result.ok else "[red]failed[/red]"
|
|
360
|
+
self.console.print(f"Auto-verify {status} [dim]({duration_ms}ms)[/dim]")
|
|
361
|
+
content = result.content
|
|
362
|
+
if len(content) > 6000:
|
|
363
|
+
content = content[:6000] + "\n... [truncated]"
|
|
364
|
+
self.session.messages.append(
|
|
365
|
+
{
|
|
366
|
+
"role": "user",
|
|
367
|
+
"content": (
|
|
368
|
+
f"[auto-verify] `{command}` "
|
|
369
|
+
f"{'passed' if result.ok else 'failed'}:\n"
|
|
370
|
+
f"```text\n{content}\n```\n"
|
|
371
|
+
+ (
|
|
372
|
+
"Fix the failures above, then re-run the verification."
|
|
373
|
+
if not result.ok
|
|
374
|
+
else "Briefly summarize what changed, then stop."
|
|
375
|
+
)
|
|
376
|
+
),
|
|
377
|
+
}
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
def _tool_schemas(self) -> list[dict[str, Any]]:
|
|
381
|
+
"""Tool schemas advertised for the next request.
|
|
382
|
+
|
|
383
|
+
In plan mode only read-only tools are advertised (the gate blocks the
|
|
384
|
+
rest as a second line of defense). Subagents never see ``task`` (no
|
|
385
|
+
nested subagents) or ``ask_user`` (no interactive user), and ``task``
|
|
386
|
+
is hidden for everyone while subagents are disabled. ``browser`` is
|
|
387
|
+
hidden until browser automation is enabled in config.
|
|
388
|
+
"""
|
|
389
|
+
from kiwimatecoder.config import get_browser, get_subagents
|
|
390
|
+
|
|
391
|
+
schemas = tools.tool_schemas(
|
|
392
|
+
read_only=self.session.mode is PermissionMode.PLAN
|
|
393
|
+
)
|
|
394
|
+
excluded: set[str] = set()
|
|
395
|
+
if self.session.subagent:
|
|
396
|
+
excluded.update({"task", "ask_user"})
|
|
397
|
+
if not get_subagents()["enabled"]:
|
|
398
|
+
excluded.add("task")
|
|
399
|
+
if not get_browser()["enabled"]:
|
|
400
|
+
excluded.add("browser")
|
|
401
|
+
if excluded:
|
|
402
|
+
schemas = [
|
|
403
|
+
schema
|
|
404
|
+
for schema in schemas
|
|
405
|
+
if schema["function"]["name"] not in excluded
|
|
406
|
+
]
|
|
407
|
+
return schemas
|
|
408
|
+
|
|
409
|
+
async def _stream_once(
|
|
410
|
+
self, model_override: str | None = None
|
|
411
|
+
) -> tuple[dict[str, Any], list[AssembledToolCall]]:
|
|
412
|
+
"""Stream one assistant response, rendering text and collecting tool calls.
|
|
413
|
+
|
|
414
|
+
Tries each active provider in order (primary first); when a provider
|
|
415
|
+
fails with a :class:`ProviderError`, the next active provider is tried
|
|
416
|
+
with the model chosen for it. A provider with no chosen model is
|
|
417
|
+
skipped (there are no default models). Only when every active provider
|
|
418
|
+
fails is the error surfaced.
|
|
419
|
+
|
|
420
|
+
``model_override`` (per-turn model routing) applies to the primary
|
|
421
|
+
provider only; fallback providers keep their own model.
|
|
422
|
+
"""
|
|
423
|
+
schemas = self._tool_schemas()
|
|
424
|
+
|
|
425
|
+
errors: list[ProviderError] = []
|
|
426
|
+
providers = self.session.active_providers
|
|
427
|
+
for index, provider in enumerate(providers):
|
|
428
|
+
try:
|
|
429
|
+
client = self._client(provider.id)
|
|
430
|
+
except ProviderError as exc:
|
|
431
|
+
errors.append(exc)
|
|
432
|
+
self._announce_failover(provider.name, exc, providers[index + 1 :])
|
|
433
|
+
continue
|
|
434
|
+
try:
|
|
435
|
+
model = self.session.model_for(provider.id)
|
|
436
|
+
if model_override and provider.id == self.session.provider_id:
|
|
437
|
+
if model_override != model:
|
|
438
|
+
self.console.print(
|
|
439
|
+
f"[dim]routed to {escape(model_override)}[/dim]"
|
|
440
|
+
)
|
|
441
|
+
model = model_override
|
|
442
|
+
if not model:
|
|
443
|
+
raise ProviderError(
|
|
444
|
+
f"no model chosen for {provider.name}; choose one with "
|
|
445
|
+
+ (
|
|
446
|
+
"/model"
|
|
447
|
+
if provider.id == self.session.provider_id
|
|
448
|
+
else f"/config model set <model> {provider.id}"
|
|
449
|
+
)
|
|
450
|
+
)
|
|
451
|
+
return await self._stream_from(client, schemas, model)
|
|
452
|
+
except ProviderError as exc:
|
|
453
|
+
errors.append(exc)
|
|
454
|
+
self._announce_failover(provider.name, exc, providers[index + 1 :])
|
|
455
|
+
continue
|
|
456
|
+
|
|
457
|
+
if errors:
|
|
458
|
+
raise ProviderError(
|
|
459
|
+
"All active providers failed: "
|
|
460
|
+
+ "; ".join(str(exc) for exc in errors)
|
|
461
|
+
)
|
|
462
|
+
raise ProviderError("No active providers are configured.")
|
|
463
|
+
|
|
464
|
+
def _announce_failover(
|
|
465
|
+
self,
|
|
466
|
+
provider_name: str,
|
|
467
|
+
exc: ProviderError,
|
|
468
|
+
remaining: list[Any],
|
|
469
|
+
) -> None:
|
|
470
|
+
if not remaining:
|
|
471
|
+
return
|
|
472
|
+
self.console.print(
|
|
473
|
+
f"\n[yellow]{escape(provider_name)} failed ({escape(str(exc))}); "
|
|
474
|
+
f"trying {escape(str(remaining[0].name))}.[/yellow]"
|
|
475
|
+
)
|
|
476
|
+
|
|
477
|
+
async def _stream_from(
|
|
478
|
+
self,
|
|
479
|
+
client: UnifiedClient,
|
|
480
|
+
schemas: list[dict[str, Any]],
|
|
481
|
+
model: str,
|
|
482
|
+
) -> tuple[dict[str, Any], list[AssembledToolCall]]:
|
|
483
|
+
"""Stream from one client, rendering text and collecting tool calls."""
|
|
484
|
+
text_parts: list[str] = []
|
|
485
|
+
assembler = ToolCallAssembler()
|
|
486
|
+
printed_any = False
|
|
487
|
+
finish_reason: str | None = None
|
|
488
|
+
status: Any = (
|
|
489
|
+
self.console.status("[dim]Thinking…[/dim]")
|
|
490
|
+
if self.spinner_enabled
|
|
491
|
+
else _NoopStatus()
|
|
492
|
+
)
|
|
493
|
+
status.start()
|
|
494
|
+
thinking = True
|
|
495
|
+
|
|
496
|
+
try:
|
|
497
|
+
async for event in client.stream_chat(
|
|
498
|
+
self._request_messages(), schemas, model
|
|
499
|
+
):
|
|
500
|
+
if isinstance(event, TextDelta):
|
|
501
|
+
if thinking:
|
|
502
|
+
status.stop()
|
|
503
|
+
thinking = False
|
|
504
|
+
if self.render_text:
|
|
505
|
+
self.console.print(
|
|
506
|
+
event.text, end="", markup=False, highlight=False
|
|
507
|
+
)
|
|
508
|
+
printed_any = True
|
|
509
|
+
self._emit("text_delta", text=event.text)
|
|
510
|
+
text_parts.append(event.text)
|
|
511
|
+
elif isinstance(event, ToolCallDelta):
|
|
512
|
+
assembler.add(event)
|
|
513
|
+
elif isinstance(event, Usage):
|
|
514
|
+
self.session.add_usage(event.prompt_tokens, event.completion_tokens)
|
|
515
|
+
self._emit(
|
|
516
|
+
"usage",
|
|
517
|
+
prompt_tokens=event.prompt_tokens,
|
|
518
|
+
completion_tokens=event.completion_tokens,
|
|
519
|
+
)
|
|
520
|
+
elif isinstance(event, Done):
|
|
521
|
+
finish_reason = event.finish_reason or finish_reason
|
|
522
|
+
except BaseException:
|
|
523
|
+
# Preserve the text the user already saw when a turn is interrupted
|
|
524
|
+
# (Ctrl-C) or the stream fails partway through.
|
|
525
|
+
partial = "".join(text_parts)
|
|
526
|
+
if partial:
|
|
527
|
+
partial_msg: dict[str, Any] = {
|
|
528
|
+
"role": "assistant",
|
|
529
|
+
"content": partial,
|
|
530
|
+
}
|
|
531
|
+
if (
|
|
532
|
+
not self.session.messages
|
|
533
|
+
or self.session.messages[-1] != partial_msg
|
|
534
|
+
):
|
|
535
|
+
self.session.messages.append(partial_msg)
|
|
536
|
+
raise
|
|
537
|
+
finally:
|
|
538
|
+
if thinking:
|
|
539
|
+
status.stop()
|
|
540
|
+
|
|
541
|
+
if printed_any:
|
|
542
|
+
self.console.print()
|
|
543
|
+
|
|
544
|
+
calls = assembler.finalize()
|
|
545
|
+
if finish_reason == "length" and not calls and self.render_text:
|
|
546
|
+
# Some providers (KiwiMate caps replies at 1,024 tokens) stop
|
|
547
|
+
# mid-answer; say so rather than let the reply look complete.
|
|
548
|
+
self.console.print(
|
|
549
|
+
"[dim]The reply was cut off at the provider's output limit.[/dim]"
|
|
550
|
+
)
|
|
551
|
+
assistant_msg: dict[str, Any] = {"role": "assistant", "content": "".join(text_parts) or None}
|
|
552
|
+
if calls:
|
|
553
|
+
assistant_msg["tool_calls"] = [
|
|
554
|
+
{
|
|
555
|
+
"id": c.id,
|
|
556
|
+
"type": "function",
|
|
557
|
+
"function": {"name": c.name, "arguments": c.arguments},
|
|
558
|
+
}
|
|
559
|
+
for c in calls
|
|
560
|
+
]
|
|
561
|
+
return assistant_msg, calls
|
|
562
|
+
|
|
563
|
+
def _auto_confirm(self, summary: str, preview: str | None) -> bool:
|
|
564
|
+
"""Approval stand-in used in dry-run mode so nothing prompts."""
|
|
565
|
+
return True
|
|
566
|
+
|
|
567
|
+
def _format_call_summary(self, name: str, args: dict[str, Any]) -> str:
|
|
568
|
+
"""Produce a short human-readable string summarizing the tool call arguments.
|
|
569
|
+
|
|
570
|
+
The result is Rich markup, and every value in it comes from the model
|
|
571
|
+
(a path, a command, a pattern), so each one is escaped: ``app/[slug]/page.tsx``
|
|
572
|
+
must not lose its ``[slug]`` and ``test_a[/tmp/x]`` must not raise.
|
|
573
|
+
"""
|
|
574
|
+
|
|
575
|
+
def clip(value: object, limit: int) -> str:
|
|
576
|
+
text = str(value or "")
|
|
577
|
+
return escape(text if len(text) <= limit else f"{text[: limit - 3]}...")
|
|
578
|
+
|
|
579
|
+
if name in ("read_file", "write_file", "edit_file", "list_dir"):
|
|
580
|
+
target = args.get("path", ".")
|
|
581
|
+
return f"{escape(name)} [dim]{escape(str(target))}[/dim]"
|
|
582
|
+
if name == "view_image":
|
|
583
|
+
target = args.get("path", ".")
|
|
584
|
+
return f"image [dim]{escape(str(target))}[/dim]"
|
|
585
|
+
if name == "generate_image":
|
|
586
|
+
return f"image-gen [dim]{clip(args.get('prompt'), 50)}[/dim]"
|
|
587
|
+
if name == "generate_video":
|
|
588
|
+
prompt = str(args.get("prompt", "") or "")
|
|
589
|
+
if not prompt and args.get("resume_job_id"):
|
|
590
|
+
prompt = f"resume {args['resume_job_id']}"
|
|
591
|
+
return f"video-gen [dim]{clip(prompt, 50)}[/dim]"
|
|
592
|
+
if name == "search":
|
|
593
|
+
pat = args.get("pattern", "")
|
|
594
|
+
mode = args.get("mode", "grep")
|
|
595
|
+
return f"search [dim]{escape(str(pat))}[/dim] ({escape(str(mode))})"
|
|
596
|
+
if name == "run_bash":
|
|
597
|
+
return f"bash [dim]`{clip(args.get('command'), 40)}`[/dim]"
|
|
598
|
+
if name == "shell":
|
|
599
|
+
return f"shell [dim]`{clip(args.get('command'), 40)}`[/dim]"
|
|
600
|
+
if name == "shell_jobs":
|
|
601
|
+
action = str(args.get("action", "") or "")
|
|
602
|
+
short = clip(args.get("command") or args.get("id"), 40)
|
|
603
|
+
suffix = f" [dim]{short}[/dim]" if short else ""
|
|
604
|
+
return f"shell_jobs [dim]{escape(action)}[/dim]{suffix}"
|
|
605
|
+
if name == "update_todos":
|
|
606
|
+
todos = args.get("todos")
|
|
607
|
+
count = len(todos) if isinstance(todos, list) else 0
|
|
608
|
+
return f"todos [dim]({count} item{'s' if count != 1 else ''})[/dim]"
|
|
609
|
+
if name == "ask_user":
|
|
610
|
+
return f"ask [dim]{clip(args.get('question'), 50)}[/dim]"
|
|
611
|
+
if name == "load_skill":
|
|
612
|
+
return f"skill [dim]{escape(str(args.get('name', '') or ''))}[/dim]"
|
|
613
|
+
if name == "web_fetch":
|
|
614
|
+
return f"web_fetch [dim]{clip(args.get('url'), 60)}[/dim]"
|
|
615
|
+
if name == "web_search":
|
|
616
|
+
return f"web_search [dim]{clip(args.get('query'), 50)}[/dim]"
|
|
617
|
+
if name in ("git", "git_write", "forge", "forge_write"):
|
|
618
|
+
action = str(args.get("action", "") or "")
|
|
619
|
+
return f"{escape(name)} [dim]{escape(action)}[/dim]"
|
|
620
|
+
if name == "remember":
|
|
621
|
+
return "memory [dim]remember[/dim]"
|
|
622
|
+
if name == "recall":
|
|
623
|
+
short = clip(args.get("query"), 40)
|
|
624
|
+
return f"memory [dim]recall {short}[/dim]" if short else "memory [dim]recall[/dim]"
|
|
625
|
+
if name == "read_clipboard":
|
|
626
|
+
return "clipboard [dim]read[/dim]"
|
|
627
|
+
if name == "write_clipboard":
|
|
628
|
+
return f"clipboard [dim]write {clip(args.get('text'), 40)}[/dim]"
|
|
629
|
+
if name in ("http_get", "http_request"):
|
|
630
|
+
short = clip(args.get("url"), 60)
|
|
631
|
+
method = str(args.get("method", "") or "GET").upper()
|
|
632
|
+
label = "GET" if name == "http_get" else method
|
|
633
|
+
return f"http [dim]{escape(label)} {short}[/dim]"
|
|
634
|
+
if name in ("lsp_diagnostics", "lsp_definition", "lsp_references"):
|
|
635
|
+
action = name[len("lsp_") :]
|
|
636
|
+
path = str(args.get("path", "") or "")
|
|
637
|
+
if action == "diagnostics" and not path:
|
|
638
|
+
return "lsp [dim]diagnostics recent files[/dim]"
|
|
639
|
+
return f"lsp [dim]{action} {escape(path)}[/dim]"
|
|
640
|
+
if name == "task":
|
|
641
|
+
return f"task [dim]{clip(args.get('description'), 50)}[/dim]"
|
|
642
|
+
if name == "browser":
|
|
643
|
+
action = str(args.get("action", "") or "")
|
|
644
|
+
target = clip(
|
|
645
|
+
args.get("url")
|
|
646
|
+
or args.get("selector")
|
|
647
|
+
or args.get("script")
|
|
648
|
+
or args.get("text"),
|
|
649
|
+
60,
|
|
650
|
+
)
|
|
651
|
+
detail = f"{escape(action)} {target}".strip()
|
|
652
|
+
return f"browser [dim]{detail}[/dim]"
|
|
653
|
+
return escape(name)
|
|
654
|
+
|
|
655
|
+
# Only purely read-only tools are safe to run concurrently: they do not
|
|
656
|
+
# mutate session state or the workspace.
|
|
657
|
+
_PARALLEL_SAFE = frozenset({"read_file", "list_dir", "search"})
|
|
658
|
+
|
|
659
|
+
def _can_run_parallel(self, calls: list[AssembledToolCall]) -> bool:
|
|
660
|
+
if len(calls) < 2:
|
|
661
|
+
return False
|
|
662
|
+
for call in calls:
|
|
663
|
+
tool = tools.get_tool(call.name)
|
|
664
|
+
if tool is None or tool.needs_approval or call.name not in self._PARALLEL_SAFE:
|
|
665
|
+
return False
|
|
666
|
+
return True
|
|
667
|
+
|
|
668
|
+
async def _handle_tool_calls(self, calls: list[AssembledToolCall]) -> bool:
|
|
669
|
+
"""Run a batch of tool calls; returns whether any file edit succeeded."""
|
|
670
|
+
results: list[tuple[dict[str, Any], bool]]
|
|
671
|
+
if self._can_run_parallel(calls):
|
|
672
|
+
results = await asyncio.gather(
|
|
673
|
+
*(asyncio.to_thread(self._run_tool_call, call) for call in calls)
|
|
674
|
+
)
|
|
675
|
+
else:
|
|
676
|
+
results = []
|
|
677
|
+
for call in calls:
|
|
678
|
+
# ``task`` drives a nested async loop, so it cannot go through
|
|
679
|
+
# the synchronous runner (or a worker thread).
|
|
680
|
+
if call.name == "task":
|
|
681
|
+
results.append(await self._run_task_call(call))
|
|
682
|
+
else:
|
|
683
|
+
results.append(self._run_tool_call(call))
|
|
684
|
+
self.session.messages.extend(message for message, _edited in results)
|
|
685
|
+
# Images attached by this batch go after its tool results (and only
|
|
686
|
+
# once, even when several calls attached one).
|
|
687
|
+
self._flush_pending_images()
|
|
688
|
+
return any(edited for _message, edited in results)
|
|
689
|
+
|
|
690
|
+
def _tool_message(self, tool_call_id: str, content: str) -> dict[str, Any]:
|
|
691
|
+
return {"role": "tool", "tool_call_id": tool_call_id, "content": content}
|
|
692
|
+
|
|
693
|
+
def _prepare_tool_call(
|
|
694
|
+
self, call: AssembledToolCall
|
|
695
|
+
) -> tuple[_PreparedCall | None, tuple[dict[str, Any], bool] | None]:
|
|
696
|
+
"""Parse, gate, hook-check, and checkpoint one tool call.
|
|
697
|
+
|
|
698
|
+
Returns ``(prepared, None)`` when the call may execute, or
|
|
699
|
+
``(None, early_result)`` with the tool message to return instead
|
|
700
|
+
(unknown tool, bad JSON, denied, dry-run, or hook-blocked).
|
|
701
|
+
"""
|
|
702
|
+
tool = tools.get_tool(call.name)
|
|
703
|
+
if tool is None:
|
|
704
|
+
return None, (
|
|
705
|
+
self._tool_message(call.id, f"Error: unknown tool '{call.name}'"),
|
|
706
|
+
False,
|
|
707
|
+
)
|
|
708
|
+
if call.name == "task" and self.session.subagent:
|
|
709
|
+
return None, (
|
|
710
|
+
self._tool_message(
|
|
711
|
+
call.id, "Error: subagents cannot spawn nested subagents."
|
|
712
|
+
),
|
|
713
|
+
False,
|
|
714
|
+
)
|
|
715
|
+
|
|
716
|
+
try:
|
|
717
|
+
args = call.parse_arguments()
|
|
718
|
+
except json.JSONDecodeError as exc:
|
|
719
|
+
return None, (
|
|
720
|
+
self._tool_message(
|
|
721
|
+
call.id, f"Error: could not parse arguments as JSON: {exc}"
|
|
722
|
+
),
|
|
723
|
+
False,
|
|
724
|
+
)
|
|
725
|
+
|
|
726
|
+
summary = self._format_call_summary(call.name, args)
|
|
727
|
+
preview_text = tools.preview(call.name, args, self.session)
|
|
728
|
+
confirm = (
|
|
729
|
+
self._auto_confirm
|
|
730
|
+
if self.session.dry_run and tool.needs_approval
|
|
731
|
+
else self.confirm
|
|
732
|
+
)
|
|
733
|
+
decision = gate(tool, args, self.session, confirm, preview_text)
|
|
734
|
+
if not decision.allowed:
|
|
735
|
+
audit.record_tool_event(
|
|
736
|
+
tool=call.name,
|
|
737
|
+
args=args,
|
|
738
|
+
decision="denied",
|
|
739
|
+
reason=decision.reason,
|
|
740
|
+
)
|
|
741
|
+
self._print_markup(
|
|
742
|
+
f"[yellow]{self._glyph('blocked')} {summary}: "
|
|
743
|
+
f"{escape(decision.reason)}[/yellow]"
|
|
744
|
+
)
|
|
745
|
+
return None, (self._tool_message(call.id, decision.reason), False)
|
|
746
|
+
|
|
747
|
+
if self.session.dry_run and tool.needs_approval:
|
|
748
|
+
audit.record_tool_event(tool=call.name, args=args, decision="dry_run")
|
|
749
|
+
self.console.print(f"[yellow]dry-run[/yellow] {summary}")
|
|
750
|
+
if preview_text:
|
|
751
|
+
self.console.print(preview_text, markup=False, highlight=False)
|
|
752
|
+
return None, (
|
|
753
|
+
self._tool_message(
|
|
754
|
+
call.id,
|
|
755
|
+
f"DRY RUN: {summary} was not executed; no files or state changed.",
|
|
756
|
+
),
|
|
757
|
+
False,
|
|
758
|
+
)
|
|
759
|
+
|
|
760
|
+
original_args = args
|
|
761
|
+
partial_hunks: tuple[int, ...] | None = None
|
|
762
|
+
if decision.selected_hunks is not None:
|
|
763
|
+
selected_args = (
|
|
764
|
+
tools.select_hunks(
|
|
765
|
+
call.name, args, self.session, decision.selected_hunks
|
|
766
|
+
)
|
|
767
|
+
if call.name in ("write_file", "edit_file")
|
|
768
|
+
else None
|
|
769
|
+
)
|
|
770
|
+
if selected_args is None:
|
|
771
|
+
reason = (
|
|
772
|
+
"Partial hunk selection could not be applied; no changes "
|
|
773
|
+
"were made."
|
|
774
|
+
)
|
|
775
|
+
audit.record_tool_event(
|
|
776
|
+
tool=call.name,
|
|
777
|
+
args=original_args,
|
|
778
|
+
decision="denied",
|
|
779
|
+
reason=reason,
|
|
780
|
+
hunks=decision.selected_hunks,
|
|
781
|
+
)
|
|
782
|
+
self._print_markup(
|
|
783
|
+
f"[yellow]{self._glyph('blocked')} {summary}: "
|
|
784
|
+
f"{escape(reason)}[/yellow]"
|
|
785
|
+
)
|
|
786
|
+
return None, (self._tool_message(call.id, reason), False)
|
|
787
|
+
args = selected_args
|
|
788
|
+
partial_hunks = decision.selected_hunks
|
|
789
|
+
|
|
790
|
+
pre_results = self._run_pre_tool_hooks(call.name, args)
|
|
791
|
+
blocked = next((item for item in pre_results if item.blocked), None)
|
|
792
|
+
if blocked is not None:
|
|
793
|
+
reason = self._hook_block_reason(blocked)
|
|
794
|
+
audit.record_tool_event(
|
|
795
|
+
tool=call.name,
|
|
796
|
+
args=original_args,
|
|
797
|
+
decision="hook_blocked",
|
|
798
|
+
reason=reason,
|
|
799
|
+
)
|
|
800
|
+
self._print_markup(
|
|
801
|
+
f"[red]{self._glyph('blocked')} {summary}: {escape(reason)}[/red]"
|
|
802
|
+
)
|
|
803
|
+
return None, (
|
|
804
|
+
self._tool_message(call.id, self._hook_block_message(blocked)),
|
|
805
|
+
False,
|
|
806
|
+
)
|
|
807
|
+
|
|
808
|
+
if call.name in ("write_file", "edit_file"):
|
|
809
|
+
path = str(args.get("path") or "").strip()
|
|
810
|
+
if path:
|
|
811
|
+
self.session.checkpoint([path], f"{call.name} {path}")
|
|
812
|
+
|
|
813
|
+
return (
|
|
814
|
+
_PreparedCall(
|
|
815
|
+
call=call,
|
|
816
|
+
tool=tool,
|
|
817
|
+
args=args,
|
|
818
|
+
original_args=original_args,
|
|
819
|
+
summary=summary,
|
|
820
|
+
partial_hunks=partial_hunks,
|
|
821
|
+
),
|
|
822
|
+
None,
|
|
823
|
+
)
|
|
824
|
+
|
|
825
|
+
def _run_tool_call(
|
|
826
|
+
self, call: AssembledToolCall
|
|
827
|
+
) -> tuple[dict[str, Any], bool]:
|
|
828
|
+
"""Execute one tool call; returns (tool message, edited_file)."""
|
|
829
|
+
prepared, early = self._prepare_tool_call(call)
|
|
830
|
+
if early is not None:
|
|
831
|
+
return early
|
|
832
|
+
assert prepared is not None
|
|
833
|
+
self._emit(
|
|
834
|
+
"tool_start",
|
|
835
|
+
tool=prepared.call.name,
|
|
836
|
+
args=self._redacted_args(prepared.args),
|
|
837
|
+
)
|
|
838
|
+
if self.output_mode == "verbose":
|
|
839
|
+
self.console.print(self._verbose_args(prepared.args))
|
|
840
|
+
|
|
841
|
+
t0 = time.perf_counter()
|
|
842
|
+
try:
|
|
843
|
+
with (
|
|
844
|
+
self.console.status(f"{prepared.summary}…")
|
|
845
|
+
if self.spinner_enabled
|
|
846
|
+
else nullcontext()
|
|
847
|
+
):
|
|
848
|
+
result = prepared.tool.execute(prepared.args, self.session)
|
|
849
|
+
except Exception as exc:
|
|
850
|
+
result = ToolResult.error(f"Tool crashed: {exc!r}")
|
|
851
|
+
return self._finish_tool_call(prepared, result, t0)
|
|
852
|
+
|
|
853
|
+
async def _run_task_call(
|
|
854
|
+
self, call: AssembledToolCall
|
|
855
|
+
) -> tuple[dict[str, Any], bool]:
|
|
856
|
+
"""Execute a ``task`` call, awaiting the nested subagent loop."""
|
|
857
|
+
prepared, early = self._prepare_tool_call(call)
|
|
858
|
+
if early is not None:
|
|
859
|
+
return early
|
|
860
|
+
assert prepared is not None
|
|
861
|
+
self._emit(
|
|
862
|
+
"tool_start",
|
|
863
|
+
tool=prepared.call.name,
|
|
864
|
+
args=self._redacted_args(prepared.args),
|
|
865
|
+
)
|
|
866
|
+
if self.output_mode == "verbose":
|
|
867
|
+
self.console.print(self._verbose_args(prepared.args))
|
|
868
|
+
|
|
869
|
+
t0 = time.perf_counter()
|
|
870
|
+
try:
|
|
871
|
+
with (
|
|
872
|
+
self.console.status(f"{prepared.summary}…")
|
|
873
|
+
if self.spinner_enabled
|
|
874
|
+
else nullcontext()
|
|
875
|
+
):
|
|
876
|
+
result = await self._run_task(prepared.args)
|
|
877
|
+
except Exception as exc:
|
|
878
|
+
result = ToolResult.error(f"Tool crashed: {exc!r}")
|
|
879
|
+
return self._finish_tool_call(prepared, result, t0)
|
|
880
|
+
|
|
881
|
+
def _finish_tool_call(
|
|
882
|
+
self,
|
|
883
|
+
prepared: _PreparedCall,
|
|
884
|
+
result: ToolResult,
|
|
885
|
+
started_at: float,
|
|
886
|
+
) -> tuple[dict[str, Any], bool]:
|
|
887
|
+
"""Record, render, and package the outcome of a prepared call."""
|
|
888
|
+
duration_ms = int((time.perf_counter() - started_at) * 1000)
|
|
889
|
+
decision_value = (
|
|
890
|
+
"allowed_partial" if prepared.partial_hunks is not None else "allowed"
|
|
891
|
+
)
|
|
892
|
+
audit.record_tool_event(
|
|
893
|
+
tool=prepared.call.name,
|
|
894
|
+
args=prepared.original_args,
|
|
895
|
+
decision=decision_value,
|
|
896
|
+
duration_ms=duration_ms,
|
|
897
|
+
ok=result.ok,
|
|
898
|
+
hunks=prepared.partial_hunks,
|
|
899
|
+
)
|
|
900
|
+
self._run_post_tool_hooks(
|
|
901
|
+
prepared.call.name,
|
|
902
|
+
prepared.original_args,
|
|
903
|
+
result.ok,
|
|
904
|
+
duration_ms,
|
|
905
|
+
decision_value,
|
|
906
|
+
)
|
|
907
|
+
self._emit(
|
|
908
|
+
"tool_end",
|
|
909
|
+
tool=prepared.call.name,
|
|
910
|
+
ok=result.ok,
|
|
911
|
+
duration_ms=duration_ms,
|
|
912
|
+
)
|
|
913
|
+
|
|
914
|
+
if result.ok:
|
|
915
|
+
if self.output_mode != "compact":
|
|
916
|
+
self._print_markup(
|
|
917
|
+
f"[bold green]{self._glyph('check')}[/bold green] "
|
|
918
|
+
f"{prepared.summary} [dim]({duration_ms}ms)[/dim]"
|
|
919
|
+
)
|
|
920
|
+
else:
|
|
921
|
+
self._print_markup(
|
|
922
|
+
f"[bold red]{self._glyph('cross')}[/bold red] "
|
|
923
|
+
f"{prepared.summary} [red](failed)[/red] [dim]({duration_ms}ms)[/dim]"
|
|
924
|
+
)
|
|
925
|
+
if self.output_mode == "verbose":
|
|
926
|
+
self.console.print(f"[dim]result: {len(result.content):,} chars[/dim]")
|
|
927
|
+
edited = prepared.call.name in ("write_file", "edit_file") and result.ok
|
|
928
|
+
content = result.content
|
|
929
|
+
if result.ok and prepared.partial_hunks is not None:
|
|
930
|
+
content += (
|
|
931
|
+
f" (applied hunks: {', '.join(str(h) for h in prepared.partial_hunks)})"
|
|
932
|
+
)
|
|
933
|
+
if edited:
|
|
934
|
+
content += self._post_edit_diagnostics(prepared.args)
|
|
935
|
+
return self._tool_message(prepared.call.id, content), edited
|
|
936
|
+
|
|
937
|
+
def _build_child_session(self, model: str) -> Session:
|
|
938
|
+
"""Create the isolated session a subagent runs in.
|
|
939
|
+
|
|
940
|
+
The child inherits the workspace, provider roster/model, permission
|
|
941
|
+
mode, rules, and trust settings, but starts with empty conversation
|
|
942
|
+
history, context, todos, checkpoints, and steering.
|
|
943
|
+
"""
|
|
944
|
+
parent = self.session
|
|
945
|
+
return Session(
|
|
946
|
+
provider_id=parent.provider_id,
|
|
947
|
+
model=model,
|
|
948
|
+
mode=parent.mode,
|
|
949
|
+
workspace_root=parent.workspace_root,
|
|
950
|
+
always_allowed=set(parent.always_allowed),
|
|
951
|
+
active_provider_ids=list(parent.active_provider_ids),
|
|
952
|
+
models=dict(parent.models),
|
|
953
|
+
output_style=parent.output_style,
|
|
954
|
+
custom_system_prompt=parent.custom_system_prompt,
|
|
955
|
+
command_rules={
|
|
956
|
+
kind: list(patterns)
|
|
957
|
+
for kind, patterns in parent.command_rules.items()
|
|
958
|
+
},
|
|
959
|
+
dry_run=parent.dry_run,
|
|
960
|
+
trusted_workspace=parent.trusted_workspace,
|
|
961
|
+
compact_at_tokens=parent.compact_at_tokens,
|
|
962
|
+
context_window=parent.context_window,
|
|
963
|
+
subagent=True,
|
|
964
|
+
)
|
|
965
|
+
|
|
966
|
+
async def _run_task(self, args: dict[str, Any]) -> ToolResult:
|
|
967
|
+
"""Run one subagent to completion and return its final report."""
|
|
968
|
+
from kiwimatecoder.config import get_budget, get_subagents
|
|
969
|
+
|
|
970
|
+
settings = get_subagents()
|
|
971
|
+
if not settings["enabled"]:
|
|
972
|
+
return ToolResult.error(
|
|
973
|
+
"Subagents are disabled. Enable them with "
|
|
974
|
+
"`/config subagents enable on`."
|
|
975
|
+
)
|
|
976
|
+
prompt = str(args.get("prompt") or "").strip()
|
|
977
|
+
if not prompt:
|
|
978
|
+
return ToolResult.error("'prompt' is required")
|
|
979
|
+
|
|
980
|
+
token_cap: int | None = None
|
|
981
|
+
max_tokens = get_budget().get("max_tokens")
|
|
982
|
+
if max_tokens:
|
|
983
|
+
remaining = int(max_tokens) - self.session.total_tokens
|
|
984
|
+
if remaining <= 0:
|
|
985
|
+
return ToolResult.error(
|
|
986
|
+
"The token budget is already exhausted; raise it with "
|
|
987
|
+
"`/config budget tokens <n>` before delegating."
|
|
988
|
+
)
|
|
989
|
+
token_cap = remaining
|
|
990
|
+
|
|
991
|
+
model = (
|
|
992
|
+
str(args.get("model") or "").strip()
|
|
993
|
+
or str(settings["model"])
|
|
994
|
+
or self.session.model
|
|
995
|
+
)
|
|
996
|
+
max_steps = int(settings["max_steps"])
|
|
997
|
+
child_session = self._build_child_session(model)
|
|
998
|
+
child = Agent(
|
|
999
|
+
child_session,
|
|
1000
|
+
TaskConsole(self.console),
|
|
1001
|
+
self.confirm,
|
|
1002
|
+
bus=self.bus,
|
|
1003
|
+
ascii_mode=self.ascii_mode,
|
|
1004
|
+
output_mode=self.output_mode,
|
|
1005
|
+
spinner=self.spinner,
|
|
1006
|
+
)
|
|
1007
|
+
|
|
1008
|
+
child_session.messages.append({"role": "user", "content": prompt})
|
|
1009
|
+
steps = 0
|
|
1010
|
+
last_text = ""
|
|
1011
|
+
last_state = ""
|
|
1012
|
+
stop_note = ""
|
|
1013
|
+
try:
|
|
1014
|
+
while True:
|
|
1015
|
+
try:
|
|
1016
|
+
assistant_msg, tool_calls = await child._stream_once()
|
|
1017
|
+
except ProviderError as exc:
|
|
1018
|
+
return ToolResult.error(f"Subagent failed: {exc}")
|
|
1019
|
+
child_session.messages.append(assistant_msg)
|
|
1020
|
+
if assistant_msg.get("content"):
|
|
1021
|
+
last_text = str(assistant_msg["content"])
|
|
1022
|
+
if not tool_calls:
|
|
1023
|
+
break
|
|
1024
|
+
await child._handle_tool_calls(tool_calls)
|
|
1025
|
+
steps += 1
|
|
1026
|
+
last_message = child_session.messages[-1]
|
|
1027
|
+
if last_message.get("role") == "tool":
|
|
1028
|
+
last_state = str(last_message.get("content") or "")
|
|
1029
|
+
if steps >= max_steps:
|
|
1030
|
+
stop_note = f"step limit ({max_steps}) reached"
|
|
1031
|
+
break
|
|
1032
|
+
if token_cap is not None and child_session.total_tokens >= token_cap:
|
|
1033
|
+
stop_note = (
|
|
1034
|
+
f"inherited token budget ({token_cap:,} tokens) reached"
|
|
1035
|
+
)
|
|
1036
|
+
break
|
|
1037
|
+
finally:
|
|
1038
|
+
# Subagent usage counts against the parent's budget and its edits
|
|
1039
|
+
# stay undoable from the parent conversation.
|
|
1040
|
+
self.session.add_usage(
|
|
1041
|
+
child_session.prompt_tokens, child_session.completion_tokens
|
|
1042
|
+
)
|
|
1043
|
+
for path in child_session.touched_files:
|
|
1044
|
+
self.session.record_touched(path)
|
|
1045
|
+
if child_session.checkpoints:
|
|
1046
|
+
self.session.checkpoints.extend(child_session.checkpoints)
|
|
1047
|
+
if self.session.checkpoint_store is None:
|
|
1048
|
+
self.session.checkpoint_store = child_session.checkpoint_store
|
|
1049
|
+
|
|
1050
|
+
report = last_text.strip() or last_state.strip() or "(no final answer)"
|
|
1051
|
+
if stop_note:
|
|
1052
|
+
report += f"\n\n[task stopped: {stop_note}; partial report]"
|
|
1053
|
+
content = (
|
|
1054
|
+
f"[task result]\n{report}\n\n"
|
|
1055
|
+
f"[task: {steps} step(s), {child_session.total_tokens:,} token(s)]"
|
|
1056
|
+
)
|
|
1057
|
+
if len(content) > TASK_RESULT_MAX_CHARS:
|
|
1058
|
+
content = content[:TASK_RESULT_MAX_CHARS] + "\n... [truncated]"
|
|
1059
|
+
return ToolResult(content=content)
|
|
1060
|
+
|
|
1061
|
+
def _post_edit_diagnostics(self, args: dict[str, Any]) -> str:
|
|
1062
|
+
"""Return ``LSP:`` lines for an edited file, or an empty string.
|
|
1063
|
+
|
|
1064
|
+
Best-effort and bounded: disabled config, a missing server, a timeout,
|
|
1065
|
+
or any other failure appends nothing and never delays past the
|
|
1066
|
+
configured timeout (capped further for post-edit latency).
|
|
1067
|
+
"""
|
|
1068
|
+
from kiwimatecoder import config, lsp
|
|
1069
|
+
from kiwimatecoder.tools.lsp import format_diagnostic
|
|
1070
|
+
|
|
1071
|
+
path = str(args.get("path") or "").strip()
|
|
1072
|
+
if not path:
|
|
1073
|
+
return ""
|
|
1074
|
+
try:
|
|
1075
|
+
settings = config.get_lsp()
|
|
1076
|
+
if not (settings["enabled"] and settings["diagnostics_after_edits"]):
|
|
1077
|
+
return ""
|
|
1078
|
+
target = Path(path)
|
|
1079
|
+
if not target.is_absolute():
|
|
1080
|
+
target = self.session.workspace_root / target
|
|
1081
|
+
manager = lsp.get_manager()
|
|
1082
|
+
if manager is None:
|
|
1083
|
+
manager = lsp.ensure_manager(root=self.session.workspace_root)
|
|
1084
|
+
timeout = min(float(settings["timeout"]), POST_EDIT_DIAGNOSTICS_TIMEOUT)
|
|
1085
|
+
diagnostics = manager.diagnostics_for(str(target), timeout=timeout)
|
|
1086
|
+
except Exception:
|
|
1087
|
+
return ""
|
|
1088
|
+
if not diagnostics:
|
|
1089
|
+
return ""
|
|
1090
|
+
shown = diagnostics[:MAX_EDIT_DIAGNOSTICS]
|
|
1091
|
+
lines = [f"LSP: {format_diagnostic(path, item)}" for item in shown]
|
|
1092
|
+
hidden = len(diagnostics) - len(shown)
|
|
1093
|
+
if hidden > 0:
|
|
1094
|
+
lines.append(f"LSP: … {hidden} more diagnostic(s)")
|
|
1095
|
+
return "\n" + "\n".join(lines)
|
|
1096
|
+
|
|
1097
|
+
def _verbose_args(self, args: dict[str, Any]) -> str:
|
|
1098
|
+
"""Render the verbose-mode argument block: redacted and capped."""
|
|
1099
|
+
try:
|
|
1100
|
+
rendered = json.dumps(args, ensure_ascii=False, sort_keys=True, default=str)
|
|
1101
|
+
except (TypeError, ValueError):
|
|
1102
|
+
rendered = str(args)
|
|
1103
|
+
rendered = redact(rendered)
|
|
1104
|
+
if len(rendered) > 500:
|
|
1105
|
+
rendered = rendered[:497] + "..."
|
|
1106
|
+
return f"[dim]args: {escape(rendered)}[/dim]"
|
|
1107
|
+
|
|
1108
|
+
def _run_pre_tool_hooks(
|
|
1109
|
+
self, name: str, args: dict[str, Any]
|
|
1110
|
+
) -> list[hooks.HookResult]:
|
|
1111
|
+
"""Emit PRE_TOOL and run configured pre-tool shell hooks."""
|
|
1112
|
+
self.bus.emit(events.PRE_TOOL, tool=name, args=args)
|
|
1113
|
+
return hooks.run_hooks(
|
|
1114
|
+
events.PRE_TOOL,
|
|
1115
|
+
session=self.session,
|
|
1116
|
+
console=self.console,
|
|
1117
|
+
tool_name=name,
|
|
1118
|
+
tool_args=args,
|
|
1119
|
+
)
|
|
1120
|
+
|
|
1121
|
+
def _run_post_tool_hooks(
|
|
1122
|
+
self,
|
|
1123
|
+
name: str,
|
|
1124
|
+
args: dict[str, Any],
|
|
1125
|
+
ok: bool,
|
|
1126
|
+
duration_ms: int,
|
|
1127
|
+
decision: str,
|
|
1128
|
+
) -> None:
|
|
1129
|
+
"""Emit POST_TOOL and run configured post-tool shell hooks."""
|
|
1130
|
+
self.bus.emit(
|
|
1131
|
+
events.POST_TOOL,
|
|
1132
|
+
tool=name,
|
|
1133
|
+
args=args,
|
|
1134
|
+
ok=ok,
|
|
1135
|
+
duration_ms=duration_ms,
|
|
1136
|
+
decision=decision,
|
|
1137
|
+
)
|
|
1138
|
+
hooks.run_hooks(
|
|
1139
|
+
events.POST_TOOL,
|
|
1140
|
+
session=self.session,
|
|
1141
|
+
console=self.console,
|
|
1142
|
+
tool_name=name,
|
|
1143
|
+
tool_args=args,
|
|
1144
|
+
ok=ok,
|
|
1145
|
+
duration_ms=duration_ms,
|
|
1146
|
+
)
|
|
1147
|
+
|
|
1148
|
+
def _hook_block_reason(self, result: hooks.HookResult) -> str:
|
|
1149
|
+
detail = "timed out" if result.timed_out else f"exit code {result.exit_code}"
|
|
1150
|
+
return f"blocked by pre_tool hook ({detail}): {redact(result.command)}"
|
|
1151
|
+
|
|
1152
|
+
def _hook_block_message(self, result: hooks.HookResult) -> str:
|
|
1153
|
+
message = (
|
|
1154
|
+
f"Error: {self._hook_block_reason(result)}. The action was not "
|
|
1155
|
+
"executed. Fix the problem the hook reported, then try again."
|
|
1156
|
+
)
|
|
1157
|
+
output = redact(result.output.strip())
|
|
1158
|
+
if output:
|
|
1159
|
+
if len(output) > 2000:
|
|
1160
|
+
output = output[:2000] + "\n... [truncated]"
|
|
1161
|
+
message += f"\n{output}"
|
|
1162
|
+
return message
|