cli-agent-yzf 0.6.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cli_agent/__init__.py +3 -0
- cli_agent/__main__.py +8 -0
- cli_agent/agent.py +388 -0
- cli_agent/checkpoint.py +345 -0
- cli_agent/cli.py +755 -0
- cli_agent/config.py +173 -0
- cli_agent/log.py +39 -0
- cli_agent/mcp.py +306 -0
- cli_agent/messages.py +127 -0
- cli_agent/permissions.py +294 -0
- cli_agent/prompt.py +79 -0
- cli_agent/providers/__init__.py +0 -0
- cli_agent/providers/anthropic.py +283 -0
- cli_agent/providers/base.py +116 -0
- cli_agent/providers/fake.py +81 -0
- cli_agent/providers/openai.py +301 -0
- cli_agent/py.typed +0 -0
- cli_agent/sandbox.py +117 -0
- cli_agent/session.py +274 -0
- cli_agent/tools/__init__.py +13 -0
- cli_agent/tools/base.py +118 -0
- cli_agent/tools/files.py +522 -0
- cli_agent/tools/shell.py +224 -0
- cli_agent/ui.py +283 -0
- cli_agent/workspace.py +59 -0
- cli_agent_yzf-0.6.0.dist-info/METADATA +161 -0
- cli_agent_yzf-0.6.0.dist-info/RECORD +29 -0
- cli_agent_yzf-0.6.0.dist-info/WHEEL +4 -0
- cli_agent_yzf-0.6.0.dist-info/entry_points.txt +3 -0
cli_agent/__init__.py
ADDED
cli_agent/__main__.py
ADDED
cli_agent/agent.py
ADDED
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
"""The agent loop: model call -> permission check -> tool execution -> repeat."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import time
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from typing import Any, Literal, Protocol
|
|
10
|
+
|
|
11
|
+
from cli_agent import log
|
|
12
|
+
from cli_agent.messages import Message, TextBlock, ToolResultBlock, ToolUseBlock
|
|
13
|
+
from cli_agent.permissions import PermissionPolicy
|
|
14
|
+
from cli_agent.providers.base import Provider, ProviderError, RetryPolicy, with_retries
|
|
15
|
+
from cli_agent.session import Session
|
|
16
|
+
from cli_agent.tools import Tool, ToolContext, ToolError, ToolResult, spec_of
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Terminated(KeyboardInterrupt):
|
|
20
|
+
"""SIGTERM or SIGHUP. Cleans up like Ctrl-C, but ends the process, also in the REPL."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
Approval = Literal["yes", "no", "always", "unattended"]
|
|
24
|
+
"""`unattended`: nobody can answer (no terminal), so the call is not run."""
|
|
25
|
+
|
|
26
|
+
# Rough context budget in characters (~4 chars/token). When the history grows
|
|
27
|
+
# past it, the oldest tool results are elided; the newest turns are kept intact.
|
|
28
|
+
DEFAULT_CONTEXT_CHARS = 400_000
|
|
29
|
+
ELIDED = "[older tool output elided to save context]"
|
|
30
|
+
_ELIDED_PREFIX = "[elided: "
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def elided_marker(call: ToolUseBlock | None, size: int, note: str = "") -> str:
|
|
34
|
+
"""Name the call whose output was removed, so the model knows what it already
|
|
35
|
+
did and does not loop re-running the same reads.
|
|
36
|
+
|
|
37
|
+
`note` is the text the model wrote right after seeing the output. It is
|
|
38
|
+
repeated inside the marker because some Anthropic-compatible backends drop
|
|
39
|
+
assistant text that shares a message with tool_use blocks (observed with the
|
|
40
|
+
development proxy; see docs/progress.md), while tool results always survive.
|
|
41
|
+
"""
|
|
42
|
+
if call is None:
|
|
43
|
+
return ELIDED
|
|
44
|
+
args = json.dumps(call.input, ensure_ascii=False)
|
|
45
|
+
if len(args) > 200:
|
|
46
|
+
args = args[:200] + "…"
|
|
47
|
+
note = " ".join(note.split())
|
|
48
|
+
if len(note) > 500:
|
|
49
|
+
note = note[:500] + "…"
|
|
50
|
+
recorded = f" Your note after reading it: {note}" if note else ""
|
|
51
|
+
return (
|
|
52
|
+
f"{_ELIDED_PREFIX}output of {call.name} {args} ({size} chars) was removed to save "
|
|
53
|
+
f"context.{recorded} Re-run it only if you need details you did not note.]"
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _is_elided(text: str) -> bool:
|
|
58
|
+
return text == ELIDED or text.startswith(_ELIDED_PREFIX)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class UI(Protocol):
|
|
62
|
+
streams: bool
|
|
63
|
+
"""True if the UI wants text deltas via on_text_delta while the model writes."""
|
|
64
|
+
|
|
65
|
+
def on_text_delta(self, text: str) -> None: ...
|
|
66
|
+
def on_text(self, text: str) -> None:
|
|
67
|
+
"""Called once per assistant message with its full text."""
|
|
68
|
+
...
|
|
69
|
+
|
|
70
|
+
def on_tool_start(self, call: ToolUseBlock, summary: str) -> None: ...
|
|
71
|
+
def on_tool_end(self, call: ToolUseBlock, result: ToolResult) -> None: ...
|
|
72
|
+
def on_notice(self, message: str) -> None: ...
|
|
73
|
+
def approve(self, call: ToolUseBlock, summary: str) -> Approval: ...
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass
|
|
77
|
+
class RunResult:
|
|
78
|
+
status: Literal["done", "max_turns", "max_tokens", "budget", "refusal", "error", "interrupted"]
|
|
79
|
+
final_text: str = ""
|
|
80
|
+
turns: int = 0
|
|
81
|
+
error: str = ""
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass
|
|
85
|
+
class Agent:
|
|
86
|
+
provider: Provider
|
|
87
|
+
tools: list[Tool]
|
|
88
|
+
policy: PermissionPolicy
|
|
89
|
+
ctx: ToolContext
|
|
90
|
+
ui: UI
|
|
91
|
+
system: str
|
|
92
|
+
max_turns: int = 50
|
|
93
|
+
max_session_tokens: int | None = None
|
|
94
|
+
"""Stop once the session's input+output tokens reach this (a cost budget)."""
|
|
95
|
+
max_tokens: int = 8192
|
|
96
|
+
retry: RetryPolicy = field(default_factory=RetryPolicy)
|
|
97
|
+
context_chars: int = DEFAULT_CONTEXT_CHARS
|
|
98
|
+
save: Callable[[Session], None] = lambda s: None
|
|
99
|
+
sleep: Callable[[float], None] | None = None
|
|
100
|
+
|
|
101
|
+
def __post_init__(self) -> None:
|
|
102
|
+
self._by_name = {t.name: t for t in self.tools}
|
|
103
|
+
|
|
104
|
+
def run(self, session: Session, prompt: str) -> RunResult:
|
|
105
|
+
"""Process one user prompt until the model stops calling tools."""
|
|
106
|
+
if session.repair():
|
|
107
|
+
self.ui.on_notice("previous run was interrupted; recorded unfinished tool calls")
|
|
108
|
+
_drop_pending_continue(session)
|
|
109
|
+
session.add_user_text(prompt)
|
|
110
|
+
self.save(session)
|
|
111
|
+
if self.ctx.checkpoints is not None:
|
|
112
|
+
try:
|
|
113
|
+
self.ctx.checkpoints.begin(prompt)
|
|
114
|
+
except OSError as err:
|
|
115
|
+
self.ui.on_notice(f"undo is unavailable for this prompt: {err}")
|
|
116
|
+
turns = 0
|
|
117
|
+
shrinks = 0
|
|
118
|
+
continuations = 0
|
|
119
|
+
continued = "" # text of earlier replies in a run of max_tokens continuations
|
|
120
|
+
last_text = ""
|
|
121
|
+
try:
|
|
122
|
+
while True:
|
|
123
|
+
if turns >= self.max_turns:
|
|
124
|
+
return RunResult("max_turns", last_text, turns, f"stopped after {turns} turns")
|
|
125
|
+
spent = session.usage.input_tokens + session.usage.output_tokens
|
|
126
|
+
if self.max_session_tokens is not None and spent >= self.max_session_tokens:
|
|
127
|
+
return RunResult(
|
|
128
|
+
"budget",
|
|
129
|
+
last_text,
|
|
130
|
+
turns,
|
|
131
|
+
f"token budget reached ({spent} of {self.max_session_tokens})",
|
|
132
|
+
)
|
|
133
|
+
turns += 1
|
|
134
|
+
elided = compact_history(session.messages, self.context_chars)
|
|
135
|
+
if elided:
|
|
136
|
+
log.event(
|
|
137
|
+
"compact", turn=turns, elided=elided, chars=history_chars(session.messages)
|
|
138
|
+
)
|
|
139
|
+
started = time.monotonic()
|
|
140
|
+
try:
|
|
141
|
+
completion = self._complete(session)
|
|
142
|
+
except ProviderError as err:
|
|
143
|
+
if is_context_overflow(err) and shrinks < MAX_SHRINKS and self._shrink(session):
|
|
144
|
+
shrinks += 1
|
|
145
|
+
turns -= 1
|
|
146
|
+
continue
|
|
147
|
+
log.event(
|
|
148
|
+
"model_error", turn=turns, retryable=err.retryable, error=str(err)[:300]
|
|
149
|
+
)
|
|
150
|
+
return RunResult("error", last_text, turns, str(err))
|
|
151
|
+
u = completion.usage
|
|
152
|
+
log.event(
|
|
153
|
+
"model_call",
|
|
154
|
+
turn=turns,
|
|
155
|
+
secs=round(time.monotonic() - started, 3),
|
|
156
|
+
model=completion.model,
|
|
157
|
+
stop=completion.stop_reason,
|
|
158
|
+
input_tokens=u.input_tokens,
|
|
159
|
+
output_tokens=u.output_tokens,
|
|
160
|
+
cache_read=u.cache_read_tokens,
|
|
161
|
+
cache_write=u.cache_write_tokens,
|
|
162
|
+
tool_calls=len(completion.message.tool_uses()),
|
|
163
|
+
)
|
|
164
|
+
session.usage.add(completion.usage)
|
|
165
|
+
msg = completion.message
|
|
166
|
+
if not msg.content:
|
|
167
|
+
# An empty assistant message is invalid in history; record a placeholder.
|
|
168
|
+
msg.content.append(TextBlock(text="(no response)"))
|
|
169
|
+
session.messages.append(msg)
|
|
170
|
+
self.save(session)
|
|
171
|
+
text = msg.text()
|
|
172
|
+
if text:
|
|
173
|
+
last_text = continued + text
|
|
174
|
+
self.ui.on_text(text)
|
|
175
|
+
calls = msg.tool_uses()
|
|
176
|
+
if not calls:
|
|
177
|
+
if completion.stop_reason == "max_tokens":
|
|
178
|
+
# A reply cut off mid-thought is usually not the end of the
|
|
179
|
+
# task; ask the model to carry on, a bounded number of times.
|
|
180
|
+
if continuations < MAX_CONTINUATIONS:
|
|
181
|
+
continuations += 1
|
|
182
|
+
continued = last_text
|
|
183
|
+
log.event("continue_after_max_tokens", turn=turns)
|
|
184
|
+
session.add_user_text(CONTINUE_PROMPT)
|
|
185
|
+
self.save(session)
|
|
186
|
+
continue
|
|
187
|
+
return RunResult("max_tokens", last_text, turns, "response hit max_tokens")
|
|
188
|
+
if completion.stop_reason == "refusal":
|
|
189
|
+
return RunResult("refusal", last_text, turns, "model refused")
|
|
190
|
+
return RunResult("done", last_text, turns)
|
|
191
|
+
continuations = 0
|
|
192
|
+
continued = ""
|
|
193
|
+
results = [self._execute(call) for call in calls]
|
|
194
|
+
session.messages.append(Message(role="user", content=list(results)))
|
|
195
|
+
self.save(session)
|
|
196
|
+
except KeyboardInterrupt as stop:
|
|
197
|
+
session.repair()
|
|
198
|
+
self.save(session)
|
|
199
|
+
if isinstance(stop, Terminated):
|
|
200
|
+
raise
|
|
201
|
+
return RunResult("interrupted", last_text, turns, "interrupted by user")
|
|
202
|
+
finally:
|
|
203
|
+
if self.ctx.checkpoints is not None:
|
|
204
|
+
try:
|
|
205
|
+
self.ctx.checkpoints.finish()
|
|
206
|
+
except OSError as err:
|
|
207
|
+
log.event("checkpoint_error", error=str(err)[:300])
|
|
208
|
+
|
|
209
|
+
def _shrink(self, session: Session) -> bool:
|
|
210
|
+
"""After the API rejected the prompt as too long, compact to half the
|
|
211
|
+
current size. Returns False if nothing more can be elided."""
|
|
212
|
+
size = history_chars(session.messages)
|
|
213
|
+
elided = compact_history(session.messages, size // 2, keep_recent=2)
|
|
214
|
+
log.event("compact_overflow", elided=elided, chars=history_chars(session.messages))
|
|
215
|
+
if elided:
|
|
216
|
+
self.ui.on_notice(f"prompt too long for the model; elided {elided} old tool output(s)")
|
|
217
|
+
return elided > 0
|
|
218
|
+
|
|
219
|
+
def _complete(self, session: Session): # type: ignore[no-untyped-def]
|
|
220
|
+
specs = [spec_of(t) for t in self.tools]
|
|
221
|
+
|
|
222
|
+
on_text = self.ui.on_text_delta if getattr(self.ui, "streams", False) else None
|
|
223
|
+
|
|
224
|
+
def call(): # type: ignore[no-untyped-def]
|
|
225
|
+
return self.provider.complete(
|
|
226
|
+
system=self.system,
|
|
227
|
+
messages=session.messages,
|
|
228
|
+
tools=specs,
|
|
229
|
+
max_tokens=self.max_tokens,
|
|
230
|
+
on_text=on_text,
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
def on_retry(attempt: int, err: ProviderError, wait: float) -> None:
|
|
234
|
+
log.event("model_retry", attempt=attempt, wait=round(wait, 2), error=str(err)[:300])
|
|
235
|
+
self.ui.on_notice(f"model call failed ({err}); retry {attempt} in {wait:.1f}s")
|
|
236
|
+
|
|
237
|
+
kwargs: dict[str, Any] = {"on_retry": on_retry}
|
|
238
|
+
if self.sleep is not None:
|
|
239
|
+
kwargs["sleep"] = self.sleep
|
|
240
|
+
return with_retries(call, self.retry, **kwargs)
|
|
241
|
+
|
|
242
|
+
def _execute(self, call: ToolUseBlock) -> ToolResultBlock:
|
|
243
|
+
tool = self._by_name.get(call.name)
|
|
244
|
+
if tool is None:
|
|
245
|
+
known = ", ".join(sorted(self._by_name))
|
|
246
|
+
return ToolResultBlock(call.id, f"unknown tool {call.name!r}; available: {known}", True)
|
|
247
|
+
if not call.complete:
|
|
248
|
+
return ToolResultBlock(
|
|
249
|
+
call.id,
|
|
250
|
+
"the tool arguments were cut off (the response hit the output token limit) and "
|
|
251
|
+
"could not be parsed; nothing was run. Retry with smaller input, e.g. write a "
|
|
252
|
+
"large file in several edits.",
|
|
253
|
+
True,
|
|
254
|
+
)
|
|
255
|
+
try:
|
|
256
|
+
summary = tool.summarize(call.input)
|
|
257
|
+
except Exception: # noqa: BLE001 - summaries must never break the loop
|
|
258
|
+
summary = f"{call.name} {json.dumps(call.input)[:200]}"
|
|
259
|
+
verdict = self.policy.check(call.name, tool.effect, call.input)
|
|
260
|
+
log.event("permission", tool=call.name, decision=verdict.decision, reason=verdict.reason)
|
|
261
|
+
if verdict.decision == "deny":
|
|
262
|
+
self.ui.on_notice(f"denied: {summary} ({verdict.reason})")
|
|
263
|
+
return ToolResultBlock(call.id, f"permission denied: {verdict.reason}", True)
|
|
264
|
+
if verdict.decision == "ask":
|
|
265
|
+
answer = self.ui.approve(call, summary)
|
|
266
|
+
hint = self.policy.allow_hint(call.name, call.input)
|
|
267
|
+
if hint:
|
|
268
|
+
self.ui.on_notice(hint)
|
|
269
|
+
if answer == "no":
|
|
270
|
+
return ToolResultBlock(
|
|
271
|
+
call.id,
|
|
272
|
+
"the user declined this tool call; ask them how to proceed or try another way"
|
|
273
|
+
+ (f". {hint}" if hint else ""),
|
|
274
|
+
True,
|
|
275
|
+
)
|
|
276
|
+
if answer == "unattended":
|
|
277
|
+
return ToolResultBlock(
|
|
278
|
+
call.id,
|
|
279
|
+
"not run: this call needs the user's approval, and this run is "
|
|
280
|
+
"non-interactive, so nobody can give it. Only calls the user's allow rules "
|
|
281
|
+
"or permission mode permit can run" + (f". {hint}" if hint else "."),
|
|
282
|
+
True,
|
|
283
|
+
)
|
|
284
|
+
if answer == "always":
|
|
285
|
+
rule = self.policy.remember(call.name, call.input)
|
|
286
|
+
self.ui.on_notice(f"added allow rule {rule!r} for this session")
|
|
287
|
+
self.ui.on_tool_start(call, summary)
|
|
288
|
+
started = time.monotonic()
|
|
289
|
+
try:
|
|
290
|
+
result = tool.run(call.input, self.ctx)
|
|
291
|
+
except ToolError as err:
|
|
292
|
+
result = ToolResult(str(err), is_error=True)
|
|
293
|
+
except KeyboardInterrupt:
|
|
294
|
+
raise
|
|
295
|
+
except Exception as err: # noqa: BLE001 - a tool bug must not kill the session
|
|
296
|
+
result = ToolResult(f"internal tool error: {type(err).__name__}: {err}", is_error=True)
|
|
297
|
+
log.event(
|
|
298
|
+
"tool",
|
|
299
|
+
tool=call.name,
|
|
300
|
+
secs=round(time.monotonic() - started, 3),
|
|
301
|
+
is_error=result.is_error,
|
|
302
|
+
output_chars=len(result.content),
|
|
303
|
+
)
|
|
304
|
+
self.ui.on_tool_end(call, result)
|
|
305
|
+
return ToolResultBlock(call.id, result.content or "(no output)", result.is_error)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
_OVERFLOW_HINTS = (
|
|
309
|
+
"prompt is too long",
|
|
310
|
+
"context length",
|
|
311
|
+
"context window",
|
|
312
|
+
"maximum context",
|
|
313
|
+
"too many tokens",
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
MAX_SHRINKS = 4
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _drop_pending_continue(session: Session) -> None:
|
|
321
|
+
"""A continue prompt left by an interrupted or failed run is stale: the user
|
|
322
|
+
has moved on, and their new prompt must not be merged into it."""
|
|
323
|
+
if not session.messages or session.messages[-1].role != "user":
|
|
324
|
+
return
|
|
325
|
+
last = session.messages[-1]
|
|
326
|
+
if (
|
|
327
|
+
last.content
|
|
328
|
+
and isinstance(last.content[-1], TextBlock)
|
|
329
|
+
and (last.content[-1].text == CONTINUE_PROMPT)
|
|
330
|
+
):
|
|
331
|
+
last.content.pop()
|
|
332
|
+
if not last.content:
|
|
333
|
+
session.messages.pop()
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
MAX_CONTINUATIONS = 3
|
|
337
|
+
CONTINUE_PROMPT = (
|
|
338
|
+
"[Your previous reply was cut off at the output token limit. Continue from where you "
|
|
339
|
+
"stopped; keep replies shorter, and make large file changes in several smaller edits.]"
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def is_context_overflow(err: ProviderError) -> bool:
|
|
344
|
+
"""True if the prompt itself is too long. An overflow caused by the requested
|
|
345
|
+
output size (max_tokens / "in the completion") is not fixed by eliding history."""
|
|
346
|
+
text = str(err).lower()
|
|
347
|
+
if "completion" in text or "max_tokens" in text:
|
|
348
|
+
return False
|
|
349
|
+
return not err.retryable and any(h in text for h in _OVERFLOW_HINTS)
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def history_chars(messages: list[Message]) -> int:
|
|
353
|
+
total = 0
|
|
354
|
+
for m in messages:
|
|
355
|
+
for b in m.content:
|
|
356
|
+
if isinstance(b, TextBlock):
|
|
357
|
+
total += len(b.text)
|
|
358
|
+
elif isinstance(b, ToolResultBlock):
|
|
359
|
+
total += len(b.content)
|
|
360
|
+
else:
|
|
361
|
+
total += len(json.dumps(b.input))
|
|
362
|
+
return total
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def compact_history(messages: list[Message], budget: int, keep_recent: int = 6) -> int:
|
|
366
|
+
"""Elide the oldest tool results until the history fits `budget`.
|
|
367
|
+
|
|
368
|
+
The last `keep_recent` messages are never touched so the model keeps the
|
|
369
|
+
details it is currently working with. Returns the number of blocks elided.
|
|
370
|
+
"""
|
|
371
|
+
elided = 0
|
|
372
|
+
if history_chars(messages) <= budget:
|
|
373
|
+
return 0
|
|
374
|
+
calls = {u.id: u for m in messages for u in m.tool_uses()}
|
|
375
|
+
for i, m in enumerate(messages[: max(len(messages) - keep_recent, 0)]):
|
|
376
|
+
following = messages[i + 1] if i + 1 < len(messages) else None
|
|
377
|
+
note = following.text() if following and following.role == "assistant" else ""
|
|
378
|
+
for b in m.content:
|
|
379
|
+
if (
|
|
380
|
+
isinstance(b, ToolResultBlock)
|
|
381
|
+
and not _is_elided(b.content)
|
|
382
|
+
and len(b.content) > 400
|
|
383
|
+
):
|
|
384
|
+
b.content = elided_marker(calls.get(b.tool_use_id), len(b.content), note)
|
|
385
|
+
elided += 1
|
|
386
|
+
if history_chars(messages) <= budget:
|
|
387
|
+
return elided
|
|
388
|
+
return elided
|