agentram 0.1.17 → 0.1.21
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.
- package/agentram/cli.py +150 -24
- package/agentram/orchestration.py +254 -229
- package/package.json +1 -1
- package/pyproject.toml +1 -1
package/agentram/cli.py
CHANGED
|
@@ -5,6 +5,7 @@ import asyncio
|
|
|
5
5
|
from concurrent.futures import ThreadPoolExecutor
|
|
6
6
|
import json
|
|
7
7
|
import os
|
|
8
|
+
import re
|
|
8
9
|
import shlex
|
|
9
10
|
import shutil
|
|
10
11
|
import textwrap
|
|
@@ -16,6 +17,51 @@ from .config import default_ram_root
|
|
|
16
17
|
from .mcp_server import McpContext, call_tool
|
|
17
18
|
from .orchestration import AgentModelEndpoint, MultiModelCodingOrchestrator
|
|
18
19
|
|
|
20
|
+
|
|
21
|
+
ENV_NAME_PATTERN = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
|
22
|
+
RAW_KEY_PREFIXES = ("sk-", "sk_", "sk-proj-", "gsk_", "xai-", "AIza")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def looks_like_raw_api_key(value: str) -> bool:
|
|
26
|
+
stripped = value.strip()
|
|
27
|
+
if not stripped:
|
|
28
|
+
return False
|
|
29
|
+
if stripped.startswith(RAW_KEY_PREFIXES):
|
|
30
|
+
return True
|
|
31
|
+
return not bool(ENV_NAME_PATTERN.fullmatch(stripped))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def update_env_file_value(path: Path, key: str, value: str) -> None:
|
|
35
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
36
|
+
lines = path.read_text(encoding="utf-8").splitlines() if path.exists() else []
|
|
37
|
+
assignment = f"{key}={value}"
|
|
38
|
+
updated = False
|
|
39
|
+
output: list[str] = []
|
|
40
|
+
for line in lines:
|
|
41
|
+
if line.strip().startswith(f"{key}="):
|
|
42
|
+
output.append(assignment)
|
|
43
|
+
updated = True
|
|
44
|
+
else:
|
|
45
|
+
output.append(line)
|
|
46
|
+
if not updated:
|
|
47
|
+
if output and output[-1].strip():
|
|
48
|
+
output.append("")
|
|
49
|
+
output.append("# Added by AgentRAM TUI setup")
|
|
50
|
+
output.append(assignment)
|
|
51
|
+
path.write_text("\n".join(output) + "\n", encoding="utf-8")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def resolve_api_key_env(slot: str, value: str) -> tuple[str, str | None]:
|
|
55
|
+
stripped = value.strip()
|
|
56
|
+
if not stripped:
|
|
57
|
+
return "", None
|
|
58
|
+
if looks_like_raw_api_key(stripped):
|
|
59
|
+
env_name = f"AGENTRAM_{slot.upper()}_API_KEY"
|
|
60
|
+
update_env_file_value(Path(".env"), env_name, stripped)
|
|
61
|
+
os.environ[env_name] = stripped
|
|
62
|
+
return env_name, env_name
|
|
63
|
+
return stripped, None
|
|
64
|
+
|
|
19
65
|
SUBAGENTS: dict[str, str] = {
|
|
20
66
|
"memory": "Summarize important facts, decisions, files, risks, and next tasks into AgentRAM events. Do not edit code.",
|
|
21
67
|
"planner": "Break the coding request into small ordered steps. Check AgentRAM goal drift before scope changes.",
|
|
@@ -43,6 +89,7 @@ SLASH_COMMANDS: list[tuple[str, str]] = [
|
|
|
43
89
|
("/bind-router provider model", "Bind intent router model"),
|
|
44
90
|
("/bind-agent slot provider model", "Bind supervisor/main/small agent"),
|
|
45
91
|
("/workflow", "Show supervisor workflow agents"),
|
|
92
|
+
("/setup", "Show model setup panel"),
|
|
46
93
|
("/route on|off|status", "Toggle auto prompt routing"),
|
|
47
94
|
("/orchestrate <task>", "Build multi-model agent plan"),
|
|
48
95
|
("/ask <prompt>", "Execute bound models and return outputs"),
|
|
@@ -74,6 +121,7 @@ SLASH_HELP = """Slash commands:
|
|
|
74
121
|
/bind-router provider model Bind intent router model
|
|
75
122
|
/bind-agent slot provider model Bind supervisor/main/small agent
|
|
76
123
|
/workflow Show supervisor workflow agents
|
|
124
|
+
/setup Show model setup panel
|
|
77
125
|
/route on|off|status Toggle auto prompt routing
|
|
78
126
|
/orchestrate <task> Build multi-model agent plan
|
|
79
127
|
/ask <prompt> Execute bound models and return outputs
|
|
@@ -273,7 +321,7 @@ def run_textual_tui(context: McpContext) -> bool:
|
|
|
273
321
|
try:
|
|
274
322
|
from textual.app import App, ComposeResult
|
|
275
323
|
from textual.containers import Horizontal, Vertical
|
|
276
|
-
from textual.widgets import Button, Footer, Header, Input, RichLog, Static
|
|
324
|
+
from textual.widgets import Button, Footer, Header, Input, RichLog, Select, Static
|
|
277
325
|
except ImportError:
|
|
278
326
|
return False
|
|
279
327
|
|
|
@@ -286,13 +334,14 @@ def run_textual_tui(context: McpContext) -> bool:
|
|
|
286
334
|
#setup { height: auto; border: solid #3b82f6; padding: 1; background: #101826; }
|
|
287
335
|
#setup-title { height: auto; color: #dbeafe; }
|
|
288
336
|
#setup-actions { height: auto; }
|
|
337
|
+
.agent-setup { height: auto; border: round #263241; padding: 1; }
|
|
289
338
|
.setup-input { height: 3; }
|
|
290
339
|
#chat { height: 1fr; border: solid #263241; padding: 1; overflow-y: scroll; }
|
|
291
340
|
#palette { height: auto; max-height: 12; border: solid #3b82f6; padding: 1; background: #101826; display: none; }
|
|
292
341
|
#input { dock: bottom; border: solid #3b82f6; }
|
|
293
342
|
.muted { color: #8b9bb0; }
|
|
294
343
|
"""
|
|
295
|
-
BINDINGS = [("ctrl+c", "
|
|
344
|
+
BINDINGS = [("ctrl+c", "interrupt_or_quit", "Cancel/Quit"), ("tab", "accept_suggestion", "Complete slash"), ("ctrl+s", "save_setup", "Save setup"), ("up", "history_prev", "Previous prompt"), ("down", "history_next", "Next prompt")]
|
|
296
345
|
|
|
297
346
|
def __init__(self, mcp_context: McpContext) -> None:
|
|
298
347
|
super().__init__()
|
|
@@ -301,6 +350,9 @@ def run_textual_tui(context: McpContext) -> bool:
|
|
|
301
350
|
self.busy = False
|
|
302
351
|
self.suggestions: list[str] = []
|
|
303
352
|
self.setup_done = False
|
|
353
|
+
self.prompt_history: list[str] = []
|
|
354
|
+
self.history_index: int | None = None
|
|
355
|
+
self.last_ctrl_c_at = 0.0
|
|
304
356
|
|
|
305
357
|
def compose(self) -> ComposeResult:
|
|
306
358
|
yield Header(show_clock=True)
|
|
@@ -308,13 +360,28 @@ def run_textual_tui(context: McpContext) -> bool:
|
|
|
308
360
|
yield Static(id="sidebar")
|
|
309
361
|
with Vertical(id="main"):
|
|
310
362
|
with Vertical(id="setup"):
|
|
311
|
-
yield Static("Model setup
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
363
|
+
yield Static("Model setup - configure each agent separately", id="setup-title")
|
|
364
|
+
for slot, label, default_model in [
|
|
365
|
+
("supervisor", "Supervisor", ""),
|
|
366
|
+
("main", "Main coding", ""),
|
|
367
|
+
("small", "Small memory", ""),
|
|
368
|
+
]:
|
|
369
|
+
with Vertical(classes="agent-setup"):
|
|
370
|
+
yield Static(f"{label} agent")
|
|
371
|
+
yield Select(
|
|
372
|
+
[
|
|
373
|
+
("OpenAI Compatible", "openai-compatible"),
|
|
374
|
+
("Anthropic Compatible", "anthropic"),
|
|
375
|
+
("Custom", "custom"),
|
|
376
|
+
],
|
|
377
|
+
value="openai-compatible",
|
|
378
|
+
id=f"setup-{slot}-provider",
|
|
379
|
+
classes="setup-input",
|
|
380
|
+
)
|
|
381
|
+
yield Input(placeholder="Custom provider if preset=Custom", id=f"setup-{slot}-custom-provider", classes="setup-input")
|
|
382
|
+
yield Input(value=default_model, placeholder="Model name", id=f"setup-{slot}-model", classes="setup-input")
|
|
383
|
+
yield Input(placeholder="Base URL, e.g. http://localhost:20128/v1", id=f"setup-{slot}-base-url", classes="setup-input")
|
|
384
|
+
yield Input(value="OPENAI_API_KEY", placeholder="API key or env name", id=f"setup-{slot}-api-key-env", classes="setup-input")
|
|
318
385
|
with Horizontal(id="setup-actions"):
|
|
319
386
|
yield Button("Save setup", id="setup-save", variant="primary")
|
|
320
387
|
yield Button("Skip", id="setup-skip")
|
|
@@ -331,7 +398,7 @@ def run_textual_tui(context: McpContext) -> bool:
|
|
|
331
398
|
self.refresh_sidebar()
|
|
332
399
|
self.set_interval(1.0, self.refresh_sidebar)
|
|
333
400
|
try:
|
|
334
|
-
self.query_one("#setup-
|
|
401
|
+
self.query_one("#setup-supervisor-model", Input).focus()
|
|
335
402
|
except Exception: # noqa: BLE001 - fallback if setup is hidden/unmounted
|
|
336
403
|
self.query_one("#input", Input).focus()
|
|
337
404
|
|
|
@@ -346,30 +413,37 @@ def run_textual_tui(context: McpContext) -> bool:
|
|
|
346
413
|
agents = self.context.profile_store.list_agents()
|
|
347
414
|
if not agents:
|
|
348
415
|
return
|
|
349
|
-
first = next(iter(agents.values()))
|
|
350
|
-
self.query_one("#setup-provider", Input).value = first.provider
|
|
351
|
-
self.query_one("#setup-base-url", Input).value = first.base_url
|
|
352
|
-
self.query_one("#setup-api-key-env", Input).value = first.api_key_env
|
|
353
416
|
for slot in ("supervisor", "main", "small"):
|
|
354
417
|
endpoint = agents.get(slot)
|
|
355
|
-
if endpoint:
|
|
356
|
-
|
|
418
|
+
if not endpoint:
|
|
419
|
+
continue
|
|
420
|
+
provider = endpoint.provider
|
|
421
|
+
preset = provider if provider in {"openai-compatible", "anthropic"} else "custom"
|
|
422
|
+
self.query_one(f"#setup-{slot}-provider", Select).value = preset
|
|
423
|
+
self.query_one(f"#setup-{slot}-custom-provider", Input).value = "" if preset != "custom" else provider
|
|
424
|
+
self.query_one(f"#setup-{slot}-model", Input).value = endpoint.model
|
|
425
|
+
self.query_one(f"#setup-{slot}-base-url", Input).value = endpoint.base_url
|
|
426
|
+
self.query_one(f"#setup-{slot}-api-key-env", Input).value = endpoint.api_key_env
|
|
357
427
|
self.chat.write("system > Existing workflow models loaded. Save to update, or Skip to keep.")
|
|
358
428
|
|
|
359
429
|
def setup_value(self, widget_id: str) -> str:
|
|
360
430
|
return self.query_one(f"#{widget_id}", Input).value.strip()
|
|
361
431
|
|
|
432
|
+
def setup_provider(self, slot: str) -> str:
|
|
433
|
+
preset = str(self.query_one(f"#setup-{slot}-provider", Select).value or "openai-compatible")
|
|
434
|
+
if preset == "custom":
|
|
435
|
+
return self.setup_value(f"setup-{slot}-custom-provider") or "custom"
|
|
436
|
+
return preset
|
|
437
|
+
|
|
362
438
|
def action_save_setup(self) -> None:
|
|
363
|
-
provider = self.setup_value("setup-provider") or "openai-compatible"
|
|
364
|
-
base_url = self.setup_value("setup-base-url")
|
|
365
|
-
api_key_env = self.setup_value("setup-api-key-env")
|
|
366
|
-
if api_key_env.startswith(("sk-", "gsk_", "sk_")):
|
|
367
|
-
self.chat.write("agentram > setup error: api key env must be variable name, not raw key")
|
|
368
|
-
return
|
|
369
439
|
role_by_slot = {"supervisor": "supervisor", "main": "coder", "small": "memory"}
|
|
370
440
|
bound: list[str] = []
|
|
371
441
|
for slot, role in role_by_slot.items():
|
|
372
|
-
|
|
442
|
+
provider = self.setup_provider(slot)
|
|
443
|
+
model = self.setup_value(f"setup-{slot}-model")
|
|
444
|
+
base_url = self.setup_value(f"setup-{slot}-base-url")
|
|
445
|
+
api_key_input = self.setup_value(f"setup-{slot}-api-key-env")
|
|
446
|
+
api_key_env, saved_env_name = resolve_api_key_env(slot, api_key_input)
|
|
373
447
|
if not model:
|
|
374
448
|
continue
|
|
375
449
|
endpoint = AgentModelEndpoint(
|
|
@@ -381,7 +455,10 @@ def run_textual_tui(context: McpContext) -> bool:
|
|
|
381
455
|
modalities=["text"],
|
|
382
456
|
)
|
|
383
457
|
self.context.profile_store.set_agent(slot, endpoint)
|
|
384
|
-
|
|
458
|
+
if saved_env_name:
|
|
459
|
+
bound.append(f"{slot}={provider}:{model} key=.env:{saved_env_name}")
|
|
460
|
+
else:
|
|
461
|
+
bound.append(f"{slot}={provider}:{model}")
|
|
385
462
|
if not bound:
|
|
386
463
|
self.chat.write("agentram > setup skipped: no model entered")
|
|
387
464
|
else:
|
|
@@ -394,6 +471,47 @@ def run_textual_tui(context: McpContext) -> bool:
|
|
|
394
471
|
self.setup_done = True
|
|
395
472
|
self.query_one("#setup", Vertical).styles.display = "none"
|
|
396
473
|
|
|
474
|
+
def show_setup(self) -> None:
|
|
475
|
+
self.setup_done = False
|
|
476
|
+
self.query_one("#setup", Vertical).styles.display = "block"
|
|
477
|
+
self.query_one("#setup-supervisor-model", Input).focus()
|
|
478
|
+
|
|
479
|
+
def action_history_prev(self) -> None:
|
|
480
|
+
input_widget = self.query_one("#input", Input)
|
|
481
|
+
if input_widget.has_focus and self.prompt_history:
|
|
482
|
+
self.history_index = len(self.prompt_history) - 1 if self.history_index is None else max(0, self.history_index - 1)
|
|
483
|
+
input_widget.value = self.prompt_history[self.history_index]
|
|
484
|
+
input_widget.cursor_position = len(input_widget.value)
|
|
485
|
+
|
|
486
|
+
def action_history_next(self) -> None:
|
|
487
|
+
input_widget = self.query_one("#input", Input)
|
|
488
|
+
if input_widget.has_focus and self.prompt_history and self.history_index is not None:
|
|
489
|
+
if self.history_index >= len(self.prompt_history) - 1:
|
|
490
|
+
self.history_index = None
|
|
491
|
+
input_widget.value = ""
|
|
492
|
+
else:
|
|
493
|
+
self.history_index += 1
|
|
494
|
+
input_widget.value = self.prompt_history[self.history_index]
|
|
495
|
+
input_widget.cursor_position = len(input_widget.value)
|
|
496
|
+
|
|
497
|
+
def action_interrupt_or_quit(self) -> None:
|
|
498
|
+
input_widget = self.query_one("#input", Input)
|
|
499
|
+
now = time.time()
|
|
500
|
+
if input_widget.has_focus and input_widget.value:
|
|
501
|
+
input_widget.value = ""
|
|
502
|
+
self.chat.write("system > Prompt cleared. Press Ctrl+C again to quit.")
|
|
503
|
+
self.last_ctrl_c_at = now
|
|
504
|
+
return
|
|
505
|
+
if self.busy:
|
|
506
|
+
self.chat.write("system > Current task cannot be cancelled safely yet. Press Ctrl+C again to quit UI.")
|
|
507
|
+
self.last_ctrl_c_at = now
|
|
508
|
+
return
|
|
509
|
+
if now - self.last_ctrl_c_at < 2.0:
|
|
510
|
+
self.exit()
|
|
511
|
+
else:
|
|
512
|
+
self.chat.write("system > Press Ctrl+C again to quit.")
|
|
513
|
+
self.last_ctrl_c_at = now
|
|
514
|
+
|
|
397
515
|
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
398
516
|
if event.button.id == "setup-save":
|
|
399
517
|
self.action_save_setup()
|
|
@@ -457,14 +575,22 @@ def run_textual_tui(context: McpContext) -> bool:
|
|
|
457
575
|
return
|
|
458
576
|
text = event.value.strip()
|
|
459
577
|
event.input.value = ""
|
|
578
|
+
self.history_index = None
|
|
460
579
|
self.hide_palette()
|
|
461
580
|
if not text:
|
|
462
581
|
return
|
|
463
582
|
if text in {"0", "q", "quit", "exit", "/exit", "/quit"}:
|
|
464
583
|
self.exit()
|
|
465
584
|
return
|
|
585
|
+
if text == "/setup":
|
|
586
|
+
self.show_setup()
|
|
587
|
+
self.chat.write("system > Model setup opened.")
|
|
588
|
+
return
|
|
466
589
|
if text in BUTTONS:
|
|
467
590
|
text = BUTTONS[text]
|
|
591
|
+
if text and (not self.prompt_history or self.prompt_history[-1] != text):
|
|
592
|
+
self.prompt_history.append(text)
|
|
593
|
+
del self.prompt_history[:-100]
|
|
468
594
|
self.chat.write(f"user > {text}")
|
|
469
595
|
if text == "/clear":
|
|
470
596
|
self.chat.clear()
|
|
@@ -1,229 +1,254 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
4
|
-
from dataclasses import dataclass, field
|
|
5
|
-
import json
|
|
6
|
-
import os
|
|
7
|
-
import re
|
|
8
|
-
import shlex
|
|
9
|
-
import subprocess
|
|
10
|
-
import urllib.error
|
|
11
|
-
import urllib.request
|
|
12
|
-
from typing import Any, Callable
|
|
13
|
-
from uuid import uuid4
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
def
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
def
|
|
118
|
-
|
|
119
|
-
if not
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
)
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import shlex
|
|
9
|
+
import subprocess
|
|
10
|
+
import urllib.error
|
|
11
|
+
import urllib.request
|
|
12
|
+
from typing import Any, Callable
|
|
13
|
+
from uuid import uuid4
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
PROVIDER_ALIASES = {
|
|
17
|
+
"openai compatible": "openai-compatible",
|
|
18
|
+
"openai_compatible": "openai-compatible",
|
|
19
|
+
"openaicompatible": "openai-compatible",
|
|
20
|
+
"openai-compatible": "openai-compatible",
|
|
21
|
+
"ollama": "ollama-v1",
|
|
22
|
+
"ollama_v1": "ollama-v1",
|
|
23
|
+
"ollama-v1": "ollama-v1",
|
|
24
|
+
"lm studio": "lmstudio",
|
|
25
|
+
"lm_studio": "lmstudio",
|
|
26
|
+
"lmstudio": "lmstudio",
|
|
27
|
+
"claudecode": "claude-code",
|
|
28
|
+
"claude_code": "claude-code",
|
|
29
|
+
"claude-code": "claude-code",
|
|
30
|
+
"claudesubagent": "claude-subagent",
|
|
31
|
+
"claude_subagent": "claude-subagent",
|
|
32
|
+
"claude-subagent": "claude-subagent",
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def normalize_provider_name(provider: str) -> str:
|
|
37
|
+
normalized = provider.strip().lower().replace("/", "-")
|
|
38
|
+
return PROVIDER_ALIASES.get(normalized, normalized)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class AgentModelEndpoint:
|
|
43
|
+
provider: str
|
|
44
|
+
model: str
|
|
45
|
+
role: str = "coder"
|
|
46
|
+
base_url: str = ""
|
|
47
|
+
api_key_env: str = ""
|
|
48
|
+
modalities: list[str] = field(default_factory=lambda: ["text"])
|
|
49
|
+
enabled: bool = True
|
|
50
|
+
id: str = field(default_factory=lambda: f"model_{uuid4().hex[:10]}")
|
|
51
|
+
|
|
52
|
+
def to_dict(self) -> dict[str, Any]:
|
|
53
|
+
return {
|
|
54
|
+
"id": self.id,
|
|
55
|
+
"provider": normalize_provider_name(self.provider),
|
|
56
|
+
"model": self.model,
|
|
57
|
+
"role": self.role,
|
|
58
|
+
"base_url": self.base_url,
|
|
59
|
+
"api_key_env": self.api_key_env,
|
|
60
|
+
"modalities": self.modalities,
|
|
61
|
+
"enabled": self.enabled,
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
@classmethod
|
|
65
|
+
def from_dict(cls, data: dict[str, Any]) -> "AgentModelEndpoint":
|
|
66
|
+
return cls(
|
|
67
|
+
id=str(data.get("id") or f"model_{uuid4().hex[:10]}"),
|
|
68
|
+
provider=normalize_provider_name(str(data.get("provider", "openai-compatible"))),
|
|
69
|
+
model=str(data.get("model", "")),
|
|
70
|
+
role=str(data.get("role", "coder")),
|
|
71
|
+
base_url=str(data.get("base_url", "")),
|
|
72
|
+
api_key_env=str(data.get("api_key_env", "")),
|
|
73
|
+
modalities=[str(item) for item in data.get("modalities", ["text"])],
|
|
74
|
+
enabled=bool(data.get("enabled", True)),
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass(frozen=True)
|
|
79
|
+
class CodingOrchestrationRequest:
|
|
80
|
+
task: str
|
|
81
|
+
context: str = ""
|
|
82
|
+
files: list[str] = field(default_factory=list)
|
|
83
|
+
modalities: list[str] = field(default_factory=lambda: ["text"])
|
|
84
|
+
|
|
85
|
+
def to_prompt(self, endpoint: AgentModelEndpoint) -> str:
|
|
86
|
+
return "\n".join(
|
|
87
|
+
[
|
|
88
|
+
f"You are AgentRAM {endpoint.role} agent.",
|
|
89
|
+
f"Model: {endpoint.model}",
|
|
90
|
+
f"Provider: {endpoint.provider}",
|
|
91
|
+
f"Modalities: {', '.join(endpoint.modalities)}",
|
|
92
|
+
"Task:",
|
|
93
|
+
self.task,
|
|
94
|
+
"Files:",
|
|
95
|
+
"\n".join(f"- {item}" for item in self.files) or "- none provided",
|
|
96
|
+
"AgentRAM Context:",
|
|
97
|
+
self.context or "No context provided.",
|
|
98
|
+
"Output contract:",
|
|
99
|
+
"- Return concise coding findings or patch plan.",
|
|
100
|
+
"- Mention files to inspect or change.",
|
|
101
|
+
"- Do not invent repo facts.",
|
|
102
|
+
]
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class MultiModelCodingOrchestrator:
|
|
107
|
+
def __init__(self, endpoints: list[AgentModelEndpoint], client: Callable[[AgentModelEndpoint, str], str] | None = None) -> None:
|
|
108
|
+
self.endpoints = [endpoint for endpoint in endpoints if endpoint.enabled]
|
|
109
|
+
self.client = client or self._call_endpoint
|
|
110
|
+
|
|
111
|
+
def build_plan(self, request: CodingOrchestrationRequest) -> dict[str, Any]:
|
|
112
|
+
agents = []
|
|
113
|
+
for endpoint in self.endpoints:
|
|
114
|
+
agents.append({"endpoint": endpoint.to_dict(), "prompt": request.to_prompt(endpoint)})
|
|
115
|
+
return {"task": request.task, "agent_count": len(agents), "agents": agents}
|
|
116
|
+
|
|
117
|
+
def run(self, request: CodingOrchestrationRequest, max_workers: int = 4) -> dict[str, Any]:
|
|
118
|
+
results: list[dict[str, Any]] = []
|
|
119
|
+
if not self.endpoints:
|
|
120
|
+
return {"task": request.task, "agent_count": 0, "results": []}
|
|
121
|
+
with ThreadPoolExecutor(max_workers=max(1, min(max_workers, len(self.endpoints)))) as executor:
|
|
122
|
+
future_map = {executor.submit(self.client, endpoint, request.to_prompt(endpoint)): endpoint for endpoint in self.endpoints}
|
|
123
|
+
for future in as_completed(future_map):
|
|
124
|
+
endpoint = future_map[future]
|
|
125
|
+
try:
|
|
126
|
+
output = future.result()
|
|
127
|
+
results.append({"endpoint": endpoint.to_dict(), "ok": True, "output": output})
|
|
128
|
+
except Exception as error: # noqa: BLE001 - agent boundary
|
|
129
|
+
results.append({"endpoint": endpoint.to_dict(), "ok": False, "error": str(error)})
|
|
130
|
+
return {"task": request.task, "agent_count": len(self.endpoints), "results": results}
|
|
131
|
+
|
|
132
|
+
def _call_endpoint(self, endpoint: AgentModelEndpoint, prompt: str) -> str:
|
|
133
|
+
provider = normalize_provider_name(endpoint.provider)
|
|
134
|
+
if provider in {"openai", "openai-compatible", "groq", "vllm", "lmstudio", "ollama-v1"}:
|
|
135
|
+
return self._call_openai_compatible(endpoint, prompt)
|
|
136
|
+
if provider in {"anthropic", "claude"}:
|
|
137
|
+
return self._call_anthropic(endpoint, prompt)
|
|
138
|
+
if provider in {"claude-code", "claude-cli", "claude-subagent", "claude-code-subagent"}:
|
|
139
|
+
return self._call_claude_code_subagent(endpoint, prompt)
|
|
140
|
+
raise ValueError(f"Unsupported provider for execution: {endpoint.provider}")
|
|
141
|
+
|
|
142
|
+
def _call_openai_compatible(self, endpoint: AgentModelEndpoint, prompt: str) -> str:
|
|
143
|
+
api_key = os.getenv(endpoint.api_key_env) if endpoint.api_key_env else ""
|
|
144
|
+
if not endpoint.base_url:
|
|
145
|
+
raise ValueError("base_url is required")
|
|
146
|
+
if endpoint.api_key_env and not api_key:
|
|
147
|
+
raise ValueError(f"missing API key env: {endpoint.api_key_env}")
|
|
148
|
+
payload = {"model": endpoint.model, "messages": [{"role": "user", "content": prompt}], "temperature": 0}
|
|
149
|
+
request = urllib.request.Request(
|
|
150
|
+
endpoint.base_url.rstrip("/") + "/chat/completions",
|
|
151
|
+
data=json.dumps(payload).encode("utf-8"),
|
|
152
|
+
headers={"Content-Type": "application/json", "Authorization": f"Bearer {api_key}"} if api_key else {"Content-Type": "application/json"},
|
|
153
|
+
method="POST",
|
|
154
|
+
)
|
|
155
|
+
url = endpoint.base_url.rstrip("/") + "/chat/completions"
|
|
156
|
+
try:
|
|
157
|
+
with urllib.request.urlopen(request, timeout=120) as response:
|
|
158
|
+
raw = response.read().decode("utf-8")
|
|
159
|
+
except urllib.error.HTTPError as error:
|
|
160
|
+
body = error.read().decode("utf-8", errors="replace") if error.fp else ""
|
|
161
|
+
hint = f"OpenAI-compatible HTTP {error.code} at {url}"
|
|
162
|
+
if error.code == 404:
|
|
163
|
+
hint += " | base_url should include /v1 but not /chat/completions; verify provider URL and model name"
|
|
164
|
+
raise RuntimeError(f"{hint} | response={body[:500]}") from error
|
|
165
|
+
try:
|
|
166
|
+
data = json.loads(raw)
|
|
167
|
+
except json.JSONDecodeError as error:
|
|
168
|
+
streamed = parse_openai_sse(raw)
|
|
169
|
+
if streamed:
|
|
170
|
+
return streamed
|
|
171
|
+
raise RuntimeError(f"OpenAI-compatible returned non-JSON at {url} | response={raw[:500]}") from error
|
|
172
|
+
return str(data.get("choices", [{}])[0].get("message", {}).get("content", ""))
|
|
173
|
+
|
|
174
|
+
def _call_anthropic(self, endpoint: AgentModelEndpoint, prompt: str) -> str:
|
|
175
|
+
api_key = os.getenv(endpoint.api_key_env) if endpoint.api_key_env else ""
|
|
176
|
+
if endpoint.api_key_env and not api_key:
|
|
177
|
+
raise ValueError(f"missing API key env: {endpoint.api_key_env}")
|
|
178
|
+
base_url = endpoint.base_url.rstrip("/") if endpoint.base_url else "https://api.anthropic.com/v1"
|
|
179
|
+
payload = {"model": endpoint.model, "max_tokens": 1024, "messages": [{"role": "user", "content": prompt}]}
|
|
180
|
+
request = urllib.request.Request(
|
|
181
|
+
base_url + "/messages",
|
|
182
|
+
data=json.dumps(payload).encode("utf-8"),
|
|
183
|
+
headers={"Content-Type": "application/json", "x-api-key": api_key, "anthropic-version": "2023-06-01"},
|
|
184
|
+
method="POST",
|
|
185
|
+
)
|
|
186
|
+
with urllib.request.urlopen(request, timeout=120) as response:
|
|
187
|
+
data = json.loads(response.read().decode("utf-8"))
|
|
188
|
+
content = data.get("content", [])
|
|
189
|
+
if content and isinstance(content, list):
|
|
190
|
+
return "\n".join(str(item.get("text", "")) for item in content if isinstance(item, dict))
|
|
191
|
+
return str(data)
|
|
192
|
+
|
|
193
|
+
def _call_claude_code_subagent(self, endpoint: AgentModelEndpoint, prompt: str) -> str:
|
|
194
|
+
command = endpoint.base_url.strip() or os.getenv("AGENTRAM_CLAUDE_COMMAND", "claude")
|
|
195
|
+
args = shlex.split(command)
|
|
196
|
+
if not args:
|
|
197
|
+
raise ValueError("claude command is required")
|
|
198
|
+
subagent_prompt = "\n".join(
|
|
199
|
+
[
|
|
200
|
+
f"Use Claude Code subagent entrypoint: {endpoint.model}",
|
|
201
|
+
f"Subagent role: {endpoint.role}",
|
|
202
|
+
"If that subagent exists in .claude/agents, follow it. If not, act as this role.",
|
|
203
|
+
"Return concise coding-agent output only.",
|
|
204
|
+
"",
|
|
205
|
+
prompt,
|
|
206
|
+
]
|
|
207
|
+
)
|
|
208
|
+
process = subprocess.run(
|
|
209
|
+
[*args, "-p", subagent_prompt],
|
|
210
|
+
check=False,
|
|
211
|
+
capture_output=True,
|
|
212
|
+
encoding="utf-8",
|
|
213
|
+
timeout=300,
|
|
214
|
+
)
|
|
215
|
+
output = (process.stdout or "").strip()
|
|
216
|
+
error = (process.stderr or "").strip()
|
|
217
|
+
if process.returncode != 0:
|
|
218
|
+
raise RuntimeError(error or f"claude command failed with exit code {process.returncode}")
|
|
219
|
+
return output or error
|
|
220
|
+
|
|
221
|
+
def parse_openai_sse(raw: str) -> str:
|
|
222
|
+
parts: list[str] = []
|
|
223
|
+
for line in raw.splitlines():
|
|
224
|
+
stripped = line.strip()
|
|
225
|
+
if not stripped.startswith("data:"):
|
|
226
|
+
continue
|
|
227
|
+
payload = stripped[5:].strip()
|
|
228
|
+
if not payload or payload == "[DONE]":
|
|
229
|
+
continue
|
|
230
|
+
try:
|
|
231
|
+
item = json.loads(payload)
|
|
232
|
+
except json.JSONDecodeError:
|
|
233
|
+
continue
|
|
234
|
+
choices = item.get("choices", [])
|
|
235
|
+
if not choices or not isinstance(choices, list):
|
|
236
|
+
continue
|
|
237
|
+
choice = choices[0]
|
|
238
|
+
if not isinstance(choice, dict):
|
|
239
|
+
continue
|
|
240
|
+
delta = choice.get("delta", {})
|
|
241
|
+
if isinstance(delta, dict):
|
|
242
|
+
content = delta.get("content")
|
|
243
|
+
if content:
|
|
244
|
+
parts.append(str(content))
|
|
245
|
+
message = choice.get("message", {})
|
|
246
|
+
if isinstance(message, dict) and message.get("content"):
|
|
247
|
+
parts.append(str(message.get("content")))
|
|
248
|
+
return strip_thinking_blocks("".join(parts)).strip()
|
|
249
|
+
|
|
250
|
+
def strip_thinking_blocks(text: str) -> str:
|
|
251
|
+
text = re.sub(r"<thinking>.*?</thinking>", "", text, flags=re.DOTALL | re.IGNORECASE)
|
|
252
|
+
text = re.sub(r"<think>.*?</think>", "", text, flags=re.DOTALL | re.IGNORECASE)
|
|
253
|
+
text = re.sub(r"^\s*</?(thinking|think)>\s*", "", text, flags=re.IGNORECASE)
|
|
254
|
+
return text.strip()
|
package/package.json
CHANGED