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.
- minimalcode/__init__.py +3 -0
- minimalcode/__main__.py +3 -0
- minimalcode/agent.py +1084 -0
- minimalcode/cli.py +1520 -0
- minimalcode/clipboard.py +212 -0
- minimalcode/config.py +1065 -0
- minimalcode/defaults.json +491 -0
- minimalcode/errors.py +31 -0
- minimalcode/highlight/__init__.py +47 -0
- minimalcode/highlight/code.py +261 -0
- minimalcode/highlight/color.py +58 -0
- minimalcode/highlight/markdown.py +710 -0
- minimalcode/highlight/palette.py +73 -0
- minimalcode/highlight/sanitize.py +175 -0
- minimalcode/highlight/thinking.py +71 -0
- minimalcode/httpclient.py +278 -0
- minimalcode/importer.py +253 -0
- minimalcode/prompt.py +195 -0
- minimalcode/provider/__init__.py +28 -0
- minimalcode/provider/anthropic.py +805 -0
- minimalcode/provider/anthropic_env.py +161 -0
- minimalcode/provider/chat.py +394 -0
- minimalcode/provider/client.py +243 -0
- minimalcode/provider/common.py +176 -0
- minimalcode/provider/deepseek_web.py +1158 -0
- minimalcode/provider/factory.py +63 -0
- minimalcode/provider/responses.py +692 -0
- minimalcode/provider/sha3_wasm_bg.wasm +0 -0
- minimalcode/provider/transport.py +241 -0
- minimalcode/provider/types.py +54 -0
- minimalcode/sandbox/__init__.py +64 -0
- minimalcode/sandbox/audit.py +120 -0
- minimalcode/sandbox/backends.py +343 -0
- minimalcode/sandbox/execution.py +435 -0
- minimalcode/sandbox/network.py +793 -0
- minimalcode/sandbox/policy.py +253 -0
- minimalcode/sandbox/runner.py +347 -0
- minimalcode/sandbox/sensitive.py +286 -0
- minimalcode/session.py +465 -0
- minimalcode/shell.py +333 -0
- minimalcode/subagent.py +120 -0
- minimalcode/tools/__init__.py +46 -0
- minimalcode/tools/files.py +1139 -0
- minimalcode/tools/process.py +164 -0
- minimalcode/tools/remote.py +74 -0
- minimalcode/tools/runtime.py +219 -0
- minimalcode/tools/schemas.py +418 -0
- minimalcode/tools/search.py +521 -0
- minimalcode/tools/search_tools.py +583 -0
- minimalcode/tools/validation.py +99 -0
- minimalcode/vision.py +231 -0
- minimalcode/web.py +238 -0
- minimalcode-1.5.2.dist-info/METADATA +646 -0
- minimalcode-1.5.2.dist-info/RECORD +58 -0
- minimalcode-1.5.2.dist-info/WHEEL +5 -0
- minimalcode-1.5.2.dist-info/entry_points.txt +2 -0
- minimalcode-1.5.2.dist-info/licenses/LICENSE +201 -0
- 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
|
+
}
|