minimalcode 1.5.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. minimalcode/__init__.py +3 -0
  2. minimalcode/__main__.py +3 -0
  3. minimalcode/agent.py +1084 -0
  4. minimalcode/cli.py +1520 -0
  5. minimalcode/clipboard.py +212 -0
  6. minimalcode/config.py +1065 -0
  7. minimalcode/defaults.json +491 -0
  8. minimalcode/errors.py +31 -0
  9. minimalcode/highlight/__init__.py +47 -0
  10. minimalcode/highlight/code.py +261 -0
  11. minimalcode/highlight/color.py +58 -0
  12. minimalcode/highlight/markdown.py +710 -0
  13. minimalcode/highlight/palette.py +73 -0
  14. minimalcode/highlight/sanitize.py +175 -0
  15. minimalcode/highlight/thinking.py +71 -0
  16. minimalcode/httpclient.py +278 -0
  17. minimalcode/importer.py +253 -0
  18. minimalcode/prompt.py +195 -0
  19. minimalcode/provider/__init__.py +28 -0
  20. minimalcode/provider/anthropic.py +805 -0
  21. minimalcode/provider/anthropic_env.py +161 -0
  22. minimalcode/provider/chat.py +394 -0
  23. minimalcode/provider/client.py +243 -0
  24. minimalcode/provider/common.py +176 -0
  25. minimalcode/provider/deepseek_web.py +1158 -0
  26. minimalcode/provider/factory.py +63 -0
  27. minimalcode/provider/responses.py +692 -0
  28. minimalcode/provider/sha3_wasm_bg.wasm +0 -0
  29. minimalcode/provider/transport.py +241 -0
  30. minimalcode/provider/types.py +54 -0
  31. minimalcode/sandbox/__init__.py +64 -0
  32. minimalcode/sandbox/audit.py +120 -0
  33. minimalcode/sandbox/backends.py +343 -0
  34. minimalcode/sandbox/execution.py +435 -0
  35. minimalcode/sandbox/network.py +793 -0
  36. minimalcode/sandbox/policy.py +253 -0
  37. minimalcode/sandbox/runner.py +347 -0
  38. minimalcode/sandbox/sensitive.py +286 -0
  39. minimalcode/session.py +465 -0
  40. minimalcode/shell.py +333 -0
  41. minimalcode/subagent.py +120 -0
  42. minimalcode/tools/__init__.py +46 -0
  43. minimalcode/tools/files.py +1139 -0
  44. minimalcode/tools/process.py +164 -0
  45. minimalcode/tools/remote.py +74 -0
  46. minimalcode/tools/runtime.py +219 -0
  47. minimalcode/tools/schemas.py +418 -0
  48. minimalcode/tools/search.py +521 -0
  49. minimalcode/tools/search_tools.py +583 -0
  50. minimalcode/tools/validation.py +99 -0
  51. minimalcode/vision.py +231 -0
  52. minimalcode/web.py +238 -0
  53. minimalcode-1.5.2.dist-info/METADATA +646 -0
  54. minimalcode-1.5.2.dist-info/RECORD +58 -0
  55. minimalcode-1.5.2.dist-info/WHEEL +5 -0
  56. minimalcode-1.5.2.dist-info/entry_points.txt +2 -0
  57. minimalcode-1.5.2.dist-info/licenses/LICENSE +201 -0
  58. minimalcode-1.5.2.dist-info/top_level.txt +1 -0
