euron-code 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
euron_code/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Euron Code: an agentic coding assistant for the terminal."""
2
+
3
+ __version__ = "0.1.0"
euron_code/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from euron_code.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
euron_code/agent.py ADDED
@@ -0,0 +1,595 @@
1
+ """The agent loop: stream a model response, run its tool calls, repeat until it answers.
2
+
3
+ Rules this file enforces:
4
+ - One model request at a time (never parallel), including sub-agents and compaction.
5
+ - Reasoning (`delta.reasoning`) is displayed or hidden, never stored or resent.
6
+ - The system prompt and tool list are byte-stable and first in every request (prefix cache).
7
+ - Retry only before any output was shown (spec §4); a mid-stream failure stops the turn.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import re
14
+ import threading
15
+ import time
16
+ import uuid
17
+ from dataclasses import dataclass, field
18
+ from pathlib import Path
19
+ from typing import Any, Callable, Dict, List, Optional
20
+
21
+ from euron_code import api, context, memory, prompts
22
+ from euron_code.api import (BusyError, ContextTooLongError, EuronError, Interrupted, MidStreamError,
23
+ NetworkError, RateLimitedError)
24
+ from euron_code.keys import EscWatcher
25
+ from euron_code.permissions import Permissions, Request
26
+ from euron_code.sessions import SessionWriter
27
+ from euron_code.tools import ToolContext, ToolError, ToolResult, default_tools, read_only_tools
28
+ from euron_code.tools.base import ReadTracker
29
+ from euron_code.tools.shell import detect_shell
30
+ from euron_code.tools.todo import render_todos
31
+
32
+ BUSY_BACKOFF = [5, 10, 20, 40, 40] # spec §4: 5, 10, 20, 40 s, max 5 tries
33
+ RATE_LIMIT_FALLBACK = 20
34
+ MAX_RATE_LIMIT_RETRIES = 5
35
+ NETWORK_BACKOFF = [2, 4, 8]
36
+ DENIED = "denied"
37
+
38
+
39
+ @dataclass
40
+ class Response:
41
+ content: str = ""
42
+ tool_calls: List[Dict[str, Any]] = field(default_factory=list)
43
+ usage: Optional[Dict[str, Any]] = None
44
+ finish_reason: Optional[str] = None
45
+ reasoning_chars: int = 0
46
+
47
+
48
+ @dataclass
49
+ class TurnResult:
50
+ text: str
51
+ stop_reason: str # end_turn | max_turns | interrupted | denied | error
52
+ turns: int
53
+ error: Optional[EuronError] = None
54
+
55
+
56
+ TOOL_CALL_TAG = re.compile(r"<tool_call>\s*(\{.*?\})\s*</tool_call>", re.DOTALL)
57
+
58
+
59
+ def parse_inline_tool_calls(content: str):
60
+ """Fallback for servers without a tool parser: Qwen emits <tool_call>{json}</tool_call>."""
61
+ calls = []
62
+ for m in TOOL_CALL_TAG.finditer(content):
63
+ try:
64
+ obj = json.loads(m.group(1))
65
+ except ValueError:
66
+ continue
67
+ name = obj.get("name")
68
+ if not name:
69
+ continue
70
+ args = obj.get("arguments", {})
71
+ calls.append({"id": f"call_{uuid.uuid4().hex[:12]}", "type": "function",
72
+ "function": {"name": name, "arguments": args if isinstance(args, str) else json.dumps(args)}})
73
+ if calls:
74
+ content = TOOL_CALL_TAG.sub("", content).strip()
75
+ return content, calls
76
+
77
+
78
+ class Agent:
79
+ def __init__(self, client: api.Client, ui, root: Path, settings: Dict[str, Any],
80
+ permissions: Permissions, model: str, *, session: Optional[SessionWriter] = None,
81
+ is_subagent: bool = False, max_turns: Optional[int] = None):
82
+ self.client = client
83
+ self.ui = ui
84
+ self.root = root.resolve()
85
+ self.settings = settings
86
+ self.permissions = permissions
87
+ self.model = model
88
+ self.session = session
89
+ self.is_subagent = is_subagent
90
+ self.max_turns = max_turns
91
+ self.window = int(settings.get("contextWindow") or 32768)
92
+ self.max_output = int(settings.get("maxOutputTokens") or 8192)
93
+ self.tools = read_only_tools() if is_subagent else default_tools(settings)
94
+ self.tool_map = {t.name: t for t in self.tools}
95
+ self.tool_schemas = [t.schema() for t in self.tools]
96
+ self.shell_kind = detect_shell(settings.get("shell"))[0]
97
+ self.ctx = ToolContext(root=self.root, settings=settings, ui=ui, tracker=ReadTracker(), agent=self)
98
+ self.tokens = context.TokenTracker()
99
+ self.cancel = threading.Event()
100
+ self.pending_note: Optional[str] = None
101
+ self.memory_files: List[memory.MemoryFile] = []
102
+ self.on_usage: Optional[Callable[[], None]] = None
103
+ self.messages: List[Dict[str, Any]] = [{"role": "system", "content": self._build_system()}]
104
+
105
+ # ---------------------------------------------------------- prompt
106
+ def _build_system(self) -> str:
107
+ if self.is_subagent:
108
+ return prompts.build_system_prompt(self.root, self.shell_kind, "", subagent=True)
109
+ self.memory_files = memory.load(self.root)
110
+ return prompts.build_system_prompt(self.root, self.shell_kind, memory.render(self.memory_files))
111
+
112
+ def reload_system(self) -> None:
113
+ self.messages[0] = {"role": "system", "content": self._build_system()}
114
+
115
+ def load_history(self, messages: List[Dict[str, Any]], todos: Optional[List[Dict[str, Any]]] = None) -> None:
116
+ self.messages = [self.messages[0]] + [m for m in messages if m.get("role") != "system"]
117
+ if todos:
118
+ self.ctx.todos[:] = todos
119
+
120
+ def clear(self) -> None:
121
+ self.messages = [self.messages[0]]
122
+ self.ctx.todos.clear()
123
+ self.ctx.tracker = ReadTracker()
124
+ self.pending_note = None
125
+
126
+ def _append(self, msg: Dict[str, Any]) -> None:
127
+ self.messages.append(msg)
128
+ if self.session is not None:
129
+ self.session.message(msg)
130
+
131
+ # ---------------------------------------------------------- public
132
+ def run_turn(self, user_text: str, max_turns: Optional[int] = None) -> TurnResult:
133
+ max_turns = max_turns or self.max_turns
134
+ text = user_text
135
+ if self.pending_note:
136
+ text = self.pending_note + "\n\n" + text
137
+ self.pending_note = None
138
+ if len(self.messages) == 1 and not self.is_subagent:
139
+ text = prompts.dynamic_context(self.root, _branch(self.root)) + "\n\n" + text
140
+ self._append({"role": "user", "content": text})
141
+ self.cancel.clear()
142
+ turns = 0
143
+ last_text = ""
144
+ todos_before = list(self.ctx.todos)
145
+ while True:
146
+ if max_turns and turns >= max_turns:
147
+ return TurnResult(last_text, "max_turns", turns)
148
+ turns += 1
149
+ try:
150
+ self.maybe_compact()
151
+ resp = self._request()
152
+ except (Interrupted, KeyboardInterrupt):
153
+ self.ui.end_response()
154
+ self.ui.warn("Interrupted. Tell Euron Code what to do instead.")
155
+ self.pending_note = "[The user interrupted your previous response.]"
156
+ return TurnResult(last_text, "interrupted", turns)
157
+ except EuronError as e:
158
+ self.ui.end_response()
159
+ return TurnResult(last_text, "error", turns, error=e)
160
+ msg: Dict[str, Any] = {"role": "assistant", "content": resp.content}
161
+ if resp.tool_calls:
162
+ msg["tool_calls"] = resp.tool_calls
163
+ self._append(msg)
164
+ last_text = resp.content or last_text
165
+ if not resp.tool_calls:
166
+ if resp.finish_reason == "length":
167
+ self.ui.warn("(The response hit the output limit.)")
168
+ if self.ctx.todos != todos_before and self.session is not None:
169
+ self.session.todos(self.ctx.todos)
170
+ return TurnResult(resp.content, "end_turn", turns)
171
+ stop = self._run_tools(resp.tool_calls, truncated=(resp.finish_reason == "length"))
172
+ if self.ctx.todos != todos_before and self.session is not None:
173
+ self.session.todos(self.ctx.todos)
174
+ todos_before = list(self.ctx.todos)
175
+ if stop == DENIED:
176
+ return TurnResult(last_text, "denied", turns)
177
+ if stop == "interrupted":
178
+ self.ui.warn("Interrupted. Tell Euron Code what to do instead.")
179
+ self.pending_note = "[The user interrupted the previous tool call.]"
180
+ return TurnResult(last_text, "interrupted", turns)
181
+
182
+ # ---------------------------------------------------------- model request
183
+ def _payload(self) -> Dict[str, Any]:
184
+ est = self.tokens.prompt_estimate(self.messages, self.tool_schemas)
185
+ room = self.window - est - 256
186
+ max_tokens = max(1024, min(self.max_output, room))
187
+ return {
188
+ "model": self.model,
189
+ "messages": self.messages,
190
+ "tools": self.tool_schemas,
191
+ "tool_choice": "auto",
192
+ "max_tokens": max_tokens,
193
+ }
194
+
195
+ def _with_retries(self, call: Callable[[], Any], allow_compact: bool = True) -> Any:
196
+ busy = 0
197
+ rate = 0
198
+ net = 0
199
+ compacted = False
200
+ while True:
201
+ try:
202
+ return call()
203
+ except RateLimitedError as e:
204
+ rate += 1
205
+ if rate > MAX_RATE_LIMIT_RETRIES:
206
+ raise
207
+ wait = e.retry_after if e.retry_after is not None else RATE_LIMIT_FALLBACK
208
+ self._wait(wait, "Rate limit reached (requests per minute).")
209
+ except BusyError as e:
210
+ if busy >= len(BUSY_BACKOFF):
211
+ raise EuronError("Euron Code servers are still busy after 5 tries. Please try again in a few minutes.",
212
+ status=e.status, code=e.code)
213
+ wait = BUSY_BACKOFF[busy]
214
+ if e.code == "user_queue_full" and busy == 0:
215
+ # we never overlap requests, so this is the gateway still releasing the
216
+ # previous slot right after [DONE]: retry quickly the first time
217
+ wait = 1
218
+ busy += 1
219
+ self._wait(wait, "Euron Code servers are busy, retrying...")
220
+ except ContextTooLongError:
221
+ if compacted or not allow_compact:
222
+ raise
223
+ compacted = True
224
+ self.ui.warn("The conversation is too long for the model; compacting and retrying once.")
225
+ self.compact(force=True)
226
+ except NetworkError:
227
+ if net >= len(NETWORK_BACKOFF):
228
+ raise
229
+ wait = NETWORK_BACKOFF[net]
230
+ net += 1
231
+ self._wait(wait, "Network problem talking to Euron Code.")
232
+
233
+ def _wait(self, seconds: float, message: str) -> None:
234
+ self.ui.end_response()
235
+ try:
236
+ self.ui.countdown(seconds, message, self.cancel)
237
+ except KeyboardInterrupt:
238
+ raise Interrupted()
239
+ if self.cancel.is_set():
240
+ raise Interrupted()
241
+
242
+ def _request(self) -> Response:
243
+ def call() -> Response:
244
+ payload = self._payload()
245
+ raw_est = context.estimate_tokens(self.messages, self.tool_schemas)
246
+ resp = self._stream_once(payload)
247
+ self.tokens.record(resp.usage, raw_est)
248
+ if self.on_usage:
249
+ self.on_usage()
250
+ return resp
251
+ return self._with_retries(call)
252
+
253
+ def _stream_once(self, payload: Dict[str, Any]) -> Response:
254
+ r = Response()
255
+ calls: Dict[int, Dict[str, Any]] = {}
256
+ shown = False
257
+ self.ui.begin_response()
258
+ started = time.monotonic()
259
+
260
+ def on_wait() -> None:
261
+ if not shown:
262
+ waited = time.monotonic() - started
263
+ if waited > 3:
264
+ self.ui.waiting(f"Waiting for Euron Code... {waited:.0f}s (queued; Esc to cancel)")
265
+
266
+ interactive = getattr(self.ui, "interactive", False) and not self.is_subagent
267
+ try:
268
+ with EscWatcher(self.cancel, enabled=interactive):
269
+ stream = self.client.stream_chat(payload, cancel=self.cancel, on_wait=on_wait)
270
+ try:
271
+ for chunk in stream:
272
+ if self._consume(chunk, r, calls):
273
+ shown = True
274
+ finally:
275
+ stream.close()
276
+ except KeyboardInterrupt:
277
+ self.cancel.set()
278
+ self._keep_partial(r)
279
+ raise Interrupted()
280
+ except Interrupted:
281
+ self._keep_partial(r)
282
+ raise
283
+ except EuronError as e:
284
+ if shown and not isinstance(e, (api.NotEntitledError, api.AuthError, api.QuotaExceededError)):
285
+ self.ui.end_response()
286
+ raise MidStreamError(
287
+ f"The response stopped part-way ({e.message}). Nothing more was applied. "
288
+ "Send your message again to retry.", status=e.status, code=e.code) from None
289
+ raise
290
+ self.ui.end_response()
291
+ r.tool_calls = []
292
+ for idx in sorted(calls):
293
+ c = calls[idx]
294
+ if not c["function"]["name"]:
295
+ continue
296
+ if not c["id"]:
297
+ c["id"] = f"call_{uuid.uuid4().hex[:12]}"
298
+ r.tool_calls.append(c)
299
+ if not r.tool_calls and "<tool_call>" in r.content:
300
+ r.content, r.tool_calls = parse_inline_tool_calls(r.content)
301
+ return r
302
+
303
+ def _consume(self, chunk: Dict[str, Any], r: Response, calls: Dict[int, Dict[str, Any]]) -> bool:
304
+ """Apply one SSE chunk. Returns True when it carried visible output."""
305
+ shown = False
306
+ if chunk.get("usage"):
307
+ r.usage = chunk["usage"]
308
+ for choice in chunk.get("choices") or []:
309
+ delta = choice.get("delta") or {}
310
+ reasoning = delta.get("reasoning") or delta.get("reasoning_content")
311
+ if reasoning:
312
+ shown = True
313
+ r.reasoning_chars += len(reasoning)
314
+ self.ui.reasoning(reasoning)
315
+ text = delta.get("content")
316
+ if text:
317
+ shown = True
318
+ r.content += text
319
+ self.ui.content(text)
320
+ for tc in delta.get("tool_calls") or []:
321
+ shown = True
322
+ idx = tc.get("index", len(calls))
323
+ cur = calls.setdefault(idx, {"id": None, "type": "function",
324
+ "function": {"name": "", "arguments": ""}})
325
+ if tc.get("id"):
326
+ cur["id"] = tc["id"]
327
+ fn = tc.get("function") or {}
328
+ if fn.get("name"):
329
+ cur["function"]["name"] += fn["name"]
330
+ self.ui.tool_call_pending(cur["function"]["name"])
331
+ if fn.get("arguments"):
332
+ cur["function"]["arguments"] += fn["arguments"]
333
+ if choice.get("finish_reason"):
334
+ r.finish_reason = choice["finish_reason"]
335
+ return shown
336
+
337
+ def _keep_partial(self, r: Response) -> None:
338
+ """Keep the visible part of an interrupted answer so the model knows what it said."""
339
+ self.ui.end_response()
340
+ if r.content.strip():
341
+ self._append({"role": "assistant", "content": r.content + "\n[interrupted by the user]"})
342
+
343
+ # ---------------------------------------------------------- tools
344
+ def _run_tools(self, tool_calls: List[Dict[str, Any]], truncated: bool = False) -> Optional[str]:
345
+ stop: Optional[str] = None
346
+ for i, tc in enumerate(tool_calls):
347
+ if stop is not None:
348
+ self._append({"role": "tool", "tool_call_id": tc["id"], "name": tc["function"]["name"],
349
+ "content": "Skipped: the user stopped this turn before this tool ran."})
350
+ continue
351
+ try:
352
+ content, outcome = self._execute(tc, truncated)
353
+ except KeyboardInterrupt:
354
+ content, outcome = "Interrupted by the user (Ctrl-C).", "interrupted"
355
+ self._append({"role": "tool", "tool_call_id": tc["id"], "name": tc["function"]["name"],
356
+ "content": content})
357
+ if outcome in (DENIED, "interrupted"):
358
+ stop = outcome
359
+ return stop
360
+
361
+ def _execute(self, tc: Dict[str, Any], truncated: bool):
362
+ name = tc["function"]["name"]
363
+ raw = tc["function"].get("arguments") or "{}"
364
+ tool = self.tool_map.get(name)
365
+ if tool is None:
366
+ self.ui.tool_start(name, "(unknown tool)")
367
+ return f"Error: unknown tool `{name}`. Available: {', '.join(self.tool_map)}.", None
368
+ try:
369
+ args = json.loads(raw) if raw.strip() else {}
370
+ if not isinstance(args, dict):
371
+ raise ValueError("arguments must be a JSON object")
372
+ except ValueError as e:
373
+ self.ui.tool_start(name, "(invalid arguments)")
374
+ hint = (" The output limit cut the call off; write smaller pieces (for example several edits)."
375
+ if truncated else "")
376
+ return f"Error: invalid JSON arguments for {name}: {e}.{hint}", None
377
+ try:
378
+ summary = tool.describe(args, self.ctx)
379
+ except Exception:
380
+ summary = ""
381
+ self.ui.tool_start(name, summary)
382
+ try:
383
+ req = tool.permission(args, self.ctx)
384
+ decision = self.permissions.check(req)
385
+ if decision == "deny":
386
+ msg = "Permission denied by a deny rule in settings. Do not retry this action."
387
+ self.ui.tool_result(msg, is_error=True)
388
+ return "Error: " + msg, None
389
+ if decision == "ask":
390
+ verdict, feedback = self._ask(tool, args, req)
391
+ if verdict != "allow":
392
+ note = "The user denied this action."
393
+ if feedback:
394
+ note += f" The user said: {feedback}"
395
+ self.ui.tool_result("denied", is_error=True)
396
+ return note, None # continue the loop with the feedback
397
+ self.ui.tool_result("denied", is_error=True)
398
+ if not getattr(self.ui, "interactive", True):
399
+ return note + " Do not retry it; continue without it or explain what you need.", None
400
+ return note + " Wait for the user's next instruction.", DENIED
401
+ elif req.category == "Edit":
402
+ self.ui.show_diff(tool.preview(args, self.ctx) or "")
403
+ result = tool.run(args, self.ctx)
404
+ except ToolError as e:
405
+ result = ToolResult(str(e), is_error=True)
406
+ except KeyboardInterrupt:
407
+ raise
408
+ except Exception as e: # a tool bug must not kill the session
409
+ result = ToolResult(f"Tool {name} failed unexpectedly: {type(e).__name__}: {e}", is_error=True)
410
+ if name != "todo_write":
411
+ self.ui.tool_result(result.preview if result.preview is not None else result.content, result.is_error)
412
+ content = result.content
413
+ if result.is_error and not content.startswith(("Error", "Exit code")):
414
+ content = "Error: " + content
415
+ return content, None
416
+
417
+ def _ask(self, tool, args, req: Request):
418
+ detail = tool.preview(args, self.ctx) # may raise ToolError (e.g. edit does not match)
419
+ if req.category == "Edit":
420
+ self.ui.show_diff(detail or "")
421
+ detail = None
422
+ titles = {
423
+ "Edit": "Apply this change?",
424
+ "Bash": "Run this command?",
425
+ "WebFetch": f"Fetch from {req.domain}?",
426
+ "DbWrite": "Run this WRITE statement on the database?",
427
+ "Read": "Read outside the project (or a secret-looking file)?",
428
+ "Db": "Open this database outside the project?",
429
+ }
430
+ title = titles.get(req.category or "", f"Allow {tool.name}?")
431
+ options = [("y", "yes"), ("n", "no")]
432
+ if req.category == "Bash":
433
+ from euron_code.permissions import command_prefix
434
+ options.append(("a", "always this exact command (session)"))
435
+ options.append(("p", f"always `{command_prefix(req.command or '')}` commands (session)"))
436
+ elif req.category != "DbWrite":
437
+ options.append(("a", f"always allow {req.category} (session)"))
438
+ if req.category not in ("DbWrite",):
439
+ options.append(("s", "always, and save to .euron/settings.local.json"))
440
+ choice = self.ui.ask_permission(title, detail, options)
441
+ if choice == "y":
442
+ return "allow", None
443
+ if choice in ("a", "p", "s"):
444
+ rule = self.permissions.session_rule_for(req, prefix=(choice == "p" or (choice == "s" and req.category == "Bash")))
445
+ self.permissions.grant_session(rule)
446
+ if choice == "s":
447
+ from euron_code import config
448
+ path = config.add_permission_rule(self.root, "allow", rule)
449
+ self.ui.dim(f" saved allow rule {rule} to {path}")
450
+ return "allow", None
451
+ feedback = ""
452
+ if getattr(self.ui, "interactive", False):
453
+ feedback = self.ui.ask_text(" Tell Euron Code what to do instead (Enter to stop): ").strip()
454
+ return "deny", feedback or None
455
+
456
+ # ---------------------------------------------------------- compaction
457
+ def context_usage(self) -> int:
458
+ return self.tokens.prompt_estimate(self.messages, self.tool_schemas)
459
+
460
+ def maybe_compact(self) -> None:
461
+ if not self.settings.get("autoCompact", True):
462
+ return
463
+ threshold = float(self.settings.get("compactThreshold") or 0.75) * self.window
464
+ if self.context_usage() > threshold:
465
+ self.ui.dim(f"Context is {self.context_usage() * 100 // self.window}% full; compacting...")
466
+ self.compact()
467
+
468
+ def compact(self, force: bool = False, instructions: Optional[str] = None, manual: bool = False) -> bool:
469
+ threshold = float(self.settings.get("compactThreshold") or 0.75) * self.window
470
+ target = threshold * 0.6
471
+ before = self.context_usage()
472
+ # stage 1: drop bulky old tool outputs
473
+ if not manual:
474
+ context.elide_old_tool_outputs(self.messages, keep_last=4)
475
+ if self.context_usage() <= target and not force:
476
+ self.ctx.tracker.clear()
477
+ self._persist_compaction()
478
+ self.ui.dim(f"Compacted: removed old tool outputs ({before} -> {self.context_usage()} tokens est.).")
479
+ return True
480
+ users = [i for i, m in enumerate(self.messages) if m.get("role") == "user"]
481
+ if manual and len(users) > 1:
482
+ cut = users[-1] # /compact: summarise everything before the latest exchange
483
+ else:
484
+ _, cut = context.split_for_compaction(self.messages, int(self.window * 0.2))
485
+ if cut <= 1:
486
+ if not manual:
487
+ context.elide_old_tool_outputs(self.messages, keep_last=1, min_chars=300)
488
+ self._persist_compaction()
489
+ self.ui.warn("Nothing older to summarise; the current turn alone is large. Consider /clear.")
490
+ return False
491
+ old = self.messages[1:cut]
492
+ recent = self.messages[cut:]
493
+ transcript = context.render_transcript(old)
494
+ max_in_chars = int((self.window - 3000) * context.CHARS_PER_TOKEN * 0.8)
495
+ if len(transcript) > max_in_chars:
496
+ transcript = transcript[-max_in_chars:]
497
+ ask = "Summarise this conversation so far.\n\n<transcript>\n" + transcript + "\n</transcript>"
498
+ if instructions:
499
+ ask += f"\n\nExtra instructions from the user for the summary: {instructions}"
500
+ payload = {"model": self.model, "max_tokens": 1500,
501
+ "messages": [{"role": "system", "content": prompts.COMPACT}, {"role": "user", "content": ask}]}
502
+ self.ui.begin_response()
503
+ self.ui.waiting("Compacting conversation...")
504
+ try:
505
+ data = self._with_retries(lambda: self.client.chat(payload), allow_compact=False)
506
+ except EuronError as e:
507
+ self.ui.end_response()
508
+ self.ui.warn(f"Could not summarise the conversation ({e.message}); dropped old tool outputs instead.")
509
+ context.elide_old_tool_outputs(self.messages, keep_last=2, min_chars=300)
510
+ self._persist_compaction()
511
+ return False
512
+ self.ui.end_response()
513
+ self.tokens.record(data.get("usage"), 0)
514
+ summary = ""
515
+ try:
516
+ summary = (data["choices"][0]["message"].get("content") or "").strip()
517
+ except (KeyError, IndexError, TypeError):
518
+ pass
519
+ if not summary:
520
+ self.ui.warn("The summary came back empty; keeping the conversation as it was.")
521
+ return False
522
+ head = "[Summary of the earlier part of this conversation]\n" + summary
523
+ if self.ctx.todos:
524
+ head += "\n\nCurrent todo list:\n" + render_todos(self.ctx.todos)
525
+ last_user = next((m for m in reversed(old) if m.get("role") == "user"), None)
526
+ if recent[0].get("role") == "user":
527
+ first = dict(recent[0])
528
+ first["content"] = head + "\n\n[Latest message]\n" + str(first.get("content") or "")
529
+ self.messages = [self.messages[0], first] + recent[1:]
530
+ else:
531
+ # cut inside the current turn: restate the user's request, then continue the work
532
+ if last_user is not None:
533
+ req = str(last_user.get("content") or "")
534
+ if len(req) > 4000:
535
+ req = req[:4000] + "\n[... request truncated ...]"
536
+ head += "\n\n[The user's current request, verbatim]\n" + req
537
+ head += "\n\nContinue the task from where the work below left off."
538
+ self.messages = [self.messages[0], {"role": "user", "content": head}] + recent
539
+ self.ctx.tracker.clear()
540
+ self.tokens.ratio = 1.0
541
+ self._persist_compaction()
542
+ self.ui.dim(f"Compacted: {before} -> {self.context_usage()} tokens (estimated).")
543
+ return True
544
+
545
+ def _persist_compaction(self) -> None:
546
+ if self.session is not None:
547
+ self.session.compact(self.messages[1:])
548
+
549
+ # ---------------------------------------------------------- sub-agent
550
+ def run_subagent(self, prompt: str) -> str:
551
+ sub_ui = _SubagentUI(self.ui)
552
+ sub = Agent(self.client, sub_ui, self.root, self.settings, self.permissions, self.model,
553
+ is_subagent=True, max_turns=15)
554
+ sub.cancel = self.cancel
555
+ res = sub.run_turn(prompt)
556
+ self.tokens.session_prompt += sub.tokens.session_prompt
557
+ self.tokens.session_completion += sub.tokens.session_completion
558
+ self.tokens.requests += sub.tokens.requests
559
+ if res.error is not None:
560
+ raise ToolError(f"Sub-agent failed: {res.error.message}")
561
+ if res.stop_reason == "interrupted":
562
+ raise KeyboardInterrupt()
563
+ text = res.text.strip() or "(the sub-agent returned no answer)"
564
+ if res.stop_reason == "max_turns":
565
+ text += "\n[sub-agent stopped at its step limit]"
566
+ return text
567
+
568
+
569
+ class _SubagentUI:
570
+ """Indented, compact progress for sub-agent tool calls."""
571
+
572
+ interactive = False
573
+
574
+ def __init__(self, parent):
575
+ self.parent = parent
576
+
577
+ def __getattr__(self, name):
578
+ return lambda *a, **k: None
579
+
580
+ def tool_start(self, name, summary):
581
+ self.parent.dim(f" - {name} {summary}")
582
+
583
+ def countdown(self, seconds, message, cancel=None):
584
+ self.parent.countdown(seconds, message, cancel)
585
+
586
+ def warn(self, msg):
587
+ self.parent.warn(" " + msg)
588
+
589
+ def ask_permission(self, *a, **k):
590
+ return "n"
591
+
592
+
593
+ def _branch(root: Path) -> Optional[str]:
594
+ from euron_code.config import git_branch
595
+ return git_branch(root)