euron-coding-agent 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.
@@ -0,0 +1,9 @@
1
+ """Euron Agent — a lightweight, provider-agnostic agentic coding backend.
2
+
3
+ Public surface:
4
+ from euron_agent.config import load_config
5
+ from euron_agent.llm import build_client
6
+ from euron_agent.loop import AgentSession
7
+ """
8
+
9
+ __version__ = "0.1.0"
@@ -0,0 +1,7 @@
1
+ """Enable `python -m euron_agent ...` (used by the VS Code auto-start)."""
2
+ import sys
3
+
4
+ from .cli import main
5
+
6
+ if __name__ == "__main__":
7
+ sys.exit(main())
euron_agent/cli.py ADDED
@@ -0,0 +1,286 @@
1
+ """Command-line interface.
2
+
3
+ euron-agent run "add a /health route to app.py" # one-shot in cwd
4
+ euron-agent chat # interactive REPL
5
+ euron-agent serve # start the API server
6
+ euron-agent providers # list configured providers
7
+ euron-agent init # scaffold config.yaml/.env
8
+
9
+ The CLI uses the very same AgentSession/loop as the VS Code backend, so the
10
+ terminal experience and the editor experience are identical.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import asyncio
16
+ import os
17
+ import shutil
18
+ import sys
19
+ from pathlib import Path
20
+
21
+ from rich.console import Console
22
+ from rich.panel import Panel
23
+ from rich.prompt import Prompt
24
+
25
+ from .config import load_config
26
+ from .events import AgentIO, ApprovalDecision
27
+ from .loop import AgentSession
28
+
29
+
30
+ def _force_utf8() -> None:
31
+ """Avoid UnicodeEncodeError for box-drawing/emoji on Windows code pages."""
32
+ for stream in (sys.stdout, sys.stderr):
33
+ try:
34
+ stream.reconfigure(encoding="utf-8", errors="replace") # type: ignore[attr-defined]
35
+ except Exception:
36
+ pass
37
+
38
+
39
+ _force_utf8()
40
+ # legacy_windows=False -> use ANSI (Windows 10+ supports it) instead of the
41
+ # win32 console API, which encodes with the active code page and chokes on '●'.
42
+ console = Console(legacy_windows=False)
43
+
44
+
45
+ # --------------------------------------------------------------------------- #
46
+ # Terminal IO
47
+ # --------------------------------------------------------------------------- #
48
+ class TerminalIO(AgentIO):
49
+ def __init__(self, auto_approve: bool):
50
+ self.auto_approve = auto_approve
51
+ self._dirty = False # unfinished streamed line on screen
52
+
53
+ def _newline_if_dirty(self) -> None:
54
+ if self._dirty:
55
+ sys.stdout.write("\n")
56
+ sys.stdout.flush()
57
+ self._dirty = False
58
+
59
+ # streamed tokens (may arrive from a worker thread)
60
+ def on_token(self, text: str) -> None:
61
+ sys.stdout.write(text)
62
+ sys.stdout.flush()
63
+ self._dirty = True
64
+
65
+ async def emit(self, event: dict) -> None:
66
+ t = event["type"]
67
+ if t == "status":
68
+ return # keep the terminal quiet; spinners would fight streaming
69
+ if t == "assistant_message":
70
+ # text already streamed via tokens; just close the line
71
+ self._newline_if_dirty()
72
+ return
73
+ self._newline_if_dirty()
74
+ if t == "tool_start":
75
+ args = event["args"]
76
+ detail = args.get("path") or args.get("command") or args.get("query") or ""
77
+ console.print(f"[cyan]⚙ {event['name']}[/cyan] [dim]{detail}[/dim]")
78
+ elif t == "diff":
79
+ self._print_diff(event["patch"])
80
+ elif t == "tool_result":
81
+ mark = "[green]✓[/green]" if event["ok"] else "[red]✗[/red]"
82
+ out = (event["output"] or "").strip()
83
+ if out:
84
+ snippet = out if len(out) < 1200 else out[:1200] + " …"
85
+ console.print(f"{mark} [dim]{snippet}[/dim]")
86
+ elif t == "error":
87
+ console.print(f"[red]error:[/red] {event['message']}")
88
+ elif t == "done":
89
+ console.print("[dim]— done —[/dim]")
90
+
91
+ def _print_diff(self, patch: str) -> None:
92
+ for line in patch.splitlines():
93
+ if line.startswith("+") and not line.startswith("+++"):
94
+ console.print(f"[green]{line}[/green]")
95
+ elif line.startswith("-") and not line.startswith("---"):
96
+ console.print(f"[red]{line}[/red]")
97
+ elif line.startswith("@@"):
98
+ console.print(f"[magenta]{line}[/magenta]")
99
+ else:
100
+ console.print(f"[dim]{line}[/dim]")
101
+
102
+ async def request_approval(self, request: dict) -> ApprovalDecision:
103
+ self._newline_if_dirty()
104
+ preview = request.get("preview") or ""
105
+ title = f"Approve {request['name']}?"
106
+ if preview:
107
+ self._print_diff(preview) if "\n" in preview and (
108
+ "+++" in preview or "@@" in preview
109
+ ) else console.print(Panel(preview, title=title, border_style="yellow"))
110
+ if self.auto_approve:
111
+ console.print("[green]auto-approved[/green]")
112
+ return ApprovalDecision(approved=True)
113
+
114
+ answer = await asyncio.to_thread(
115
+ Prompt.ask,
116
+ f"[yellow]{title}[/yellow] (y/n, or type feedback to reject)",
117
+ default="y",
118
+ )
119
+ a = answer.strip().lower()
120
+ if a in ("y", "yes", ""):
121
+ return ApprovalDecision(approved=True)
122
+ if a in ("n", "no"):
123
+ return ApprovalDecision(approved=False, feedback="rejected by user")
124
+ return ApprovalDecision(approved=False, feedback=answer.strip())
125
+
126
+
127
+ # --------------------------------------------------------------------------- #
128
+ # Commands
129
+ # --------------------------------------------------------------------------- #
130
+ async def _run_task(task: str, args) -> None:
131
+ cfg = load_config(args.config, provider=args.provider, model=args.model)
132
+ if args.yes:
133
+ cfg.agent.auto_approve_writes = True
134
+ cfg.agent.auto_approve_commands = True
135
+ workspace = str(Path(args.workspace).resolve())
136
+ console.print(
137
+ f"[dim]workspace={workspace} · provider={cfg.provider.name} · "
138
+ f"model={cfg.provider.model}[/dim]"
139
+ )
140
+ io = TerminalIO(auto_approve=args.yes)
141
+ session = AgentSession(workspace, cfg, io)
142
+ await session.run(task)
143
+
144
+
145
+ def cmd_run(args) -> None:
146
+ asyncio.run(_run_task(args.task, args))
147
+
148
+
149
+ async def _chat(args) -> None:
150
+ cfg = load_config(args.config, provider=args.provider, model=args.model)
151
+ if args.yes:
152
+ cfg.agent.auto_approve_writes = True
153
+ cfg.agent.auto_approve_commands = True
154
+ workspace = str(Path(args.workspace).resolve())
155
+ io = TerminalIO(auto_approve=args.yes)
156
+ session = AgentSession(workspace, cfg, io) # one session => memory across turns
157
+ console.print(
158
+ Panel(
159
+ f"Euron Agent · [bold]{cfg.provider.name}[/bold] / {cfg.provider.model}\n"
160
+ f"workspace: {workspace}\n"
161
+ "Type a task. Commands: /exit, /reset, /yes (toggle auto-approve).",
162
+ border_style="cyan",
163
+ )
164
+ )
165
+ while True:
166
+ try:
167
+ msg = await asyncio.to_thread(Prompt.ask, "[bold cyan]you[/bold cyan]")
168
+ except (EOFError, KeyboardInterrupt):
169
+ break
170
+ msg = msg.strip()
171
+ if not msg:
172
+ continue
173
+ if msg in ("/exit", "/quit"):
174
+ break
175
+ if msg == "/reset":
176
+ session.messages.clear()
177
+ console.print("[dim]context cleared[/dim]")
178
+ continue
179
+ if msg == "/yes":
180
+ io.auto_approve = not io.auto_approve
181
+ console.print(f"[dim]auto-approve = {io.auto_approve}[/dim]")
182
+ continue
183
+ await session.run(msg)
184
+ console.print("[dim]bye[/dim]")
185
+
186
+
187
+ def cmd_chat(args) -> None:
188
+ asyncio.run(_chat(args))
189
+
190
+
191
+ def cmd_serve(args) -> None:
192
+ from .server import serve
193
+
194
+ console.print(f"[cyan]Euron Agent server[/cyan] on http://{args.host}:{args.port}")
195
+ serve(host=args.host, port=args.port, reload=args.reload)
196
+
197
+
198
+ def cmd_providers(args) -> None:
199
+ from rich.table import Table
200
+
201
+ cfg = load_config(args.config)
202
+ table = Table(title="Configured providers")
203
+ table.add_column("name")
204
+ table.add_column("active")
205
+ table.add_column("type")
206
+ table.add_column("model")
207
+ table.add_column("base_url")
208
+ for name, p in cfg.all_providers.items():
209
+ table.add_row(
210
+ name,
211
+ "●" if name == cfg.provider.name else "",
212
+ p.type,
213
+ p.model,
214
+ p.base_url or "(default)",
215
+ )
216
+ console.print(table)
217
+
218
+
219
+ def cmd_init(args) -> None:
220
+ backend = Path(__file__).resolve().parent.parent
221
+ pairs = [("config.example.yaml", "config.yaml"), (".env.example", ".env")]
222
+ for src, dst in pairs:
223
+ dst_path = Path.cwd() / dst
224
+ src_path = backend / src
225
+ if dst_path.exists():
226
+ console.print(f"[yellow]skip[/yellow] {dst} already exists")
227
+ elif src_path.exists():
228
+ shutil.copy(src_path, dst_path)
229
+ console.print(f"[green]created[/green] {dst}")
230
+ else:
231
+ console.print(f"[red]missing template[/red] {src}")
232
+ console.print("Edit config.yaml (pick a provider) and .env (add your key).")
233
+
234
+
235
+ # --------------------------------------------------------------------------- #
236
+ # Parser
237
+ # --------------------------------------------------------------------------- #
238
+ def build_parser() -> argparse.ArgumentParser:
239
+ p = argparse.ArgumentParser(prog="euron-agent", description="Euron coding agent.")
240
+ p.add_argument("--config", help="Path to config.yaml")
241
+ p.add_argument("--provider", help="Override active provider profile")
242
+ p.add_argument("--model", help="Override model id")
243
+ p.add_argument(
244
+ "--workspace", default=os.getcwd(), help="Workspace root (default: cwd)"
245
+ )
246
+ sub = p.add_subparsers(dest="command", required=True)
247
+
248
+ r = sub.add_parser("run", help="Run a single task and exit")
249
+ r.add_argument("task")
250
+ r.add_argument("--yes", "-y", action="store_true", help="Auto-approve all actions")
251
+ r.set_defaults(func=cmd_run)
252
+
253
+ c = sub.add_parser("chat", help="Interactive REPL")
254
+ c.add_argument("--yes", "-y", action="store_true", help="Auto-approve all actions")
255
+ c.set_defaults(func=cmd_chat)
256
+
257
+ s = sub.add_parser("serve", help="Start the FastAPI server")
258
+ s.add_argument("--host", default="127.0.0.1")
259
+ s.add_argument("--port", type=int, default=8000)
260
+ s.add_argument("--reload", action="store_true")
261
+ s.set_defaults(func=cmd_serve)
262
+
263
+ sub.add_parser("providers", help="List configured providers").set_defaults(
264
+ func=cmd_providers
265
+ )
266
+ sub.add_parser("init", help="Scaffold config.yaml and .env").set_defaults(
267
+ func=cmd_init
268
+ )
269
+ return p
270
+
271
+
272
+ def main(argv=None) -> int:
273
+ args = build_parser().parse_args(argv)
274
+ try:
275
+ args.func(args)
276
+ except KeyboardInterrupt:
277
+ console.print("\n[dim]interrupted[/dim]")
278
+ return 130
279
+ except Exception as e: # noqa: BLE001
280
+ console.print(f"[red]fatal:[/red] {type(e).__name__}: {e}")
281
+ return 1
282
+ return 0
283
+
284
+
285
+ if __name__ == "__main__":
286
+ sys.exit(main())
euron_agent/config.py ADDED
@@ -0,0 +1,203 @@
1
+ """Configuration loading and resolution.
2
+
3
+ Config is layered, in order of precedence (later wins):
4
+ 1. built-in defaults
5
+ 2. config.yaml (next to the package, the cwd, or $EURON_AGENT_CONFIG)
6
+ 3. environment variables (.env is loaded automatically)
7
+ 4. explicit overrides passed on the CLI / API call
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ from dataclasses import dataclass, field, replace
13
+ from pathlib import Path
14
+ from typing import Any, Optional
15
+
16
+ import yaml
17
+ from dotenv import load_dotenv
18
+
19
+ load_dotenv() # pull .env into os.environ if present
20
+
21
+
22
+ # --------------------------------------------------------------------------- #
23
+ # Dataclasses
24
+ # --------------------------------------------------------------------------- #
25
+ @dataclass
26
+ class ProviderConfig:
27
+ name: str
28
+ type: str = "openai" # "openai" (OpenAI-compatible) or "anthropic"
29
+ base_url: Optional[str] = None
30
+ api_key_env: Optional[str] = None
31
+ api_key: Optional[str] = None # resolved from api_key_env at load time
32
+ model: str = "gpt-4o-mini"
33
+ temperature: float = 0.2
34
+ max_tokens: int = 4096
35
+ extra_headers: dict = field(default_factory=dict)
36
+
37
+
38
+ @dataclass
39
+ class AgentConfig:
40
+ max_steps: int = 30
41
+ stream: bool = True
42
+ auto_approve_reads: bool = True
43
+ auto_approve_writes: bool = False
44
+ auto_approve_commands: bool = False
45
+ max_file_bytes: int = 120_000
46
+ max_command_seconds: int = 60
47
+
48
+
49
+ @dataclass
50
+ class Config:
51
+ provider: ProviderConfig
52
+ agent: AgentConfig
53
+ ignore: list[str] = field(default_factory=list)
54
+ all_providers: dict[str, ProviderConfig] = field(default_factory=dict)
55
+
56
+
57
+ DEFAULT_IGNORE = [
58
+ ".git/**", "node_modules/**", "__pycache__/**", ".venv/**", "venv/**",
59
+ "dist/**", "build/**", "*.lock", ".env", ".env.*",
60
+ ]
61
+
62
+ # Built-in provider profiles so the extension (and a fresh install with no
63
+ # config.yaml) works out of the box: the user just picks a provider and supplies
64
+ # a key. Any of these can be overridden by a user-defined provider of the same
65
+ # name in config.yaml.
66
+ BUILTIN_PROVIDERS: dict[str, dict] = {
67
+ "euri": {
68
+ "type": "openai",
69
+ "base_url": "https://api.euron.one/api/v1",
70
+ "api_key_env": "EURI_API_KEY",
71
+ "model": "gpt-4.1-mini",
72
+ },
73
+ "openai": {
74
+ "type": "openai",
75
+ "base_url": "https://api.openai.com/v1",
76
+ "api_key_env": "OPENAI_API_KEY",
77
+ "model": "gpt-4o-mini",
78
+ },
79
+ "openrouter": {
80
+ "type": "openai",
81
+ "base_url": "https://openrouter.ai/api/v1",
82
+ "api_key_env": "OPENROUTER_API_KEY",
83
+ "model": "openai/gpt-4o-mini",
84
+ },
85
+ "ollama": {
86
+ "type": "openai",
87
+ "base_url": "http://localhost:11434/v1",
88
+ "api_key_env": None,
89
+ "model": "qwen2.5-coder:7b",
90
+ },
91
+ "anthropic": {
92
+ "type": "anthropic",
93
+ "base_url": None,
94
+ "api_key_env": "ANTHROPIC_API_KEY",
95
+ "model": "claude-sonnet-4-6",
96
+ },
97
+ # Generic OpenAI-compatible endpoint; base_url/model supplied at runtime.
98
+ "custom": {
99
+ "type": "openai",
100
+ "base_url": "http://localhost:8001/v1",
101
+ "api_key_env": None,
102
+ "model": "local-model",
103
+ },
104
+ }
105
+
106
+
107
+ # --------------------------------------------------------------------------- #
108
+ # Loading
109
+ # --------------------------------------------------------------------------- #
110
+ def _candidate_paths(explicit: Optional[str]) -> list[Path]:
111
+ paths = []
112
+ if explicit:
113
+ paths.append(Path(explicit))
114
+ env = os.getenv("EURON_AGENT_CONFIG")
115
+ if env:
116
+ paths.append(Path(env))
117
+ paths.append(Path.cwd() / "config.yaml")
118
+ paths.append(Path(__file__).resolve().parent.parent / "config.yaml")
119
+ return paths
120
+
121
+
122
+ def _find_config_file(explicit: Optional[str]) -> Optional[Path]:
123
+ for p in _candidate_paths(explicit):
124
+ if p and p.is_file():
125
+ return p
126
+ return None
127
+
128
+
129
+ def _provider_from_dict(name: str, d: dict) -> ProviderConfig:
130
+ api_key_env = d.get("api_key_env")
131
+ api_key = os.getenv(api_key_env) if api_key_env else None
132
+ return ProviderConfig(
133
+ name=name,
134
+ type=d.get("type", "openai"),
135
+ base_url=d.get("base_url"),
136
+ api_key_env=api_key_env,
137
+ api_key=api_key,
138
+ model=d.get("model", "gpt-4o-mini"),
139
+ temperature=float(d.get("temperature", 0.2)),
140
+ max_tokens=int(d.get("max_tokens", 4096)),
141
+ extra_headers=d.get("extra_headers", {}) or {},
142
+ )
143
+
144
+
145
+ def load_config(
146
+ config_path: Optional[str] = None,
147
+ *,
148
+ provider: Optional[str] = None,
149
+ model: Optional[str] = None,
150
+ api_key: Optional[str] = None,
151
+ base_url: Optional[str] = None,
152
+ ) -> Config:
153
+ """Load configuration, applying optional overrides.
154
+
155
+ `api_key` / `base_url` let a caller (e.g. the VS Code extension) inject a
156
+ secret and endpoint at runtime so nothing has to live in a file.
157
+ """
158
+ raw: dict[str, Any] = {}
159
+ cfg_file = _find_config_file(config_path)
160
+ if cfg_file:
161
+ raw = yaml.safe_load(cfg_file.read_text(encoding="utf-8")) or {}
162
+
163
+ # Start from the built-in profiles, then let config.yaml override/extend them.
164
+ merged: dict[str, dict] = {k: dict(v) for k, v in BUILTIN_PROVIDERS.items()}
165
+ for name, d in (raw.get("providers", {}) or {}).items():
166
+ merged[name] = {**merged.get(name, {}), **d}
167
+ all_providers = {name: _provider_from_dict(name, d) for name, d in merged.items()}
168
+
169
+ active = provider or raw.get("active") or "openai"
170
+ if active not in all_providers:
171
+ raise ValueError(
172
+ f"Provider '{active}' not found. Available: {', '.join(all_providers)}"
173
+ )
174
+ selected = all_providers[active]
175
+ overrides: dict[str, Any] = {}
176
+ if model:
177
+ overrides["model"] = model
178
+ if api_key: # non-empty only
179
+ overrides["api_key"] = api_key
180
+ if base_url:
181
+ overrides["base_url"] = base_url
182
+ if overrides:
183
+ selected = replace(selected, **overrides)
184
+
185
+ agent_raw = raw.get("agent", {}) or {}
186
+ agent = AgentConfig(
187
+ max_steps=int(agent_raw.get("max_steps", 30)),
188
+ stream=bool(agent_raw.get("stream", True)),
189
+ auto_approve_reads=bool(agent_raw.get("auto_approve_reads", True)),
190
+ auto_approve_writes=bool(agent_raw.get("auto_approve_writes", False)),
191
+ auto_approve_commands=bool(agent_raw.get("auto_approve_commands", False)),
192
+ max_file_bytes=int(agent_raw.get("max_file_bytes", 120_000)),
193
+ max_command_seconds=int(agent_raw.get("max_command_seconds", 60)),
194
+ )
195
+
196
+ ignore = raw.get("ignore") or DEFAULT_IGNORE
197
+
198
+ return Config(
199
+ provider=selected,
200
+ agent=agent,
201
+ ignore=list(ignore),
202
+ all_providers=all_providers,
203
+ )
euron_agent/events.py ADDED
@@ -0,0 +1,85 @@
1
+ """Event protocol and the AgentIO interface.
2
+
3
+ The agent loop is transport-agnostic: it talks to the outside world only
4
+ through an `AgentIO` implementation. The CLI implements it with the terminal;
5
+ the FastAPI server implements it over a WebSocket. Both speak the same set of
6
+ event dicts so the VS Code webview and the CLI render identical information.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import abc
11
+ from dataclasses import dataclass
12
+ from typing import Optional
13
+
14
+
15
+ # --------------------------------------------------------------------------- #
16
+ # Event constructors (plain dicts so they serialize straight to JSON)
17
+ # --------------------------------------------------------------------------- #
18
+ def status(message: str) -> dict:
19
+ return {"type": "status", "message": message}
20
+
21
+
22
+ def token(text: str) -> dict:
23
+ """A streamed chunk of assistant text."""
24
+ return {"type": "token", "text": text}
25
+
26
+
27
+ def assistant_message(text: str) -> dict:
28
+ """A complete assistant message (sent once a turn finishes)."""
29
+ return {"type": "assistant_message", "text": text}
30
+
31
+
32
+ def tool_start(call_id: str, name: str, args: dict) -> dict:
33
+ return {"type": "tool_start", "id": call_id, "name": name, "args": args}
34
+
35
+
36
+ def tool_result(call_id: str, name: str, ok: bool, output: str) -> dict:
37
+ return {"type": "tool_result", "id": call_id, "name": name, "ok": ok, "output": output}
38
+
39
+
40
+ def diff(path: str, patch: str, is_new: bool = False) -> dict:
41
+ return {"type": "diff", "path": path, "patch": patch, "is_new": is_new}
42
+
43
+
44
+ def approval_request(call_id: str, name: str, args: dict, preview: Optional[str]) -> dict:
45
+ return {
46
+ "type": "approval_request",
47
+ "id": call_id,
48
+ "name": name,
49
+ "args": args,
50
+ "preview": preview,
51
+ }
52
+
53
+
54
+ def done(summary: str = "") -> dict:
55
+ return {"type": "done", "summary": summary}
56
+
57
+
58
+ def error(message: str) -> dict:
59
+ return {"type": "error", "message": message}
60
+
61
+
62
+ # --------------------------------------------------------------------------- #
63
+ # Approval decision + IO interface
64
+ # --------------------------------------------------------------------------- #
65
+ @dataclass
66
+ class ApprovalDecision:
67
+ approved: bool
68
+ feedback: Optional[str] = None # optional user note fed back to the model
69
+
70
+
71
+ class AgentIO(abc.ABC):
72
+ """How the agent loop emits output and requests human approval."""
73
+
74
+ @abc.abstractmethod
75
+ def on_token(self, text: str) -> None:
76
+ """Called for every streamed token. MAY be invoked from a worker
77
+ thread, so implementations must be thread-safe / non-blocking."""
78
+
79
+ @abc.abstractmethod
80
+ async def emit(self, event: dict) -> None:
81
+ """Emit a structured event (always from the event loop)."""
82
+
83
+ @abc.abstractmethod
84
+ async def request_approval(self, request: dict) -> ApprovalDecision:
85
+ """Ask the human to approve a gated action and block until answered."""