minimalcode/agent.py ADDED
@@ -0,0 +1,1084 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import sys
5
+ import threading
6
+ from dataclasses import dataclass, field
7
+ from pathlib import Path
8
+ from typing import Any, TextIO
9
+
10
+ from .config import Settings
11
+ from .highlight import (
12
+ MarkdownHighlighter,
13
+ TerminalSanitizer,
14
+ ThinkingRenderer,
15
+ sanitize_terminal_text,
16
+ style_activity_tag,
17
+ )
18
+ from .prompt import build_system_prompt, merge_agents_summary
19
+ from .provider import OpenAICompatibleProvider, ProviderError, ToolCall
20
+ from .session import Session
21
+ from .shell import ShellSpec
22
+ from .tools import ToolError, ToolRuntime, available_tool_schemas, tool_result_text
23
+ from .tools.schemas import READ_ONLY_TOOLS
24
+
25
+
26
+ def _is_wide_character(char: str) -> bool:
27
+ """Return True for scripts whose characters cost roughly one token each."""
28
+ code = ord(char)
29
+ return (
30
+ 0x1100 <= code <= 0x115F # Hangul Jamo
31
+ or 0x2E80 <= code <= 0x9FFF # CJK radicals through CJK unified ideographs
32
+ or 0xA000 <= code <= 0xA4CF # Yi
33
+ or 0xAC00 <= code <= 0xD7A3 # Hangul syllables
34
+ or 0xF900 <= code <= 0xFAFF # CJK compatibility ideographs
35
+ or 0xFE30 <= code <= 0xFE6F # CJK compatibility forms
36
+ or 0xFF00 <= code <= 0xFF60 # Fullwidth forms
37
+ or 0xFFE0 <= code <= 0xFFE6
38
+ or 0x1F300 <= code <= 0x1FAFF # Emoji
39
+ or 0x20000 <= code <= 0x3FFFD
40
+ )
41
+
42
+
43
+ def estimate_text_tokens(text: str) -> int:
44
+ """Estimate tokens for one string, weighted by character width.
45
+
46
+ ``len(text) // 4`` is a decent ASCII rule and a fourfold undercount for CJK:
47
+ a Chinese character carries roughly one token, not a quarter of one. Because
48
+ this estimate is the compaction trigger and the fallback when a provider
49
+ omits usage, undercounting silently lets the history outgrow the window.
50
+ """
51
+ if not text:
52
+ return 0
53
+ wide = 0
54
+ for char in text:
55
+ if _is_wide_character(char):
56
+ wide += 1
57
+ narrow = len(text) - wide
58
+ return wide + (narrow + 3) // 4
59
+
60
+
61
+ def estimate_tokens(messages: list[dict[str, Any]]) -> int:
62
+ return max(1, estimate_text_tokens(json.dumps(messages, ensure_ascii=False)))
63
+
64
+
65
+ def _tool_output_preview(result: dict[str, Any]) -> str:
66
+ if result.get("status") == "success":
67
+ if "stderr" in result and result.get("stderr"):
68
+ return f"exit {result.get('exit_code', 0)}; stderr present"
69
+ return "ok"
70
+ return str(result.get("message") or result.get("status") or "error")
71
+
72
+
73
+ _ACTIVITY_OUTPUT_MAX_LINES = 12
74
+ _ACTIVITY_OUTPUT_MAX_CHARS = 4000
75
+ # One model turn that asks for hundreds of calls is either a broken provider
76
+ # or an attempted resource exhaustion; it is never a normal coding step.
77
+ _MAX_TOOL_CALLS_PER_TURN = 32
78
+ _INCOMPLETE_FINISH_REASONS = {
79
+ "length",
80
+ "max_tokens",
81
+ "content_filter",
82
+ "max_output_tokens",
83
+ "failed",
84
+ "cancelled",
85
+ "canceled",
86
+ "incomplete",
87
+ "incomplete_stream",
88
+ "in_progress",
89
+ "queued",
90
+ "server_error",
91
+ }
92
+ # A compaction replaces the dropped prefix with this many synthetic messages, so
93
+ # a cut has to remove more than that to make the history smaller.
94
+ _COMPACT_HEAD_MESSAGES = 2
95
+ _COMPACTED_SUMMARY_PREFIX = (
96
+ "[Earlier conversation compacted. Continue from this state.]"
97
+ )
98
+ _COMPACTED_SUMMARY_ACK = "Context received. I will continue from the recorded state."
99
+
100
+
101
+ def _clip_activity_output(text: str) -> str:
102
+ """Keep tool activity readable without echoing entire command output."""
103
+ lines = text.splitlines()
104
+ if len(lines) > _ACTIVITY_OUTPUT_MAX_LINES:
105
+ head = _ACTIVITY_OUTPUT_MAX_LINES // 2
106
+ tail = _ACTIVITY_OUTPUT_MAX_LINES - head
107
+ omitted = len(lines) - _ACTIVITY_OUTPUT_MAX_LINES
108
+ lines = [*lines[:head], f" … {omitted} lines omitted", *lines[-tail:]]
109
+ clipped = "\n".join(lines)
110
+ if len(clipped) > _ACTIVITY_OUTPUT_MAX_CHARS:
111
+ clipped = clipped[:_ACTIVITY_OUTPUT_MAX_CHARS] + "…"
112
+ return clipped
113
+
114
+
115
+ def _shell_output_activity(result: dict[str, Any]) -> str:
116
+ lines: list[str] = []
117
+ exit_code = result.get("exit_code")
118
+ if isinstance(exit_code, int):
119
+ lines.append(f" exit {exit_code}")
120
+ else:
121
+ lines.append(f" {result.get('status', 'error')}")
122
+
123
+ stdout = _clip_activity_output(str(result.get("stdout") or "").rstrip())
124
+ stderr = _clip_activity_output(str(result.get("stderr") or "").rstrip())
125
+ message = str(result.get("message") or "").strip()
126
+ if stdout:
127
+ lines.extend((" stdout:", stdout))
128
+ if stderr:
129
+ lines.extend((" stderr:", stderr))
130
+ if message:
131
+ lines.append(f" {message}")
132
+ return "\n".join(lines)
133
+
134
+
135
+ def _repair_tool_history(
136
+ messages: list[dict[str, Any]],
137
+ ) -> tuple[list[dict[str, Any]], bool]:
138
+ repaired: list[dict[str, Any]] = []
139
+ changed = False
140
+ call_order: list[str] | None = None
141
+ call_names: dict[str, str] = {}
142
+ block_results: list[dict[str, Any]] = []
143
+
144
+ def synthetic_result(call_id: str, name: str) -> dict[str, Any]:
145
+ return {
146
+ "role": "tool",
147
+ "tool_call_id": call_id,
148
+ "name": name,
149
+ "content": json.dumps(
150
+ {
151
+ "status": "error",
152
+ "message": "interrupted before tool result was recorded",
153
+ },
154
+ separators=(",", ":"),
155
+ ),
156
+ }
157
+
158
+ def flush_block() -> None:
159
+ nonlocal changed, call_order, call_names, block_results
160
+ if call_order is None:
161
+ block_results = []
162
+ return
163
+ by_id: dict[str, dict[str, Any]] = {}
164
+ for result in block_results:
165
+ call_id = str(result.get("tool_call_id") or "")
166
+ if call_id in call_names and call_id not in by_id:
167
+ by_id[call_id] = result
168
+ else:
169
+ # A result for an unknown or already answered call id makes
170
+ # most providers reject the whole request; drop it.
171
+ changed = True
172
+ for call_id in call_order:
173
+ result = by_id.get(call_id)
174
+ if result is not None:
175
+ repaired.append(result)
176
+ else:
177
+ repaired.append(synthetic_result(call_id, call_names[call_id]))
178
+ changed = True
179
+ call_order = None
180
+ call_names = {}
181
+ block_results = []
182
+
183
+ for message in messages:
184
+ role = message.get("role")
185
+ if call_order is not None and role != "tool":
186
+ flush_block()
187
+ if role == "tool" and call_order is not None:
188
+ block_results.append(dict(message))
189
+ continue
190
+ if role == "tool":
191
+ # Orphan result: no assistant tool_calls precedes it, so replaying
192
+ # it is a protocol error for every OpenAI-compatible server.
193
+ changed = True
194
+ continue
195
+ if (
196
+ role == "assistant"
197
+ and message.get("content") is None
198
+ and (
199
+ not isinstance(message.get("tool_calls"), list)
200
+ or not message["tool_calls"]
201
+ )
202
+ ):
203
+ # Older sessions stored an empty reply as null content. Providers
204
+ # reject that shape on the next request, so repair it in place.
205
+ repaired.append({**message, "content": ""})
206
+ changed = True
207
+ else:
208
+ repaired.append(dict(message))
209
+ if role == "assistant" and isinstance(message.get("tool_calls"), list):
210
+ call_order = []
211
+ call_names = {}
212
+ for call in message["tool_calls"]:
213
+ if not isinstance(call, dict):
214
+ continue
215
+ call_id = str(call.get("id") or "")
216
+ function = call.get("function")
217
+ # A hand-edited session file can carry a non-object `function`;
218
+ # reading it with `.get` raised AttributeError before any turn ran.
219
+ name = (
220
+ str(function.get("name") or "")
221
+ if isinstance(function, dict)
222
+ else ""
223
+ )
224
+ if call_id:
225
+ if call_id not in call_names:
226
+ call_order.append(call_id)
227
+ call_names[call_id] = name
228
+ flush_block()
229
+ return repaired, changed
230
+
231
+
232
+ @dataclass(slots=True)
233
+ class AgentResult:
234
+ text: str
235
+ completed: bool
236
+ usage: dict[str, int] = field(default_factory=dict)
237
+ run_usage: dict[str, int] = field(default_factory=dict)
238
+ turns: int = 0
239
+ # True when at least one turn in this run had no provider-reported usage and
240
+ # the numbers came from ``estimate_tokens`` instead.
241
+ usage_estimated: bool = False
242
+
243
+
244
+ SUMMARY_PROMPT = """\
245
+ Summarize this coding-agent session as durable repository notes for the next \
246
+ session. Record what was asked, the files created or modified, the decisions \
247
+ taken and why, the commands and tests that were actually run with their \
248
+ outcomes, and any remaining work, blockers, or known limitations. Write short \
249
+ Markdown sections with terse bullets. Omit greetings, narration, and reasoning \
250
+ that did not change the result. Never claim a check ran unless the transcript \
251
+ shows it ran, and do not wrap the answer in a code fence.
252
+ """
253
+
254
+
255
+ def _is_compacted_head(messages: list[dict[str, Any]]) -> bool:
256
+ """Return True when the history already starts with a compaction summary.
257
+
258
+ Replacing that pair again would summarize the summary and leave an equally
259
+ long history, which is what used to happen on every turn of a long tool
260
+ loop.
261
+ """
262
+ if len(messages) < _COMPACT_HEAD_MESSAGES:
263
+ return False
264
+ first, second = messages[0], messages[1]
265
+ content = first.get("content")
266
+ # The summary prefix identifies the pair; the acknowledgement wording is not
267
+ # matched exactly so a history written by an older version still counts.
268
+ return (
269
+ first.get("role") == "user"
270
+ and isinstance(content, str)
271
+ and content.startswith(_COMPACTED_SUMMARY_PREFIX)
272
+ and second.get("role") == "assistant"
273
+ )
274
+
275
+
276
+ def _compaction_boundaries(messages: list[dict[str, Any]], target: int) -> list[int]:
277
+ """Return replay-safe cut points in preference order.
278
+
279
+ A cut may only land before a message that does not belong to a tool-call
280
+ block, otherwise the kept tail starts with an orphan tool result. The
281
+ largest usable tail wins, so the search starts at ``target`` and walks
282
+ backwards to the nearest user message.
283
+
284
+ A long tool loop inside one user turn can leave no user message between the
285
+ previous summary and ``target``. Cutting there would re-summarize the
286
+ summary without shrinking anything, so the next user message after
287
+ ``target`` is offered as well: the summary still covers everything that is
288
+ dropped, including the tool results in between.
289
+
290
+ Some session files start mid-turn and have no user message at all; the last
291
+ candidate is any message outside a tool-call block so compaction can still
292
+ make progress.
293
+ """
294
+ boundaries: list[int] = []
295
+ for index in range(target, 0, -1):
296
+ if messages[index].get("role") == "user":
297
+ boundaries.append(index)
298
+ break
299
+ for index in range(target + 1, len(messages)):
300
+ if messages[index].get("role") == "user":
301
+ boundaries.append(index)
302
+ break
303
+ for index in range(target, 0, -1):
304
+ if messages[index].get("role") == "tool":
305
+ continue
306
+ previous = messages[index - 1]
307
+ if previous.get("role") == "assistant" and previous.get("tool_calls"):
308
+ continue
309
+ boundaries.append(index)
310
+ break
311
+ return boundaries
312
+
313
+
314
+ class Agent:
315
+ def __init__(
316
+ self,
317
+ *,
318
+ settings: Settings,
319
+ provider: OpenAICompatibleProvider,
320
+ session: Session,
321
+ tool_runtime: ToolRuntime,
322
+ shell: ShellSpec,
323
+ max_turns: int | None = None,
324
+ output: TextIO | None = None,
325
+ error_output: TextIO | None = None,
326
+ quiet: bool = False,
327
+ stream_output: bool = True,
328
+ markdown_highlight: bool | None = None,
329
+ ):
330
+ self.settings = settings
331
+ self.provider = provider
332
+ self.session = session
333
+ self.tools = tool_runtime
334
+ self.tool_schemas = available_tool_schemas(
335
+ self.tools.web_client,
336
+ self.tools.vision_client,
337
+ self.tools.subagent_enabled,
338
+ allowed=self._allowed_tools(),
339
+ )
340
+ self.shell = shell
341
+ configured_turns = (
342
+ settings.get("max_turns", 1000) if max_turns is None else max_turns
343
+ )
344
+ self.max_turns = max(1, int(configured_turns))
345
+ self.output = output or sys.stdout
346
+ self.error_output = error_output or sys.stderr
347
+ self.quiet = quiet
348
+ self.stream_output = stream_output
349
+ # Retry notices travel through the activity stream, so --quiet and
350
+ # --json stay silent while an interactive turn shows the retries.
351
+ if hasattr(self.provider, "notify"):
352
+ self.provider.notify = self._activity
353
+ highlight_enabled = (
354
+ bool(settings.get("output.markdown_highlight", True))
355
+ if markdown_highlight is None
356
+ else markdown_highlight
357
+ )
358
+ self._highlighter = (
359
+ MarkdownHighlighter(
360
+ self.output,
361
+ theme=str(settings.get("output.theme", "dark")),
362
+ )
363
+ if stream_output and highlight_enabled
364
+ else None
365
+ )
366
+ # Reasoning is shown in the six-level heading style, so it follows the
367
+ # same switch as highlighting: no ANSI means no reasoning block, and
368
+ # redirected stdout keeps carrying the answer alone.
369
+ self._thinking_renderer = (
370
+ ThinkingRenderer(
371
+ self.output,
372
+ theme=str(settings.get("output.theme", "dark")),
373
+ )
374
+ if stream_output and highlight_enabled
375
+ else None
376
+ )
377
+ self.last_prompt_tokens = 0
378
+ # Number of session messages covered by last_prompt_tokens. Messages
379
+ # appended after the request (the new user turn and tool results) are
380
+ # not in the provider-reported prompt size yet.
381
+ self._prompt_session_count = 0
382
+ # Number of messages at the last failed compaction. Retrying the same
383
+ # summary request before every turn can dominate the bill when the
384
+ # provider is down; wait for the history to grow instead.
385
+ self._compact_blocked_at: int | None = None
386
+ self.estimated_turns = 0
387
+ self._system_prompt: str | None = None
388
+ self._plain_sanitizer = TerminalSanitizer()
389
+ self.total_usage = {
390
+ "prompt_tokens": 0,
391
+ "completion_tokens": 0,
392
+ "total_tokens": 0,
393
+ "cached_tokens": 0,
394
+ "reasoning_tokens": 0,
395
+ }
396
+
397
+ self._repair_history()
398
+
399
+ def _repair_history(self) -> None:
400
+ """Close tool_calls left without results by an interrupted turn.
401
+
402
+ Ctrl-C or a provider failure can stop the loop between an assistant
403
+ tool call and its result. Replaying that history makes most providers
404
+ reject the next request, so the gap is filled before every run.
405
+ """
406
+ repaired, changed = _repair_tool_history(self.session.messages)
407
+ if changed:
408
+ self.session.replace_messages(
409
+ repaired,
410
+ summary="Recovered an interrupted tool turn.",
411
+ dropped_messages=0,
412
+ )
413
+
414
+ def refresh_instructions(self) -> None:
415
+ """Drop the cached system prompt so the next build re-reads instructions.
416
+
417
+ ``AGENTS.md`` / ``CLAUDE.md`` are read at the start of every run rather
418
+ than once per process: ``/summary`` and external editors rewrite them
419
+ between turns, and a stale copy would silently keep old rules.
420
+ """
421
+ self._system_prompt = None
422
+
423
+ def _system_messages(self) -> list[dict[str, Any]]:
424
+ if self._system_prompt is None:
425
+ # Building the prompt reads SYSTEM.md plus every AGENTS.md/CLAUDE.md
426
+ # on the path; missing files are skipped. The tool loop calls this
427
+ # several times per run, so the result is cached until the next run.
428
+ self._system_prompt = build_system_prompt(
429
+ self.settings,
430
+ self.session.cwd,
431
+ self.shell,
432
+ )
433
+ return [{"role": "system", "content": self._system_prompt}]
434
+
435
+ def _messages(self) -> list[dict[str, Any]]:
436
+ return [*self._system_messages(), *self.session.messages]
437
+
438
+ def _accumulate_usage(
439
+ self,
440
+ usage: dict[str, int],
441
+ messages: list[dict[str, Any]],
442
+ reply_text: str = "",
443
+ *,
444
+ update_prompt_state: bool = True,
445
+ ) -> None:
446
+ """Record one turn's usage, estimating only what the provider omitted.
447
+
448
+ A missing usage object is not the same as a turn that cost nothing: Chat
449
+ servers that ignore ``stream_options`` report nothing at all, and
450
+ printing ``0 completion`` for a real answer is a lie. Estimates are
451
+ flagged so the CLI can mark them instead of presenting them as reported.
452
+ Compaction and summary requests pass ``update_prompt_state=False`` so
453
+ their prompt size is counted in the totals without becoming the main
454
+ conversation's compaction trigger.
455
+ """
456
+ estimated = not usage
457
+ if estimated:
458
+ prompt = estimate_tokens(messages)
459
+ completion = estimate_text_tokens(reply_text)
460
+ normalized = {
461
+ "prompt_tokens": prompt,
462
+ "completion_tokens": completion,
463
+ "total_tokens": prompt + completion,
464
+ }
465
+ else:
466
+ normalized = dict(usage)
467
+ if reply_text and not int(normalized.get("completion_tokens", 0) or 0):
468
+ prompt = int(normalized.get("prompt_tokens", 0) or 0)
469
+ completion = estimate_text_tokens(reply_text)
470
+ normalized["completion_tokens"] = completion
471
+ normalized["total_tokens"] = max(
472
+ int(normalized.get("total_tokens", 0) or 0), prompt + completion
473
+ )
474
+ estimated = True
475
+ for key in self.total_usage:
476
+ self.total_usage[key] += int(normalized.get(key, 0))
477
+ if update_prompt_state:
478
+ self.last_prompt_tokens = int(normalized.get("prompt_tokens", 0))
479
+ if estimated:
480
+ self.estimated_turns += 1
481
+ self.session.append_usage(normalized)
482
+
483
+ def _complete_text(self, messages: list[dict[str, Any]]) -> str:
484
+ """Complete a summary/compaction request and account for its usage."""
485
+ complete = getattr(self.provider, "complete", None)
486
+ if callable(complete):
487
+ reply = complete(messages)
488
+ self._accumulate_usage(
489
+ reply.usage,
490
+ messages,
491
+ reply.content,
492
+ update_prompt_state=False,
493
+ )
494
+ return reply.content
495
+ # Lightweight providers used by embedders and tests only implement
496
+ # ``complete_text``; there is no usage object to record.
497
+ return self.provider.complete_text(messages)
498
+
499
+ def _close_thinking(self) -> None:
500
+ """Finish the reasoning block and resync the Markdown line state."""
501
+ if self._thinking_renderer is None:
502
+ return
503
+ if self._thinking_renderer.finish() and self._highlighter is not None:
504
+ self._highlighter.note_external_newline()
505
+
506
+ def _write_delta(self, text: str) -> None:
507
+ # Close the reasoning block first, so the answer never continues on the
508
+ # styled line the reasoning was written to.
509
+ self._close_thinking()
510
+ if self.stream_output:
511
+ if self._highlighter is not None:
512
+ self._highlighter.feed(text)
513
+ else:
514
+ safe = self._plain_sanitizer.feed(text)
515
+ if safe:
516
+ self.output.write(safe)
517
+ self.output.flush()
518
+
519
+ def _write_thinking(self, text: str) -> None:
520
+ """Render one reasoning delta in the six-level heading style."""
521
+ if self._thinking_renderer is not None:
522
+ self._thinking_renderer.feed(text)
523
+
524
+ def _finish_delta(self) -> None:
525
+ self._close_thinking()
526
+ if self._highlighter is not None:
527
+ self._highlighter.finish()
528
+ else:
529
+ self._plain_sanitizer.finish()
530
+ self._plain_sanitizer = TerminalSanitizer()
531
+
532
+ def _activity(self, message: str) -> None:
533
+ if not self.quiet:
534
+ self.error_output.write(
535
+ style_activity_tag(message, self.error_output) + "\n"
536
+ )
537
+ self.error_output.flush()
538
+
539
+ def _parse_tool_arguments(self, raw_arguments: str) -> dict[str, Any]:
540
+ try:
541
+ arguments = json.loads(raw_arguments or "{}")
542
+ except json.JSONDecodeError as exc:
543
+ raise ToolError(f"invalid tool JSON: {exc}") from exc
544
+ if not isinstance(arguments, dict):
545
+ raise ToolError("tool arguments must be an object")
546
+ return arguments
547
+
548
+ def _run_tool(self, name: str, arguments: dict[str, Any]) -> dict[str, Any]:
549
+ try:
550
+ return self.tools.dispatch(name, arguments)
551
+ except (ToolError, OSError, TypeError, ValueError) as exc:
552
+ return {"status": "error", "message": str(exc)}
553
+ except Exception as exc: # noqa: BLE001 - a bad tool must not end the turn
554
+ # Any unexpected failure inside a handler is still reported to the
555
+ # model as a tool result; re-raising would abort the whole turn and
556
+ # leave the user message without an answer. Subagent workers rely on
557
+ # the same contract, so this never raises inside a thread either.
558
+ return {"status": "error", "message": f"{type(exc).__name__}: {exc}"}
559
+
560
+ def _execute_tool(self, name: str, raw_arguments: str) -> dict[str, Any]:
561
+ try:
562
+ arguments = self._parse_tool_arguments(raw_arguments)
563
+ except ToolError as exc:
564
+ return {"status": "error", "message": str(exc)}
565
+ self._activity(self.tools.activity(name, arguments))
566
+ return self._run_tool(name, arguments)
567
+
568
+ def _allowed_tools(
569
+ self, tool_runtime: ToolRuntime | None = None
570
+ ) -> frozenset[str] | None:
571
+ """Return the tool names the runtime accepts, or None when unrestricted."""
572
+ allowed = getattr(tool_runtime or self.tools, "allowed_tools", None)
573
+ return allowed if isinstance(allowed, frozenset) else None
574
+
575
+ def _execute_tool_calls(self, calls: list[ToolCall]) -> list[dict[str, Any]]:
576
+ """Run one turn's tool calls, overlapping adjacent independent calls.
577
+
578
+ A subagent is a separate process that shares no in-memory state with
579
+ the parent, and the read-only tools cannot change the workspace, so a
580
+ run of adjacent calls from either set executes at the same time and
581
+ still reports results in request order. Everything else keeps strict
582
+ order: a ``write`` followed by a command that reads the file depends on
583
+ the earlier call finishing, so an independent group never overlaps a
584
+ neighbouring tool. Mixing a read with a write would also let the read
585
+ observe a half-written file.
586
+ """
587
+ results: list[dict[str, Any]] = []
588
+ index = 0
589
+ while index < len(calls):
590
+ name = calls[index].name
591
+ end = index + 1
592
+ limit = 1
593
+ if name == "subagent":
594
+ while end < len(calls) and calls[end].name == "subagent":
595
+ end += 1
596
+ limit = self.tools.subagent_max_parallel
597
+ elif name in READ_ONLY_TOOLS:
598
+ while end < len(calls) and calls[end].name in READ_ONLY_TOOLS:
599
+ end += 1
600
+ limit = self.tools.max_parallel
601
+ if limit <= 1:
602
+ # A group larger than the limit still has to answer every call:
603
+ # taking only the first one left the rest unexecuted and
604
+ # unanswered, and a provider rejects an unpaired tool call on
605
+ # the next turn.
606
+ for call in calls[index:end]:
607
+ results.append(self._execute_tool(call.name, call.arguments))
608
+ elif end - index == 1:
609
+ results.append(self._execute_tool(name, calls[index].arguments))
610
+ else:
611
+ results.extend(self._execute_group(calls[index:end], limit))
612
+ index = end
613
+ return results
614
+
615
+ def _execute_group(self, calls: list[ToolCall], limit: int) -> list[dict[str, Any]]:
616
+ """Run one independent group concurrently, in call order.
617
+
618
+ Activity lines are still written from this thread before the workers
619
+ start, so the terminal shows requests in the order the model made them.
620
+ """
621
+ # Every slot gets an answer up front: a worker that dies before writing
622
+ # its result must not turn into a KeyError that aborts the turn.
623
+ outcomes: dict[int, dict[str, Any]] = {
624
+ index: {"status": "error", "message": "tool result was not recorded"}
625
+ for index in range(len(calls))
626
+ }
627
+ runnable: list[tuple[int, str, dict[str, Any]]] = []
628
+ for index, call in enumerate(calls):
629
+ try:
630
+ arguments = self._parse_tool_arguments(call.arguments)
631
+ except ToolError as exc:
632
+ # A malformed call is answered at once so it cannot block the
633
+ # valid calls requested in the same turn.
634
+ outcomes[index] = {"status": "error", "message": str(exc)}
635
+ continue
636
+ self._activity(self.tools.activity(call.name, arguments))
637
+ runnable.append((index, call.name, arguments))
638
+ if runnable:
639
+ # The semaphore, not the thread count, is the concurrency limit:
640
+ # one thread per call keeps Ctrl-C free to abandon the join below.
641
+ slots = threading.BoundedSemaphore(max(1, limit))
642
+ lock = threading.Lock()
643
+
644
+ def run(index: int, name: str, arguments: dict[str, Any]) -> None:
645
+ with slots:
646
+ result = self._run_tool(name, arguments)
647
+ with lock:
648
+ outcomes[index] = result
649
+
650
+ threads = [
651
+ threading.Thread(
652
+ target=run,
653
+ args=(index, name, arguments),
654
+ name=f"{name}-{index}",
655
+ daemon=True,
656
+ )
657
+ for index, name, arguments in runnable
658
+ ]
659
+ started: list[threading.Thread] = []
660
+ for thread, (index, _name, _arguments) in zip(threads, runnable):
661
+ try:
662
+ thread.start()
663
+ except RuntimeError as exc:
664
+ # Thread exhaustion must not abort the turn or leave a slot
665
+ # without an answer; report it as that call's tool failure.
666
+ outcomes[index] = {
667
+ "status": "error",
668
+ "message": f"cannot start tool worker: {exc}",
669
+ }
670
+ continue
671
+ started.append(thread)
672
+ # Daemon threads keep Ctrl-C from waiting on every child: the
673
+ # interrupt leaves this join and the process can exit.
674
+ for thread in started:
675
+ thread.join()
676
+ return [outcomes[index] for index in range(len(calls))]
677
+
678
+ def run(self, user_text: str) -> AgentResult:
679
+ if not user_text.strip():
680
+ raise ValueError("user message is empty")
681
+ # Read AGENTS.md / CLAUDE.md again for this run: instructions may have
682
+ # changed on disk since the previous turn (including via /summary).
683
+ self.refresh_instructions()
684
+ self._repair_history()
685
+ usage_before = dict(self.total_usage)
686
+ estimated_before = self.estimated_turns
687
+ self.session.append_message({"role": "user", "content": user_text})
688
+ final_text = ""
689
+ final_completed = False
690
+ final_finish_reason: str | None = None
691
+ turn = 0
692
+
693
+ for turn in range(1, self.max_turns + 1):
694
+ if self.should_compact():
695
+ self._compact(force=False)
696
+ messages = self._messages()
697
+ self._prompt_session_count = len(self.session.messages)
698
+ try:
699
+ reply = self.provider.stream_chat(
700
+ messages,
701
+ tools=self.tool_schemas,
702
+ on_text=self._write_delta,
703
+ on_thinking=self._write_thinking,
704
+ )
705
+ finally:
706
+ self._finish_delta()
707
+ self._accumulate_usage(reply.usage, messages, reply.content)
708
+ if reply.content or reply.tool_calls:
709
+ self.session.append_message(reply.as_message())
710
+ final_text = reply.content
711
+ final_finish_reason = reply.finish_reason
712
+
713
+ if not reply.tool_calls:
714
+ final_completed = reply.finish_reason in {None, "stop", "completed"}
715
+ if final_completed and not reply.content.strip():
716
+ # A finished turn with neither text nor a tool call is a
717
+ # provider-side failure; nothing was delivered to the user.
718
+ final_completed = False
719
+ break
720
+
721
+ if reply.finish_reason in _INCOMPLETE_FINISH_REASONS:
722
+ for call in reply.tool_calls:
723
+ result = {
724
+ "status": "error",
725
+ "message": (
726
+ "tool call was not executed because the provider "
727
+ f"stopped with finish reason {reply.finish_reason!r}"
728
+ ),
729
+ }
730
+ self.session.append_message(
731
+ {
732
+ "role": "tool",
733
+ "tool_call_id": call.id,
734
+ "name": call.name,
735
+ "content": tool_result_text(result),
736
+ }
737
+ )
738
+ self._activity(f" {_tool_output_preview(result)}")
739
+ break
740
+
741
+ if len(reply.tool_calls) > _MAX_TOOL_CALLS_PER_TURN:
742
+ message = (
743
+ f"provider requested {len(reply.tool_calls)} tool calls in one "
744
+ f"turn; the limit is {_MAX_TOOL_CALLS_PER_TURN}"
745
+ )
746
+ for call in reply.tool_calls:
747
+ result = {
748
+ "status": "error",
749
+ "message": message,
750
+ }
751
+ self.session.append_message(
752
+ {
753
+ "role": "tool",
754
+ "tool_call_id": call.id,
755
+ "name": call.name,
756
+ "content": tool_result_text(result),
757
+ }
758
+ )
759
+ self._activity(f" {message}")
760
+ break
761
+
762
+ results = self._execute_tool_calls(reply.tool_calls)
763
+ for call, result in zip(reply.tool_calls, results):
764
+ tool_message = {
765
+ "role": "tool",
766
+ "tool_call_id": call.id,
767
+ "name": call.name,
768
+ "content": tool_result_text(result),
769
+ }
770
+ self.session.append_message(tool_message)
771
+ if call.name in {"shell", "subagent"}:
772
+ self._activity(_shell_output_activity(result))
773
+ else:
774
+ self._activity(f" {_tool_output_preview(result)}")
775
+
776
+ if not final_completed:
777
+ self._report_incomplete(
778
+ turn=turn,
779
+ final_text=final_text,
780
+ finish_reason=final_finish_reason,
781
+ )
782
+ return AgentResult(
783
+ text=final_text,
784
+ completed=final_completed,
785
+ usage=dict(self.total_usage),
786
+ run_usage={
787
+ key: value - usage_before.get(key, 0)
788
+ for key, value in self.total_usage.items()
789
+ },
790
+ turns=turn,
791
+ usage_estimated=self.estimated_turns > estimated_before,
792
+ )
793
+
794
+ def _report_incomplete(
795
+ self,
796
+ *,
797
+ turn: int,
798
+ final_text: str,
799
+ finish_reason: str | None,
800
+ ) -> None:
801
+ if self.quiet:
802
+ return
803
+ if finish_reason in _INCOMPLETE_FINISH_REASONS:
804
+ # A provider-side stop is the concrete cause; max_turns only says
805
+ # how many rounds were allowed. Report the cause first even when the
806
+ # stop happened on the final allowed round.
807
+ preview = " ".join(final_text.split()) if final_text.strip() else ""
808
+ if len(preview) > 200:
809
+ preview = preview[:197] + "..."
810
+ if preview:
811
+ message = f"Provider stopped early: {preview}"
812
+ else:
813
+ message = (
814
+ "The provider returned an empty response; the task is unfinished."
815
+ )
816
+ elif turn >= self.max_turns:
817
+ message = f"Reached max turns ({self.max_turns}); task may be incomplete."
818
+ elif not final_text.strip():
819
+ message = "The provider returned an empty response; the task is unfinished."
820
+ else:
821
+ # Never echo a whole answer to stderr while reporting the failure.
822
+ preview = " ".join(final_text.split())
823
+ if len(preview) > 200:
824
+ preview = preview[:197] + "..."
825
+ message = f"Provider stopped early: {preview}"
826
+ if finish_reason and finish_reason not in {"stop", "completed", "tool_calls"}:
827
+ message = f"{message} (finish reason: {finish_reason})"
828
+ message = sanitize_terminal_text(message)
829
+ self.error_output.write(message + "\n")
830
+ self.error_output.flush()
831
+
832
+ def should_compact(self) -> bool:
833
+ if self._compact_blocked_at == len(self.session.messages):
834
+ return False
835
+ threshold = float(self.settings.get("context.compact_at", 0.8))
836
+ window = max(1, self.provider.context_window)
837
+ # Re-serializing the whole history on every turn is O(n^2) over a long
838
+ # session; the provider-reported prompt size is the better signal and is
839
+ # only missing before the first reply or right after a compaction.
840
+ current = self.last_prompt_tokens
841
+ if current <= 0:
842
+ current = estimate_tokens(self._messages())
843
+ else:
844
+ # The provider-reported size describes the request that was sent,
845
+ # not the user message and tool results appended since then.
846
+ appended = self.session.messages[self._prompt_session_count :]
847
+ if appended:
848
+ current += estimate_tokens(appended)
849
+ return current >= int(window * threshold)
850
+
851
+ def _compact(self, *, force: bool) -> bool:
852
+ keep = max(4, int(self.settings.get("context.keep_messages", 12)))
853
+ messages = self.session.messages
854
+ if len(messages) <= keep + 2:
855
+ return False
856
+
857
+ target = len(messages) - keep
858
+ # Compaction replaces everything before the cut with a summary pair.
859
+ # Rewriting the pair a previous compaction wrote would summarize the
860
+ # summary and leave the history the same length, so that cut only counts
861
+ # when real messages go with it; every other boundary is accepted as
862
+ # soon as it drops something, because even two large early messages are
863
+ # worth replacing with a summary.
864
+ head_is_summary = _is_compacted_head(messages)
865
+ cut = next(
866
+ (
867
+ index
868
+ for index in _compaction_boundaries(messages, target)
869
+ if index > _COMPACT_HEAD_MESSAGES or not head_is_summary
870
+ ),
871
+ None,
872
+ )
873
+ if cut is None:
874
+ if not force:
875
+ return False
876
+ raise RuntimeError(
877
+ "cannot compact: no message boundary would shrink the history"
878
+ )
879
+
880
+ old_messages = messages[:cut]
881
+ transcript = self._transcript(old_messages)
882
+ prompt = (
883
+ "Summarize this coding-agent conversation for continuation. Preserve modified "
884
+ "files, decisions, exact commands or tests already run, errors, and remaining "
885
+ "work. Omit greetings and redundant reasoning. Use compact plain text."
886
+ )
887
+ try:
888
+ summary = self._complete_text(
889
+ [
890
+ {"role": "system", "content": prompt},
891
+ {
892
+ "role": "user",
893
+ "content": transcript,
894
+ },
895
+ ]
896
+ ).strip()
897
+ except ProviderError:
898
+ if force:
899
+ raise
900
+ self._compact_blocked_at = len(self.session.messages)
901
+ self._activity("[compact] skipped: provider request failed")
902
+ return False
903
+ if not summary:
904
+ self._compact_blocked_at = len(self.session.messages)
905
+ return False
906
+
907
+ replacement = [
908
+ {
909
+ "role": "user",
910
+ "content": f"{_COMPACTED_SUMMARY_PREFIX}\n\n{summary}",
911
+ },
912
+ {
913
+ "role": "assistant",
914
+ "content": _COMPACTED_SUMMARY_ACK,
915
+ },
916
+ *messages[cut:],
917
+ ]
918
+ self.session.replace_messages(
919
+ replacement,
920
+ summary=summary,
921
+ dropped_messages=len(old_messages),
922
+ )
923
+ self._compact_blocked_at = None
924
+ self.last_prompt_tokens = 0
925
+ self._prompt_session_count = 0
926
+ self._activity(f"[compact] {len(old_messages)} messages -> summary")
927
+ return True
928
+
929
+ def compact(self) -> bool:
930
+ return self._compact(force=True)
931
+
932
+ def summarize(self) -> str:
933
+ """Ask the provider to condense the whole session into durable notes."""
934
+ summary = self._complete_text(
935
+ [
936
+ {"role": "system", "content": SUMMARY_PROMPT},
937
+ {
938
+ "role": "user",
939
+ # Summarize the conversation, not the system prompt: the
940
+ # prompt already includes AGENTS.md, and feeding it back
941
+ # would copy old summaries into the new one.
942
+ "content": self._transcript(self.session.messages),
943
+ },
944
+ ]
945
+ ).strip()
946
+ if not summary:
947
+ raise ProviderError("the provider returned an empty summary")
948
+ return summary
949
+
950
+ def write_summary(self) -> Path:
951
+ """Write the session summary into the workspace ``AGENTS.md``.
952
+
953
+ The path goes through the sandbox resolver like any tool path, so a
954
+ symlinked or sensitive ``AGENTS.md`` is refused instead of overwritten.
955
+ """
956
+ target = self.tools.sandbox.resolve_path(
957
+ "AGENTS.md",
958
+ cwd=self.session.cwd,
959
+ operation="write",
960
+ allow_outside=self.tools.allow_outside,
961
+ )
962
+ existing: str | None = None
963
+ if target.is_file():
964
+ try:
965
+ existing = target.read_text(encoding="utf-8-sig")
966
+ except UnicodeDecodeError as exc:
967
+ raise ToolError(f"{target} is not valid UTF-8 text") from exc
968
+ except OSError as exc:
969
+ raise ToolError(f"cannot read {target}: {exc}") from exc
970
+ merged = merge_agents_summary(existing, self.summarize())
971
+ # Dispatch instead of calling `write` directly: `--read-only` and
972
+ # `tools.allow` are enforced there, and `/summary` must not be a way
973
+ # around them.
974
+ self.tools.dispatch("write", {"path": str(target), "content": merged})
975
+ return target
976
+
977
+ def _transcript(self, messages: list[dict[str, Any]]) -> str:
978
+ lines: list[str] = []
979
+ for message in messages:
980
+ role = str(message.get("role", "unknown"))
981
+ content = message.get("content")
982
+ if isinstance(content, list):
983
+ content = json.dumps(content, ensure_ascii=False, separators=(",", ":"))
984
+ lines.append(f"{role.upper()}: {content or ''}")
985
+ calls = message.get("tool_calls")
986
+ if isinstance(calls, list):
987
+ for call in calls:
988
+ if not isinstance(call, dict):
989
+ continue
990
+ function = call.get("function")
991
+ if not isinstance(function, dict):
992
+ continue
993
+ lines.append(
994
+ f"TOOL_CALL {function.get('name', '')}: "
995
+ f"{function.get('arguments', '')}"
996
+ )
997
+ text = "\n".join(lines)
998
+ # Fit the transcript comfortably in the model window: roughly half the
999
+ # token budget converted to characters, bounded so a 1M-token model
1000
+ # does not produce a multi-megabyte summarization request.
1001
+ max_chars = min(200_000, max(4_000, self.provider.context_window // 2))
1002
+ if len(text) > max_chars:
1003
+ head = int(max_chars * 0.25)
1004
+ tail = max_chars - head
1005
+ text = text[:head] + "\n...<middle omitted>...\n" + text[-tail:]
1006
+ return text
1007
+
1008
+ def new_session(self, session: Session, tool_runtime: ToolRuntime) -> None:
1009
+ """Start a fresh conversation while keeping the active provider."""
1010
+ schemas = available_tool_schemas(
1011
+ tool_runtime.web_client,
1012
+ tool_runtime.vision_client,
1013
+ tool_runtime.subagent_enabled,
1014
+ allowed=self._allowed_tools(tool_runtime),
1015
+ )
1016
+ self.session = session
1017
+ self.tools = tool_runtime
1018
+ self.tool_schemas = schemas
1019
+ self.last_prompt_tokens = 0
1020
+ self._prompt_session_count = 0
1021
+ self._compact_blocked_at = None
1022
+ self.estimated_turns = 0
1023
+ self._system_prompt = None
1024
+ self.total_usage = dict.fromkeys(self.total_usage, 0)
1025
+
1026
+ def switch_runtime(
1027
+ self,
1028
+ provider: OpenAICompatibleProvider,
1029
+ tool_runtime: ToolRuntime,
1030
+ ) -> None:
1031
+ """Adopt a new provider/model without discarding the session history.
1032
+
1033
+ The caller owns the old provider and its optional web/vision clients and
1034
+ closes them after this returns. Tool declarations are rebuilt because a
1035
+ provider without ``web_tools`` or ``vision`` must stop advertising those
1036
+ tools, and the previous prompt size is dropped because it describes the
1037
+ old model's context window.
1038
+ """
1039
+ schemas = available_tool_schemas(
1040
+ tool_runtime.web_client,
1041
+ tool_runtime.vision_client,
1042
+ tool_runtime.subagent_enabled,
1043
+ allowed=self._allowed_tools(tool_runtime),
1044
+ )
1045
+ if hasattr(provider, "notify"):
1046
+ provider.notify = self._activity
1047
+ self.provider = provider
1048
+ self.tools = tool_runtime
1049
+ self.tool_schemas = schemas
1050
+ self.last_prompt_tokens = 0
1051
+ self._prompt_session_count = 0
1052
+ self._compact_blocked_at = None
1053
+ # A resumed session must continue with the provider that was active
1054
+ # when the switch happened, not the one recorded at creation.
1055
+ self.session.provider = provider.name
1056
+ self.session.model = provider.model
1057
+ try:
1058
+ self.session.append_event(
1059
+ {
1060
+ "type": "meta",
1061
+ "provider": provider.name,
1062
+ "model": provider.model,
1063
+ }
1064
+ )
1065
+ except (OSError, TypeError, ValueError):
1066
+ pass
1067
+
1068
+ def status(self) -> dict[str, Any]:
1069
+ summary = getattr(self.tools, "sandbox_summary", None)
1070
+ return {
1071
+ "provider": self.provider.name,
1072
+ "model": self.provider.model,
1073
+ "effort": self.provider.thinking,
1074
+ "shell": self.shell.label,
1075
+ "session": str(self.session.path) if self.session.path else "<memory>",
1076
+ "messages": len(self.session.messages),
1077
+ "last_prompt_tokens": self.last_prompt_tokens,
1078
+ "context_window": self.provider.context_window,
1079
+ "usage": dict(self.total_usage),
1080
+ "usage_estimated": self.estimated_turns > 0,
1081
+ "read_only": getattr(self.tools, "read_only", False),
1082
+ "tools": sorted(self._allowed_tools() or ()),
1083
+ "sandbox": summary() if callable(summary) else {"enabled": False},
1084
+ }