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 +3 -0
- euron_code/__main__.py +4 -0
- euron_code/agent.py +595 -0
- euron_code/api.py +364 -0
- euron_code/auth.py +115 -0
- euron_code/cli.py +351 -0
- euron_code/config.py +207 -0
- euron_code/context.py +131 -0
- euron_code/credentials.py +197 -0
- euron_code/doctor.py +88 -0
- euron_code/keys.py +92 -0
- euron_code/memory.py +97 -0
- euron_code/permissions.py +245 -0
- euron_code/prompts.py +98 -0
- euron_code/repl.py +425 -0
- euron_code/sessions.py +186 -0
- euron_code/tools/__init__.py +33 -0
- euron_code/tools/base.py +167 -0
- euron_code/tools/db.py +368 -0
- euron_code/tools/fs.py +412 -0
- euron_code/tools/ignore.py +75 -0
- euron_code/tools/search.py +204 -0
- euron_code/tools/shell.py +214 -0
- euron_code/tools/task.py +41 -0
- euron_code/tools/todo.py +64 -0
- euron_code/tools/web.py +112 -0
- euron_code/ui.py +269 -0
- euron_code/usage_view.py +108 -0
- euron_code-0.1.0.dist-info/METADATA +278 -0
- euron_code-0.1.0.dist-info/RECORD +33 -0
- euron_code-0.1.0.dist-info/WHEEL +4 -0
- euron_code-0.1.0.dist-info/entry_points.txt +2 -0
- euron_code-0.1.0.dist-info/licenses/LICENSE +23 -0
euron_code/__init__.py
ADDED
euron_code/__main__.py
ADDED
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)
|