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.
- euron_agent/__init__.py +9 -0
- euron_agent/__main__.py +7 -0
- euron_agent/cli.py +286 -0
- euron_agent/config.py +203 -0
- euron_agent/events.py +85 -0
- euron_agent/llm.py +272 -0
- euron_agent/loop.py +140 -0
- euron_agent/prompts.py +39 -0
- euron_agent/server.py +216 -0
- euron_agent/tool_schemas.py +109 -0
- euron_agent/tools.py +318 -0
- euron_coding_agent-0.1.0.dist-info/METADATA +70 -0
- euron_coding_agent-0.1.0.dist-info/RECORD +17 -0
- euron_coding_agent-0.1.0.dist-info/WHEEL +5 -0
- euron_coding_agent-0.1.0.dist-info/entry_points.txt +2 -0
- euron_coding_agent-0.1.0.dist-info/licenses/LICENSE +21 -0
- euron_coding_agent-0.1.0.dist-info/top_level.txt +1 -0
euron_agent/__init__.py
ADDED
euron_agent/__main__.py
ADDED
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."""
|