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 ADDED
@@ -0,0 +1,3 @@
1
+ """cli-agent: an autonomous coding agent for the terminal."""
2
+
3
+ __version__ = "0.6.0"
cli_agent/__main__.py ADDED
@@ -0,0 +1,8 @@
1
+ import sys
2
+
3
+ from cli_agent.cli import main
4
+
5
+ # The guard matters: search_files spawns a child process, which re-imports the
6
+ # main module and must not run the CLI again.
7
+ if __name__ == "__main__":
8
+ sys.exit(main())
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