euron-code 0.5.0__tar.gz → 0.5.2__tar.gz
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-0.5.0 → euron_code-0.5.2}/CHANGELOG.md +18 -0
- {euron_code-0.5.0 → euron_code-0.5.2}/PKG-INFO +1 -1
- {euron_code-0.5.0 → euron_code-0.5.2}/pyproject.toml +1 -1
- {euron_code-0.5.0 → euron_code-0.5.2}/src/euron_code/__init__.py +1 -1
- {euron_code-0.5.0 → euron_code-0.5.2}/src/euron_code/agent.py +105 -4
- {euron_code-0.5.0 → euron_code-0.5.2}/src/euron_code/api.py +20 -1
- {euron_code-0.5.0 → euron_code-0.5.2}/src/euron_code/cli.py +36 -8
- {euron_code-0.5.0 → euron_code-0.5.2}/src/euron_code/permissions.py +6 -0
- {euron_code-0.5.0 → euron_code-0.5.2}/src/euron_code/prompts.py +9 -0
- {euron_code-0.5.0 → euron_code-0.5.2}/src/euron_code/tools/erie.py +28 -17
- {euron_code-0.5.0 → euron_code-0.5.2}/src/euron_code/tools/oss.py +1 -1
- {euron_code-0.5.0 → euron_code-0.5.2}/src/euron_code/ui.py +15 -2
- {euron_code-0.5.0 → euron_code-0.5.2}/tests/mock_gateway.py +43 -0
- euron_code-0.5.2/tests/test_qa_attach_secrets.py +59 -0
- euron_code-0.5.2/tests/test_qa_long_task.py +88 -0
- euron_code-0.5.2/tests/test_qa_print_output.py +101 -0
- euron_code-0.5.2/tests/test_qa_reuse_unrelated.py +105 -0
- euron_code-0.5.2/tests/test_qa_yolo_outside.py +59 -0
- {euron_code-0.5.0 → euron_code-0.5.2}/tests/test_v5.py +27 -0
- {euron_code-0.5.0 → euron_code-0.5.2}/.gitignore +0 -0
- {euron_code-0.5.0 → euron_code-0.5.2}/LICENSE +0 -0
- {euron_code-0.5.0 → euron_code-0.5.2}/README.md +0 -0
- {euron_code-0.5.0 → euron_code-0.5.2}/src/euron_code/__main__.py +0 -0
- {euron_code-0.5.0 → euron_code-0.5.2}/src/euron_code/activity.py +0 -0
- {euron_code-0.5.0 → euron_code-0.5.2}/src/euron_code/attachments.py +0 -0
- {euron_code-0.5.0 → euron_code-0.5.2}/src/euron_code/auth.py +0 -0
- {euron_code-0.5.0 → euron_code-0.5.2}/src/euron_code/builtin_skills/build-from-reference/SKILL.md +0 -0
- {euron_code-0.5.0 → euron_code-0.5.2}/src/euron_code/builtin_skills/explain-codebase/SKILL.md +0 -0
- {euron_code-0.5.0 → euron_code-0.5.2}/src/euron_code/builtin_skills/review/SKILL.md +0 -0
- {euron_code-0.5.0 → euron_code-0.5.2}/src/euron_code/builtin_skills/test-writer/SKILL.md +0 -0
- {euron_code-0.5.0 → euron_code-0.5.2}/src/euron_code/cache.py +0 -0
- {euron_code-0.5.0 → euron_code-0.5.2}/src/euron_code/checkpoints.py +0 -0
- {euron_code-0.5.0 → euron_code-0.5.2}/src/euron_code/config.py +0 -0
- {euron_code-0.5.0 → euron_code-0.5.2}/src/euron_code/context.py +0 -0
- {euron_code-0.5.0 → euron_code-0.5.2}/src/euron_code/contextforge/LICENSE +0 -0
- {euron_code-0.5.0 → euron_code-0.5.2}/src/euron_code/contextforge/__init__.py +0 -0
- {euron_code-0.5.0 → euron_code-0.5.2}/src/euron_code/contextforge/text.py +0 -0
- {euron_code-0.5.0 → euron_code-0.5.2}/src/euron_code/contextforge/toolout.py +0 -0
- {euron_code-0.5.0 → euron_code-0.5.2}/src/euron_code/credentials.py +0 -0
- {euron_code-0.5.0 → euron_code-0.5.2}/src/euron_code/doctor.py +0 -0
- {euron_code-0.5.0 → euron_code-0.5.2}/src/euron_code/documents.py +0 -0
- {euron_code-0.5.0 → euron_code-0.5.2}/src/euron_code/error_hints.py +0 -0
- {euron_code-0.5.0 → euron_code-0.5.2}/src/euron_code/errorlog.py +0 -0
- {euron_code-0.5.0 → euron_code-0.5.2}/src/euron_code/frontmatter.py +0 -0
- {euron_code-0.5.0 → euron_code-0.5.2}/src/euron_code/hooks.py +0 -0
- {euron_code-0.5.0 → euron_code-0.5.2}/src/euron_code/keys.py +0 -0
- {euron_code-0.5.0 → euron_code-0.5.2}/src/euron_code/licensing.py +0 -0
- {euron_code-0.5.0 → euron_code-0.5.2}/src/euron_code/memory.py +0 -0
- {euron_code-0.5.0 → euron_code-0.5.2}/src/euron_code/project_setup.py +0 -0
- {euron_code-0.5.0 → euron_code-0.5.2}/src/euron_code/repl.py +0 -0
- {euron_code-0.5.0 → euron_code-0.5.2}/src/euron_code/repomap.py +0 -0
- {euron_code-0.5.0 → euron_code-0.5.2}/src/euron_code/rules.py +0 -0
- {euron_code-0.5.0 → euron_code-0.5.2}/src/euron_code/sessions.py +0 -0
- {euron_code-0.5.0 → euron_code-0.5.2}/src/euron_code/skills.py +0 -0
- {euron_code-0.5.0 → euron_code-0.5.2}/src/euron_code/textsafe.py +0 -0
- {euron_code-0.5.0 → euron_code-0.5.2}/src/euron_code/tools/__init__.py +0 -0
- {euron_code-0.5.0 → euron_code-0.5.2}/src/euron_code/tools/background.py +0 -0
- {euron_code-0.5.0 → euron_code-0.5.2}/src/euron_code/tools/base.py +0 -0
- {euron_code-0.5.0 → euron_code-0.5.2}/src/euron_code/tools/db.py +0 -0
- {euron_code-0.5.0 → euron_code-0.5.2}/src/euron_code/tools/document.py +0 -0
- {euron_code-0.5.0 → euron_code-0.5.2}/src/euron_code/tools/fs.py +0 -0
- {euron_code-0.5.0 → euron_code-0.5.2}/src/euron_code/tools/ignore.py +0 -0
- {euron_code-0.5.0 → euron_code-0.5.2}/src/euron_code/tools/reuse.py +0 -0
- {euron_code-0.5.0 → euron_code-0.5.2}/src/euron_code/tools/search.py +0 -0
- {euron_code-0.5.0 → euron_code-0.5.2}/src/euron_code/tools/shell.py +0 -0
- {euron_code-0.5.0 → euron_code-0.5.2}/src/euron_code/tools/skill.py +0 -0
- {euron_code-0.5.0 → euron_code-0.5.2}/src/euron_code/tools/symbols.py +0 -0
- {euron_code-0.5.0 → euron_code-0.5.2}/src/euron_code/tools/task.py +0 -0
- {euron_code-0.5.0 → euron_code-0.5.2}/src/euron_code/tools/todo.py +0 -0
- {euron_code-0.5.0 → euron_code-0.5.2}/src/euron_code/tools/web.py +0 -0
- {euron_code-0.5.0 → euron_code-0.5.2}/src/euron_code/trust.py +0 -0
- {euron_code-0.5.0 → euron_code-0.5.2}/src/euron_code/usage_view.py +0 -0
- {euron_code-0.5.0 → euron_code-0.5.2}/tests/conftest.py +0 -0
- {euron_code-0.5.0 → euron_code-0.5.2}/tests/helpers.py +0 -0
- {euron_code-0.5.0 → euron_code-0.5.2}/tests/serve_helper.py +0 -0
- {euron_code-0.5.0 → euron_code-0.5.2}/tests/test_agent_mock.py +0 -0
- {euron_code-0.5.0 → euron_code-0.5.2}/tests/test_api_shell.py +0 -0
- {euron_code-0.5.0 → euron_code-0.5.2}/tests/test_bench.py +0 -0
- {euron_code-0.5.0 → euron_code-0.5.2}/tests/test_cli_e2e.py +0 -0
- {euron_code-0.5.0 → euron_code-0.5.2}/tests/test_context_memory_sessions.py +0 -0
- {euron_code-0.5.0 → euron_code-0.5.2}/tests/test_db.py +0 -0
- {euron_code-0.5.0 → euron_code-0.5.2}/tests/test_erie.py +0 -0
- {euron_code-0.5.0 → euron_code-0.5.2}/tests/test_permissions.py +0 -0
- {euron_code-0.5.0 → euron_code-0.5.2}/tests/test_powershell_exit_codes.py +0 -0
- {euron_code-0.5.0 → euron_code-0.5.2}/tests/test_repl.py +0 -0
- {euron_code-0.5.0 → euron_code-0.5.2}/tests/test_search.py +0 -0
- {euron_code-0.5.0 → euron_code-0.5.2}/tests/test_textsafe.py +0 -0
- {euron_code-0.5.0 → euron_code-0.5.2}/tests/test_tools_fs.py +0 -0
- {euron_code-0.5.0 → euron_code-0.5.2}/tests/test_v2_rules_skills_hooks.py +0 -0
- {euron_code-0.5.0 → euron_code-0.5.2}/tests/test_v2_tokens_shortcuts.py +0 -0
- {euron_code-0.5.0 → euron_code-0.5.2}/tests/test_v2_usage_headers.py +0 -0
- {euron_code-0.5.0 → euron_code-0.5.2}/tests/test_v31_security.py +0 -0
- {euron_code-0.5.0 → euron_code-0.5.2}/tests/test_v3_accounts_value_setup.py +0 -0
- {euron_code-0.5.0 → euron_code-0.5.2}/tests/test_v3_activity.py +0 -0
- {euron_code-0.5.0 → euron_code-0.5.2}/tests/test_v3_attachments.py +0 -0
- {euron_code-0.5.0 → euron_code-0.5.2}/tests/test_v3_context.py +0 -0
- {euron_code-0.5.0 → euron_code-0.5.2}/tests/test_v3_live_repl.py +0 -0
- {euron_code-0.5.0 → euron_code-0.5.2}/tests/test_v3_single_login.py +0 -0
- {euron_code-0.5.0 → euron_code-0.5.2}/tests/test_v4.py +0 -0
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.5.2 (2026-11-05)
|
|
4
|
+
|
|
5
|
+
Security and reliability fixes from pre-launch testing.
|
|
6
|
+
|
|
7
|
+
- Bypass mode (`--yolo`) now asks before any command that names a path outside your project, including
|
|
8
|
+
quoted paths and paths in your home folder.
|
|
9
|
+
- Files that usually hold secrets (such as `.env`) are never sent with your message without a yes.
|
|
10
|
+
- An answer that comes back empty is asked for once more instead of ending silently; `-p` always prints a
|
|
11
|
+
result, and JSON mode reports errors as JSON.
|
|
12
|
+
- Hindi, emoji and other non-English text print correctly when output is piped on Windows.
|
|
13
|
+
- Long tasks no longer stop early as a false "loop", or with an unfinished task list.
|
|
14
|
+
- Reuse planning picks the right components and records every reused source.
|
|
15
|
+
|
|
16
|
+
## 0.5.1 (2026-11-05)
|
|
17
|
+
|
|
18
|
+
- Running `euron` again right after a run finished no longer says Euron Code is already running for
|
|
19
|
+
your account: the login is freed when Euron Code exits.
|
|
20
|
+
|
|
3
21
|
## 0.5.0 (2026-11-05)
|
|
4
22
|
|
|
5
23
|
### Build from references (V5 contract section 1)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: euron-code
|
|
3
|
-
Version: 0.5.
|
|
3
|
+
Version: 0.5.2
|
|
4
4
|
Summary: Euron Code: an agentic coding assistant in your terminal for Euron Plus and Super 30 students.
|
|
5
5
|
Project-URL: Homepage, https://euron.one/code-agent
|
|
6
6
|
Author-email: Euron <contact@euron.one>
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "euron-code"
|
|
7
|
-
version = "0.5.
|
|
7
|
+
version = "0.5.2"
|
|
8
8
|
description = "Euron Code: an agentic coding assistant in your terminal for Euron Plus and Super 30 students."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.9"
|
|
@@ -167,7 +167,9 @@ class Agent:
|
|
|
167
167
|
self.pending_images: List[Dict[str, Any]] = [] # image_url parts for the next prompt
|
|
168
168
|
self.pending_image_paths: List[Path] = [] # their local files (a photo may belong on the page)
|
|
169
169
|
self._placeholder_nudged = False
|
|
170
|
+
self._ctx_note_band = 0 # 10% band of the last small "Context was N% full" note
|
|
170
171
|
self.pending_paths: List[Path] = [] # /attach, /paste-image: attached to the next prompt
|
|
172
|
+
self._notice_nudged = False # one THIRD_PARTY_NOTICES nudge per prompt
|
|
171
173
|
self.memory_files: List[memory.MemoryFile] = []
|
|
172
174
|
self.rules = rules.RuleSet([])
|
|
173
175
|
self.skills: Dict[str, skills.Skill] = {}
|
|
@@ -319,6 +321,7 @@ class Agent:
|
|
|
319
321
|
errorlog.get().task_id = self.client.task_id
|
|
320
322
|
self._nudged = False
|
|
321
323
|
self._placeholder_nudged = False
|
|
324
|
+
self._notice_nudged = False
|
|
322
325
|
for r in self._run_hooks("UserPromptSubmit", {"prompt": user_text}):
|
|
323
326
|
if r.code == 2:
|
|
324
327
|
self.ui.warn("A UserPromptSubmit hook blocked this prompt: " + (r.stderr.strip() or "(no reason given)"))
|
|
@@ -379,8 +382,11 @@ class Agent:
|
|
|
379
382
|
continues = 0 # answer cut by the output limit, continued automatically
|
|
380
383
|
cut_tools = 0
|
|
381
384
|
effort_retry = False
|
|
385
|
+
empty_retry = False # one retry when the model answers with nothing at all
|
|
382
386
|
partial = "" # text of an answer that is being continued
|
|
383
387
|
todos_before = list(self.ctx.todos)
|
|
388
|
+
todo_nudged = False # one "your task list is not finished" nudge per request
|
|
389
|
+
todos_at_start = list(self.ctx.todos)
|
|
384
390
|
while True:
|
|
385
391
|
if max_turns and turns >= max_turns:
|
|
386
392
|
return TurnResult(last_text, "max_turns", turns)
|
|
@@ -433,6 +439,15 @@ class Agent:
|
|
|
433
439
|
self._append({"role": "user", "content": prompts.ANSWER_NUDGE})
|
|
434
440
|
self._effort_once = "low"
|
|
435
441
|
continue
|
|
442
|
+
if (not resp.tool_calls and not (partial + resp.content).strip() and not empty_retry
|
|
443
|
+
and not self.is_subagent):
|
|
444
|
+
# the model ended the turn without a word (seen live): ask once instead of
|
|
445
|
+
# printing nothing and exiting 0
|
|
446
|
+
empty_retry = True
|
|
447
|
+
self._append({"role": "assistant", "content": "(empty reply)"})
|
|
448
|
+
self._append({"role": "user", "content": prompts.EMPTY_NUDGE})
|
|
449
|
+
self.ui.dim("(the answer came back empty; asking again)")
|
|
450
|
+
continue
|
|
436
451
|
msg: Dict[str, Any] = {"role": "assistant", "content": resp.content}
|
|
437
452
|
if resp.tool_calls:
|
|
438
453
|
msg["tool_calls"] = resp.tool_calls
|
|
@@ -455,11 +470,29 @@ class Agent:
|
|
|
455
470
|
self._append({"role": "user", "content": prompts.PLACEHOLDER_NUDGE.format(where=where)})
|
|
456
471
|
self.ui.dim("(asking Euron Code to replace placeholder text with your details)")
|
|
457
472
|
continue
|
|
473
|
+
missing = self._missing_notices() if not self._notice_nudged and not self.is_subagent else ""
|
|
474
|
+
if missing:
|
|
475
|
+
self._notice_nudged = True
|
|
476
|
+
self._append({"role": "user", "content": prompts.NOTICE_NUDGE.format(repos=missing)})
|
|
477
|
+
self.ui.dim("(asking Euron Code to record the reused sources in THIRD_PARTY_NOTICES.md)")
|
|
478
|
+
continue
|
|
458
479
|
if self._needs_verify_nudge():
|
|
459
480
|
self._nudged = True
|
|
460
481
|
self._append({"role": "user", "content": prompts.verify_nudge(self.test_command)})
|
|
461
482
|
self.ui.dim("(asking Euron Code to check its change)")
|
|
462
483
|
continue
|
|
484
|
+
open_todos = [t for t in self.ctx.todos if isinstance(t, dict)
|
|
485
|
+
and t.get("status") in ("pending", "in_progress")]
|
|
486
|
+
if (open_todos and self.ctx.todos != todos_at_start and not todo_nudged and not self.is_subagent
|
|
487
|
+
and self.permissions.mode != "plan"
|
|
488
|
+
and len((resp.content or "").strip()) < 400):
|
|
489
|
+
# QA 2026-11-05: a portfolio build ended on "Scaffolding the Next.js project now..." with 6 of 6
|
|
490
|
+
# todos open and no file written; exit 0, nothing built
|
|
491
|
+
todo_nudged = True
|
|
492
|
+
self._append({"role": "user", "content": prompts.TODO_NUDGE.format(
|
|
493
|
+
todos="; ".join(str(t.get("content"))[:80] for t in open_todos[:5]))})
|
|
494
|
+
self.ui.dim("(the task list is not finished; asking Euron Code to continue)")
|
|
495
|
+
continue
|
|
463
496
|
if not self.is_subagent:
|
|
464
497
|
self._footer()
|
|
465
498
|
return TurnResult(partial + resp.content, "end_turn", turns)
|
|
@@ -755,7 +788,11 @@ class Agent:
|
|
|
755
788
|
args = str(fn.get("arguments"))
|
|
756
789
|
failed = content.startswith("Error") or (content.startswith("Exit code: ") and
|
|
757
790
|
not content.startswith("Exit code: 0"))
|
|
758
|
-
|
|
791
|
+
# A failing run is "the same" only when its start AND its end match (numbers ignored, so timings
|
|
792
|
+
# do not hide a real loop). QA 2026-11-05: pytest printed the same 300-char warning first every time,
|
|
793
|
+
# so 15 failed / collection error / 4 failed counted as one call failing 5 times and the turn stopped.
|
|
794
|
+
key = (fn.get("name"), args, failed,
|
|
795
|
+
(content[:300], re.sub(r"\d+", "N", content[-300:])) if failed else "")
|
|
759
796
|
n = self._calls[key] = self._calls.get(key, 0) + 1
|
|
760
797
|
if failed:
|
|
761
798
|
if n >= LOOP_STOP_AT:
|
|
@@ -879,6 +916,9 @@ class Agent:
|
|
|
879
916
|
model call), so the model starts from proven code whatever it would have chosen; and put the
|
|
880
917
|
user's photo into the project. Returns text for the end of the user message."""
|
|
881
918
|
from euron_code.tools import erie
|
|
919
|
+
# only what the user typed: an attached document's text ("The service must expose GET /health")
|
|
920
|
+
# turned a question about it into a build request (QA 2026-11-05)
|
|
921
|
+
user_text = re.sub(r'<attached-file path="[^"]*">.*?</attached-file>', " ", user_text, flags=re.S)
|
|
882
922
|
if not erie.is_build_request(user_text):
|
|
883
923
|
return ""
|
|
884
924
|
out = ""
|
|
@@ -942,6 +982,36 @@ class Agent:
|
|
|
942
982
|
hits.append(f"{rel} ({', '.join(found[:3])})")
|
|
943
983
|
return "; ".join(hits[:6])
|
|
944
984
|
|
|
985
|
+
def _missing_notices(self) -> str:
|
|
986
|
+
"""Repos read this prompt that a changed file names (an "Adapted from owner/repo" header) but that
|
|
987
|
+
THIRD_PARTY_NOTICES.md does not list. QA 2026-11-05: a portfolio's Hero said "Adapted from
|
|
988
|
+
euron-sde-team/euron-open-multi-tenant-lms (MIT)" and the notices file had no entry for it."""
|
|
989
|
+
from euron_code import licensing
|
|
990
|
+
repos = list(dict.fromkeys(r for r, _, _ in self.ledger.get("reused") or []))
|
|
991
|
+
if not repos:
|
|
992
|
+
return ""
|
|
993
|
+
try:
|
|
994
|
+
notices = (self.root / licensing.NOTICES_FILE).read_text(encoding="utf-8", errors="ignore")
|
|
995
|
+
except OSError:
|
|
996
|
+
notices = ""
|
|
997
|
+
listed = {ln[3:].strip().lower() for ln in notices.splitlines() if ln.startswith("## ")}
|
|
998
|
+
todo = [r for r in repos if r.lower() not in listed]
|
|
999
|
+
if not todo:
|
|
1000
|
+
return ""
|
|
1001
|
+
named = []
|
|
1002
|
+
for rel in self.files_changed():
|
|
1003
|
+
p = self.root / rel
|
|
1004
|
+
if p.name == licensing.NOTICES_FILE:
|
|
1005
|
+
continue
|
|
1006
|
+
try:
|
|
1007
|
+
if not p.is_file() or p.stat().st_size > 400_000:
|
|
1008
|
+
continue
|
|
1009
|
+
text = p.read_text(encoding="utf-8", errors="ignore")
|
|
1010
|
+
except OSError:
|
|
1011
|
+
continue
|
|
1012
|
+
named += [r for r in todo if r in text and r not in named]
|
|
1013
|
+
return ", ".join(named[:6])
|
|
1014
|
+
|
|
945
1015
|
def _verified(self) -> bool:
|
|
946
1016
|
if not self.ledger["edits"]:
|
|
947
1017
|
return True
|
|
@@ -960,7 +1030,10 @@ class Agent:
|
|
|
960
1030
|
"""Local report after a prompt that changed files (shown to the user, not sent to the model)."""
|
|
961
1031
|
changed = self.files_changed()
|
|
962
1032
|
reused = self.ledger.get("reused") or []
|
|
963
|
-
|
|
1033
|
+
read_repos = {r for r, _, _ in reused}
|
|
1034
|
+
# "x reused from repo" only when that repo was actually read (QA 2026-11-05: a question about two
|
|
1035
|
+
# attached documents printed "Reuse plan: api reused from euron-open-smart-router" with nothing read)
|
|
1036
|
+
planned = [c for c in self.reuse_report() if c["decision"] in ("REUSE", "ADAPT") and c.get("repo") in read_repos]
|
|
964
1037
|
if planned:
|
|
965
1038
|
self._phase("Integration completed")
|
|
966
1039
|
self.ui.dim("Reuse plan: " + "; ".join(f"{c['component']} {_PAST.get(c['decision'], c['decision'].lower())} from {c['repo']} "
|
|
@@ -1193,7 +1266,14 @@ class Agent:
|
|
|
1193
1266
|
self.savings.add("micro", saved)
|
|
1194
1267
|
self._persist_compaction()
|
|
1195
1268
|
after = self.context_usage()
|
|
1196
|
-
|
|
1269
|
+
pct = before * 100 // self.usable_window
|
|
1270
|
+
if pct < 60:
|
|
1271
|
+
self._ctx_note_band = 0
|
|
1272
|
+
# above 60% the note came on EVERY step, even for 32 freed tokens (QA 2026-11-05: 11 lines in one
|
|
1273
|
+
# build); now a small clearing is shown once per 10% band
|
|
1274
|
+
if before - after >= 2000 or (pct >= 60 and pct // 10 > self._ctx_note_band):
|
|
1275
|
+
if before - after < 2000:
|
|
1276
|
+
self._ctx_note_band = pct // 10
|
|
1197
1277
|
self.ui.dim(f"Context was {before * 100 // self.usable_window}% full: cleared {len(ids)} tool "
|
|
1198
1278
|
f"output(s), freed {max(0, before - after):,} tokens, now "
|
|
1199
1279
|
f"{after * 100 // self.usable_window}%.")
|
|
@@ -1243,7 +1323,11 @@ class Agent:
|
|
|
1243
1323
|
if not manual:
|
|
1244
1324
|
context.elide_old_tool_outputs(self.messages, keep_last=1, min_chars=300)
|
|
1245
1325
|
self._persist_compaction()
|
|
1246
|
-
|
|
1326
|
+
if len(users) <= 1: # the first message itself is too big: /clear cannot help
|
|
1327
|
+
self.ui.warn("Your message alone is too large for the context. Put long text in a file and "
|
|
1328
|
+
"mention it with @file instead.")
|
|
1329
|
+
else:
|
|
1330
|
+
self.ui.warn("Nothing older to summarise; the current turn alone is large. Consider /clear.")
|
|
1247
1331
|
return False
|
|
1248
1332
|
old = self.messages[1:cut]
|
|
1249
1333
|
recent = self.messages[cut:]
|
|
@@ -1459,6 +1543,10 @@ class Agent:
|
|
|
1459
1543
|
self.pending_image_paths.append(p)
|
|
1460
1544
|
attachments.privacy_notice_once(self.ui)
|
|
1461
1545
|
return None
|
|
1546
|
+
from euron_code.permissions import is_sensitive
|
|
1547
|
+
if is_sensitive(p) and not self._consent_to_secret(p):
|
|
1548
|
+
return (f"(not attached: {self.ctx.rel(p)} looks like a secrets file, so its contents were not "
|
|
1549
|
+
"sent. Do not read it unless the user asks you to.)")
|
|
1462
1550
|
if documents.is_document(p):
|
|
1463
1551
|
return documents.extract(p, cap_tokens=cap)
|
|
1464
1552
|
data = p.read_bytes()[:8192]
|
|
@@ -1469,6 +1557,19 @@ class Agent:
|
|
|
1469
1557
|
except (ToolError, documents.DocumentError, OSError) as e:
|
|
1470
1558
|
return f"(could not attach: {e})"
|
|
1471
1559
|
|
|
1560
|
+
def _consent_to_secret(self, p: Path) -> bool:
|
|
1561
|
+
"""A .env / key / credentials file named in a prompt (typed or dragged, @ or a bare absolute
|
|
1562
|
+
path) is only sent after a yes. QA 2026-11-05: "the values are in C:\\Users\\me\\app\\.env" uploaded
|
|
1563
|
+
the file's contents without a question, in default mode and from outside the project."""
|
|
1564
|
+
rel = self.ctx.rel(p)
|
|
1565
|
+
if not getattr(self.ui, "interactive", False):
|
|
1566
|
+
self.ui.warn(f"Not attached: {rel} looks like a secrets file (its contents would be sent with your "
|
|
1567
|
+
"message). Attach it in an interactive session if you mean to.")
|
|
1568
|
+
return False
|
|
1569
|
+
choice = self.ui.ask_permission(f"Attach {rel}? It looks like a secrets file: its contents are sent with "
|
|
1570
|
+
"your message.", None, [("y", "yes"), ("n", "no")])
|
|
1571
|
+
return choice == "y"
|
|
1572
|
+
|
|
1472
1573
|
# ---------------------------------------------------------- sub-agent
|
|
1473
1574
|
def run_subagent(self, prompt: str) -> str:
|
|
1474
1575
|
sub_ui = _SubagentUI(self.ui)
|
|
@@ -58,6 +58,10 @@ class AuthError(EuronError):
|
|
|
58
58
|
SIGNED_IN_ELSEWHERE_MESSAGE = ("You signed in to Euron Code on another device, so this one was signed out. "
|
|
59
59
|
"One login at a time: signing in on a new computer signs out the old one.")
|
|
60
60
|
SESSION_IN_USE_MESSAGE = "Euron Code is running for your account in another terminal or computer."
|
|
61
|
+
#: shown instead of the gateway's raw text, which named the model's context size and the serving
|
|
62
|
+
#: stack's parameters (QA 2026-11-05, a 100 KB prompt)
|
|
63
|
+
CONTEXT_TOO_LONG_MESSAGE = ("This request is too long for Euron Code to read in one go. Put long text in a file in "
|
|
64
|
+
"your project and mention it with @file (it is then read in parts), or split the request.")
|
|
61
65
|
|
|
62
66
|
|
|
63
67
|
class NotEntitledError(EuronError):
|
|
@@ -208,7 +212,9 @@ def error_from_body(status: int, body: Any, retry_after: Optional[float] = None)
|
|
|
208
212
|
if status == 413 or code == "context_length_exceeded" or (
|
|
209
213
|
status == 400 and "context length" in message.lower()
|
|
210
214
|
):
|
|
211
|
-
|
|
215
|
+
e = ContextTooLongError(CONTEXT_TOO_LONG_MESSAGE, **kw)
|
|
216
|
+
e.detail = message # the gateway's own text, never shown
|
|
217
|
+
return e
|
|
212
218
|
if status == 503:
|
|
213
219
|
return BusyError(message, **kw)
|
|
214
220
|
if status == 429:
|
|
@@ -259,7 +265,20 @@ class Client:
|
|
|
259
265
|
follow_redirects=False,
|
|
260
266
|
)
|
|
261
267
|
|
|
268
|
+
def release_session(self) -> None:
|
|
269
|
+
"""Free the one-login lock on exit, so the next run on this computer is not told
|
|
270
|
+
"already running" for two minutes. Best effort: a few seconds at most, never raises."""
|
|
271
|
+
sid = self.session_id
|
|
272
|
+
if not (self.api_key and valid_id(sid)):
|
|
273
|
+
return
|
|
274
|
+
try:
|
|
275
|
+
self._http.post(f"{self.base_url}/v1/session/release", timeout=3.0,
|
|
276
|
+
headers={**self._headers(), "X-Euron-Session": str(sid)})
|
|
277
|
+
except Exception:
|
|
278
|
+
pass
|
|
279
|
+
|
|
262
280
|
def close(self) -> None:
|
|
281
|
+
self.release_session()
|
|
263
282
|
self._http.close()
|
|
264
283
|
|
|
265
284
|
def _headers(self) -> Dict[str, str]:
|
|
@@ -27,11 +27,27 @@ def _fix_windows_console() -> None:
|
|
|
27
27
|
for stream in (sys.stdout, sys.stderr):
|
|
28
28
|
try:
|
|
29
29
|
if stream and hasattr(stream, "reconfigure") and (stream.encoding or "").lower() not in ("utf-8", "utf8"):
|
|
30
|
-
stream.
|
|
30
|
+
if not stream.isatty():
|
|
31
|
+
# redirected to a file or pipe: write UTF-8. With the legacy code page, Hindi and emoji
|
|
32
|
+
# answers came out as "???" (QA 2026-11-05: `euron -p ... > out.txt` lost every character)
|
|
33
|
+
stream.reconfigure(encoding="utf-8", errors="replace")
|
|
34
|
+
else:
|
|
35
|
+
stream.reconfigure(errors="replace")
|
|
31
36
|
except Exception:
|
|
32
37
|
pass
|
|
33
38
|
|
|
34
39
|
|
|
40
|
+
def _positive_int(raw: str) -> int:
|
|
41
|
+
"""--max-turns: 0 silently meant "no limit" and -1 stopped before the first step."""
|
|
42
|
+
try:
|
|
43
|
+
n = int(raw)
|
|
44
|
+
except ValueError:
|
|
45
|
+
raise argparse.ArgumentTypeError(f"expected a whole number, got {raw!r}") from None
|
|
46
|
+
if n < 1:
|
|
47
|
+
raise argparse.ArgumentTypeError("must be 1 or more")
|
|
48
|
+
return n
|
|
49
|
+
|
|
50
|
+
|
|
35
51
|
def main_parser() -> argparse.ArgumentParser:
|
|
36
52
|
p = argparse.ArgumentParser(
|
|
37
53
|
prog="euron",
|
|
@@ -43,7 +59,7 @@ def main_parser() -> argparse.ArgumentParser:
|
|
|
43
59
|
p.add_argument("-p", "--print", dest="print_mode", action="store_true",
|
|
44
60
|
help="Non-interactive: run the prompt, print the result, exit.")
|
|
45
61
|
p.add_argument("--output-format", choices=["text", "json"], default="text", help="With -p: text or json.")
|
|
46
|
-
p.add_argument("--max-turns", type=
|
|
62
|
+
p.add_argument("--max-turns", type=_positive_int, default=None, help="Maximum model steps for this run.")
|
|
47
63
|
p.add_argument("--model", default=None, help="Model id (default: your account's default model).")
|
|
48
64
|
p.add_argument("-c", "--continue", dest="cont", action="store_true", help="Continue the latest session in this folder.")
|
|
49
65
|
p.add_argument("-r", "--resume", nargs="?", const="", default=None, metavar="ID",
|
|
@@ -280,15 +296,26 @@ def run_config(ui, args) -> int:
|
|
|
280
296
|
# ---------------------------------------------------------------- main session
|
|
281
297
|
|
|
282
298
|
def run_main(args) -> int:
|
|
283
|
-
from euron_code.agent import Agent
|
|
284
|
-
from euron_code.permissions import Permissions
|
|
285
|
-
|
|
286
299
|
root = config.find_project_root()
|
|
287
300
|
settings = config.load_settings(root)
|
|
288
301
|
if args.print_mode:
|
|
289
302
|
ui = QuietUI(verbose=args.verbose)
|
|
290
303
|
else:
|
|
291
304
|
ui = UI(reasoning=str(settings.get("reasoning") or "collapse"))
|
|
305
|
+
code = _run_session(args, ui, root, settings)
|
|
306
|
+
if (args.print_mode and args.output_format == "json" and code != EXIT_OK
|
|
307
|
+
and not getattr(ui, "result_written", False)):
|
|
308
|
+
# a failure before the turn started (no prompt, not logged in, unreachable): scripts still get JSON
|
|
309
|
+
msg = getattr(ui, "last_error", None) or "Euron Code could not start."
|
|
310
|
+
sys.stdout.write(json.dumps({"type": "result", "subtype": "error", "is_error": True, "result": "",
|
|
311
|
+
"exit_code": code, "error": {"message": msg}}, ensure_ascii=False) + "\n")
|
|
312
|
+
sys.stdout.flush()
|
|
313
|
+
return code
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _run_session(args, ui, root, settings) -> int:
|
|
317
|
+
from euron_code.agent import Agent
|
|
318
|
+
from euron_code.permissions import Permissions
|
|
292
319
|
|
|
293
320
|
prompt = args.prompt
|
|
294
321
|
if args.print_mode:
|
|
@@ -491,7 +518,7 @@ def run_print(agent, ui, client, prompt: str, args, session) -> int:
|
|
|
491
518
|
"type": "result",
|
|
492
519
|
"subtype": "success" if code == 0 else ("error_max_turns" if code == EXIT_MAX_TURNS else "error"),
|
|
493
520
|
"is_error": code != 0,
|
|
494
|
-
"result": res.text,
|
|
521
|
+
"result": (res.text or "").lstrip("\r\n"),
|
|
495
522
|
"stop_reason": res.stop_reason,
|
|
496
523
|
"num_turns": res.turns,
|
|
497
524
|
"session_id": session.id,
|
|
@@ -510,8 +537,9 @@ def run_print(agent, ui, client, prompt: str, args, session) -> int:
|
|
|
510
537
|
out["error"] = {"code": err.code, "status": err.status, "message": message}
|
|
511
538
|
sys.stdout.write(json.dumps(out, ensure_ascii=False) + "\n")
|
|
512
539
|
else:
|
|
513
|
-
if res.text:
|
|
514
|
-
sys.stdout.write(res.text.rstrip() + "\n")
|
|
540
|
+
if res.text and res.text.strip():
|
|
541
|
+
sys.stdout.write(res.text.strip("\r\n").rstrip() + "\n") # no leading blank lines before the answer
|
|
542
|
+
ui.result_written = True
|
|
515
543
|
sys.stdout.flush()
|
|
516
544
|
return code
|
|
517
545
|
|
|
@@ -177,6 +177,9 @@ DESTRUCTIVE = [re.compile(p, re.IGNORECASE) for p in (
|
|
|
177
177
|
r"\bmkfs(\.\w+)?\b", r"\bdd\s+if=", r"\b(shutdown|reboot|halt|poweroff)\b", r"\bformat\s+[a-z]:",
|
|
178
178
|
r"\b(del|erase)\s+(/[a-z]\s+)*/s\b", r"\b(rd|rmdir)\s+(/[a-z]\s+)*/s\b",
|
|
179
179
|
r"\bRemove-Item\b.*-Recurse\b.*(?:~|\$HOME|\$env:USERPROFILE|[A-Za-z]:\\?\s*$|\s/\s*$)",
|
|
180
|
+
# the home folder named BEFORE -Recurse as well (QA 2026-11-05: `Remove-Item -Path "$env:USERPROFILE\x"
|
|
181
|
+
# -Recurse -Force` deleted a folder outside the project in bypass mode)
|
|
182
|
+
r"\bRemove-Item\b.*(?:~|\$HOME|\$env:USERPROFILE|%USERPROFILE%).*\s-Recurse\b",
|
|
180
183
|
r"\bchmod\s+-R\s+0?777\b", r":\(\)\s*\{\s*:\|:&\s*\};", r"\bdrop\s+(database|schema)\b",
|
|
181
184
|
r"\b(curl|wget|iwr|irm|Invoke-WebRequest|Invoke-RestMethod)\b[^|;]*\|\s*(sudo\s+)?(ba|z)?sh\b",
|
|
182
185
|
r"\b(curl|wget|iwr|irm|Invoke-WebRequest|Invoke-RestMethod)\b[^|;]*\|\s*(iex|Invoke-Expression|python3?)\b",
|
|
@@ -204,6 +207,9 @@ def outside_paths(command: str, roots: List[Path]) -> List[str]:
|
|
|
204
207
|
for seg in _segments(command.replace(">", " ").replace("<", " ")):
|
|
205
208
|
for tok in _tokens(seg):
|
|
206
209
|
for part in [tok] + tok.split("=")[1:]:
|
|
210
|
+
# shlex keeps the quotes on Windows (posix=False), so a quoted path ("C:\x", "$env:USERPROFILE\x")
|
|
211
|
+
# was never seen as a path and skipped this guard (QA 2026-11-05)
|
|
212
|
+
part = part.strip().strip('"').strip("'")
|
|
207
213
|
if not _PATHLIKE.match(part) or (os.name == "nt" and _WIN_SWITCH.match(part)):
|
|
208
214
|
continue
|
|
209
215
|
if part.startswith("/dev/") or part in ("/dev/null", "NUL"):
|
|
@@ -71,16 +71,25 @@ Do not invent anything. Do not call tools. Plain text or Markdown, at most about
|
|
|
71
71
|
PLACEHOLDER_NUDGE = ("These files still contain placeholder text: {where}. Replace it with the details the user gave "
|
|
72
72
|
"(name, photo, email, links). If the user did not give a detail, remove it or ask for it.")
|
|
73
73
|
|
|
74
|
+
NOTICE_NUDGE = ("Your files say they reuse code from {repos}, but THIRD_PARTY_NOTICES.md has no entry for it. "
|
|
75
|
+
"Record each one with add_third_party_notice (source, licence, the files that use it), then finish. "
|
|
76
|
+
"If you did not actually use that code, remove the attribution comment instead.")
|
|
77
|
+
|
|
74
78
|
BUILD_NOTE = ("[Build request: start with find_reusable (split it into 1-8 components) and follow its "
|
|
75
79
|
"REUSE / ADAPT / GENERATE decisions before writing code.]")
|
|
76
80
|
|
|
77
81
|
CONTINUE_NUDGE = "Continue exactly where you stopped. Do not repeat what you already wrote."
|
|
78
82
|
|
|
83
|
+
TODO_NUDGE = ("You stopped, but your task list is not finished: {todos}. Continue now with the next item. If you "
|
|
84
|
+
"cannot finish it, say exactly what is left and why.")
|
|
85
|
+
|
|
79
86
|
CUT_TOOL_NUDGE = ("Your last tool call was cut off by the output limit, so it was not run. Make the change in smaller "
|
|
80
87
|
"steps: use edit_file for targeted edits, or write the file in parts (create it with the first part, "
|
|
81
88
|
"then add the rest with edit_file).")
|
|
82
89
|
|
|
83
90
|
ANSWER_NUDGE = "Answer now, briefly, with what you have. Do not deliberate further."
|
|
91
|
+
EMPTY_NUDGE = ("Your last reply was empty. Reply to the user now: answer the request, or say briefly what "
|
|
92
|
+
"you did and what is left.")
|
|
84
93
|
|
|
85
94
|
|
|
86
95
|
INIT_PROMPT = """Write (or improve) EURON.md at the project root: the memory file Euron Code reads at the start of every session. Keep it short (under ~70 lines): every line costs tokens in every future request.
|
|
@@ -40,21 +40,26 @@ UI_WORDS = re.compile(r"\b(website|site|portfolio|landing|page|pages|ui|frontend
|
|
|
40
40
|
r"resume|cv|shop|store|form|react|next\.?js|vue|svelte|html|css|tailwind)\b", re.I)
|
|
41
41
|
# (component name, keyword pattern, query); UI queries get "react tsx component" so Euron's TS/TSX kits match
|
|
42
42
|
_PARTS = [
|
|
43
|
-
("
|
|
44
|
-
|
|
45
|
-
("
|
|
46
|
-
("
|
|
47
|
-
("
|
|
48
|
-
("
|
|
49
|
-
("
|
|
50
|
-
("
|
|
51
|
-
("
|
|
52
|
-
("
|
|
53
|
-
|
|
54
|
-
("
|
|
55
|
-
|
|
56
|
-
("
|
|
57
|
-
|
|
43
|
+
# whole words only (QA 2026-11-05: "rag" in "storage" gave a todo API a chat component; "orm" in "form",
|
|
44
|
+
# "rest" in "interest", "work" in "framework", "auth" in "author", "form" in "platform" did the same)
|
|
45
|
+
("hero", r"\b(portfolio|landing|personal|resume|cv|about me|profile)s?\b", "hero section with name photo and intro"),
|
|
46
|
+
("projects", r"\b(portfolio|projects?|work|case stud(y|ies))\b", "projects grid cards"),
|
|
47
|
+
("navbar", r"\b(website|site|portfolio|landing|page|blog|shop|store|dashboard)s?\b", "responsive navbar"),
|
|
48
|
+
("contact", r"\b(contact|portfolio|landing|email|form)s?\b", "contact form"),
|
|
49
|
+
("dashboard", r"\b(dashboard|admin|analytics|chart)s?\b", "dashboard layout charts cards"),
|
|
50
|
+
("blog", r"\b(blog|posts|articles)\b", "blog post list and markdown rendering"),
|
|
51
|
+
("cart", r"\b(shop|store|cart|checkout|e-?commerce)\b", "shopping cart and checkout"),
|
|
52
|
+
("auth", r"\b(login|signup|sign up|sign in|auth|authentication|authenticate|authorization|jwt|oauth|accounts?)\b",
|
|
53
|
+
"login signup jwt authentication"),
|
|
54
|
+
("api", r"\b(api|apis|backend|server|crud|endpoints?|rest|restful|fastapi|flask|django|express)\b",
|
|
55
|
+
"rest api crud endpoints"),
|
|
56
|
+
("database", r"\b(database|db|postgres|postgresql|sqlite|sqlite3|mongo|mongodb|prisma|orm|crud)\b",
|
|
57
|
+
"database models and queries"),
|
|
58
|
+
("payments", r"\b(payments?|razorpay|stripe|subscriptions?)\b", "payment integration"),
|
|
59
|
+
("chat", r"\b(chat|chatbot|bot|assistant|llm|rag)s?\b", "chat ui and llm integration"),
|
|
60
|
+
("upload", r"\b(uploads?|uploading|file storage|s3|image upload)\b", "file upload"),
|
|
61
|
+
("scraper", r"\b(scrap\w*|crawl\w*)\b", "web scraper"),
|
|
62
|
+
("cli", r"\b(cli|command[- ]line|terminal tool)\b", "command line interface"),
|
|
58
63
|
]
|
|
59
64
|
|
|
60
65
|
|
|
@@ -186,6 +191,12 @@ def _indexed(tier: Any) -> bool:
|
|
|
186
191
|
return False
|
|
187
192
|
|
|
188
193
|
|
|
194
|
+
#: QA 2026-11-05: the engine marked an unrelated test file REUSE (0.92) for a word-count CLI; the model saw it was
|
|
195
|
+
#: unrelated, wrote its own code, and still added a licence header and a THIRD_PARTY_NOTICES entry for it.
|
|
196
|
+
UNRELATED_NOTE = (" If those files turn out to be unrelated to this component, write it yourself instead and add "
|
|
197
|
+
"no licence header or notice for them.")
|
|
198
|
+
|
|
199
|
+
|
|
189
200
|
def instructions(c: Dict[str, Any]) -> str:
|
|
190
201
|
cand = c.get("candidate") or {}
|
|
191
202
|
d = c.get("decision")
|
|
@@ -195,10 +206,10 @@ def instructions(c: Dict[str, Any]) -> str:
|
|
|
195
206
|
if d == "REUSE":
|
|
196
207
|
return (f"- {c['name']}: REUSE {repo} {', '.join(paths[:4])} - read them with {tool}, integrate with "
|
|
197
208
|
"minimal edits, keep the licence header; once its files are written, record it once with "
|
|
198
|
-
"add_third_party_notice.")
|
|
209
|
+
"add_third_party_notice." + UNRELATED_NOTE)
|
|
199
210
|
if d == "ADAPT":
|
|
200
211
|
return (f"- {c['name']}: ADAPT {repo} {', '.join(paths[:4])} - read with {tool}, then modify it to fit; keep "
|
|
201
|
-
"the attribution.")
|
|
212
|
+
"the attribution." + UNRELATED_NOTE)
|
|
202
213
|
return f"- {c['name']}: GENERATE - write it yourself ({c.get('reason') or 'no suitable reusable code'})."
|
|
203
214
|
|
|
204
215
|
|
|
@@ -149,7 +149,7 @@ class OssRead(Tool):
|
|
|
149
149
|
|
|
150
150
|
class AddThirdPartyNotice(Tool):
|
|
151
151
|
name = "add_third_party_notice"
|
|
152
|
-
description = 'Record a
|
|
152
|
+
description = 'Record a source you copied or adapted code from in THIRD_PARTY_NOTICES.md (once per repo).'
|
|
153
153
|
parameters = {
|
|
154
154
|
"type": "object",
|
|
155
155
|
"properties": {
|
|
@@ -324,6 +324,16 @@ class QuietUI(UI):
|
|
|
324
324
|
def __init__(self, verbose: bool = False):
|
|
325
325
|
super().__init__(reasoning="hide", stderr=True)
|
|
326
326
|
self.verbose = verbose
|
|
327
|
+
self.last_error: Optional[str] = None # for the -p JSON result when the run cannot start
|
|
328
|
+
self.result_written = False
|
|
329
|
+
|
|
330
|
+
def error(self, msg: str) -> None:
|
|
331
|
+
self.last_error = msg
|
|
332
|
+
super().error(msg)
|
|
333
|
+
|
|
334
|
+
def exact(self, msg: str) -> None:
|
|
335
|
+
self.last_error = msg
|
|
336
|
+
super().exact(msg)
|
|
327
337
|
|
|
328
338
|
def reasoning(self, text: str) -> None:
|
|
329
339
|
pass
|
|
@@ -361,8 +371,11 @@ class QuietUI(UI):
|
|
|
361
371
|
time.sleep(min(0.1, max(0.0, end - time.monotonic())))
|
|
362
372
|
|
|
363
373
|
def ask_permission(self, title, detail, options) -> str:
|
|
364
|
-
|
|
365
|
-
|
|
374
|
+
# even --yolo asks for paths outside the project and destructive commands: say so instead of
|
|
375
|
+
# suggesting --yolo to someone who already used it (QA 2026-11-05)
|
|
376
|
+
self.console.print(Text(f"Denied (non-interactive): {title} Edits and commands inside the project can be "
|
|
377
|
+
"allowed with --allowed-tools or --yolo; paths outside the project and destructive "
|
|
378
|
+
"commands need a yes in an interactive session.", style="yellow"))
|
|
366
379
|
return "n"
|
|
367
380
|
|
|
368
381
|
|
|
@@ -36,8 +36,13 @@ Error scenarios are chosen by a tag in the user's message, e.g. "[mock:rate]":
|
|
|
36
36
|
erie ERIE: find_reusable (slugify REUSE, money ADAPT, cli GENERATE), reads the pinned files, writes a small
|
|
37
37
|
price-list app from them plus tests, records the notice, runs pytest, answers
|
|
38
38
|
slowtool runs `python -c "import time; time.sleep(N)"` (N from "[mock:slowtool N]"), then answers
|
|
39
|
+
todostop writes a 2-item task list, then stops on "Scaffolding the project now..."; finishes when nudged
|
|
40
|
+
todostopalways the same, but never finishes
|
|
39
41
|
disabled 503 code_agent_disabled
|
|
40
42
|
simple a plain text answer, no tools
|
|
43
|
+
emptyonce an empty answer (no text, no tools) until the CLI asks again, then two blank lines and "ok"
|
|
44
|
+
emptyalways an empty answer every time
|
|
45
|
+
hindi a Hindi word and an emoji
|
|
41
46
|
longtask reads big.txt 400 lines at a time until the end (to force auto-compaction)
|
|
42
47
|
(no tag) the default edit script on calc.py / test_calc.py
|
|
43
48
|
|
|
@@ -316,6 +321,23 @@ def script_reply(scenario: str, messages: List[Dict[str, Any]], body: Optional[D
|
|
|
316
321
|
"start": 1, "end": 5})]}
|
|
317
322
|
return {"reasoning": "", "content": "Found Euron's hybrid_search; I will adapt it and keep the MIT notice.",
|
|
318
323
|
"tool_calls": []}
|
|
324
|
+
if scenario == "nonotice": # QA 2026-11-05: adapted code with a header, but no THIRD_PARTY_NOTICES entry
|
|
325
|
+
if "THIRD_PARTY_NOTICES.md has no entry" in last_user and last is None:
|
|
326
|
+
return {"reasoning": "", "content": "", "tool_calls": [_tool_call(
|
|
327
|
+
"add_third_party_notice", {"source": "euron-sde-team/euron-rag-kit", "license": "MIT",
|
|
328
|
+
"files": "retrieve.py"})]}
|
|
329
|
+
if last is None:
|
|
330
|
+
return {"reasoning": "", "content": "", "tool_calls": [_tool_call(
|
|
331
|
+
"euron_reuse_read", {"repo": "euron-sde-team/euron-rag-kit", "path": "src/rag/retrieve.py",
|
|
332
|
+
"start": 1, "end": 5})]}
|
|
333
|
+
if last == "euron_reuse_read":
|
|
334
|
+
return {"reasoning": "", "content": "", "tool_calls": [_tool_call(
|
|
335
|
+
"write_file", {"path": "retrieve.py", "content": "# Adapted from euron-sde-team/euron-rag-kit (MIT)\n"
|
|
336
|
+
"def hybrid_search(q):\n return [q]\n"})]}
|
|
337
|
+
if last == "write_file":
|
|
338
|
+
return {"reasoning": "", "content": "", "tool_calls": [_tool_call(
|
|
339
|
+
"bash", {"command": "python -c \"import retrieve\"", "description": "Check it imports"})]}
|
|
340
|
+
return {"reasoning": "", "content": "Adapted hybrid_search into retrieve.py.", "tool_calls": []}
|
|
319
341
|
if scenario == "noverify": # edits, then answers without running anything
|
|
320
342
|
if last_user.startswith("You changed files but did not run anything"):
|
|
321
343
|
return {"reasoning": "", "content": "I could not run tests here; I re-read the change and it is correct.",
|
|
@@ -341,10 +363,31 @@ def script_reply(scenario: str, messages: List[Dict[str, Any]], body: Optional[D
|
|
|
341
363
|
if (body or {}).get("reasoning_effort") == "low":
|
|
342
364
|
return {"reasoning": "short thought", "content": "Answer after less thinking.", "tool_calls": []}
|
|
343
365
|
return {"reasoning": "thinking " * 200, "content": "", "tool_calls": [], "finish": "length"}
|
|
366
|
+
if scenario == "hindi":
|
|
367
|
+
return {"reasoning": "short thought", "content": "चार \U0001F3AF", "tool_calls": []}
|
|
368
|
+
if scenario in ("emptyonce", "emptyalways"):
|
|
369
|
+
# the live model sometimes ends a turn with no text at all (QA 2026-11-05)
|
|
370
|
+
nudged = any("Your last reply was empty" in str(m.get("content") or "") for m in messages
|
|
371
|
+
if m.get("role") == "user")
|
|
372
|
+
if scenario == "emptyonce" and nudged:
|
|
373
|
+
return {"reasoning": "short thought", "content": "\n\nok", "tool_calls": []}
|
|
374
|
+
return {"reasoning": "short thought", "content": "", "tool_calls": []}
|
|
344
375
|
if scenario == "simple" or scenario == "rate" or scenario == "busy" or scenario == "queue_full" \
|
|
345
376
|
or scenario == "ctx" or scenario == "keepalive" or scenario == "queued" or scenario == "noimages":
|
|
346
377
|
return {"reasoning": REASONING_TEXT, "content": "Hello from the MOCK gateway. Nothing to change.",
|
|
347
378
|
"tool_calls": []}
|
|
379
|
+
if scenario in ("todostop", "todostopalways"): # QA 2026-11-05: plans, then stops on "doing it now..."
|
|
380
|
+
if last_user.startswith("You stopped, but your task list is not finished") and scenario == "todostop":
|
|
381
|
+
if last is None:
|
|
382
|
+
return {"reasoning": "", "content": "", "tool_calls": [_tool_call("todo_write", {"todos": [
|
|
383
|
+
{"content": "scaffold the app", "status": "completed"},
|
|
384
|
+
{"content": "write the hero", "status": "completed"}]})]}
|
|
385
|
+
return {"reasoning": "", "content": "Built the app.", "tool_calls": []}
|
|
386
|
+
if last is None and "task list is not finished" not in last_user:
|
|
387
|
+
return {"reasoning": "", "content": "", "tool_calls": [_tool_call("todo_write", {"todos": [
|
|
388
|
+
{"content": "scaffold the app", "status": "in_progress"},
|
|
389
|
+
{"content": "write the hero", "status": "pending"}]})]}
|
|
390
|
+
return {"reasoning": "", "content": "Scaffolding the project now...", "tool_calls": []}
|
|
348
391
|
if scenario == "slowtool":
|
|
349
392
|
if last is None:
|
|
350
393
|
secs = re.findall(r"\[mock:slowtool (\d+(?:\.\d+)?)\]", " ".join(
|