agentram 0.1.17 → 0.1.20
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 +103 -24
- package/agentram/orchestration.py +254 -229
- package/package.json +1 -1
- package/pyproject.toml +1 -1
package/agentram/cli.py
CHANGED
|
@@ -43,6 +43,7 @@ SLASH_COMMANDS: list[tuple[str, str]] = [
|
|
|
43
43
|
("/bind-router provider model", "Bind intent router model"),
|
|
44
44
|
("/bind-agent slot provider model", "Bind supervisor/main/small agent"),
|
|
45
45
|
("/workflow", "Show supervisor workflow agents"),
|
|
46
|
+
("/setup", "Show model setup panel"),
|
|
46
47
|
("/route on|off|status", "Toggle auto prompt routing"),
|
|
47
48
|
("/orchestrate <task>", "Build multi-model agent plan"),
|
|
48
49
|
("/ask <prompt>", "Execute bound models and return outputs"),
|
|
@@ -74,6 +75,7 @@ SLASH_HELP = """Slash commands:
|
|
|
74
75
|
/bind-router provider model Bind intent router model
|
|
75
76
|
/bind-agent slot provider model Bind supervisor/main/small agent
|
|
76
77
|
/workflow Show supervisor workflow agents
|
|
78
|
+
/setup Show model setup panel
|
|
77
79
|
/route on|off|status Toggle auto prompt routing
|
|
78
80
|
/orchestrate <task> Build multi-model agent plan
|
|
79
81
|
/ask <prompt> Execute bound models and return outputs
|
|
@@ -273,7 +275,7 @@ def run_textual_tui(context: McpContext) -> bool:
|
|
|
273
275
|
try:
|
|
274
276
|
from textual.app import App, ComposeResult
|
|
275
277
|
from textual.containers import Horizontal, Vertical
|
|
276
|
-
from textual.widgets import Button, Footer, Header, Input, RichLog, Static
|
|
278
|
+
from textual.widgets import Button, Footer, Header, Input, RichLog, Select, Static
|
|
277
279
|
except ImportError:
|
|
278
280
|
return False
|
|
279
281
|
|
|
@@ -286,13 +288,14 @@ def run_textual_tui(context: McpContext) -> bool:
|
|
|
286
288
|
#setup { height: auto; border: solid #3b82f6; padding: 1; background: #101826; }
|
|
287
289
|
#setup-title { height: auto; color: #dbeafe; }
|
|
288
290
|
#setup-actions { height: auto; }
|
|
291
|
+
.agent-setup { height: auto; border: round #263241; padding: 1; }
|
|
289
292
|
.setup-input { height: 3; }
|
|
290
293
|
#chat { height: 1fr; border: solid #263241; padding: 1; overflow-y: scroll; }
|
|
291
294
|
#palette { height: auto; max-height: 12; border: solid #3b82f6; padding: 1; background: #101826; display: none; }
|
|
292
295
|
#input { dock: bottom; border: solid #3b82f6; }
|
|
293
296
|
.muted { color: #8b9bb0; }
|
|
294
297
|
"""
|
|
295
|
-
BINDINGS = [("ctrl+c", "
|
|
298
|
+
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
299
|
|
|
297
300
|
def __init__(self, mcp_context: McpContext) -> None:
|
|
298
301
|
super().__init__()
|
|
@@ -301,6 +304,9 @@ def run_textual_tui(context: McpContext) -> bool:
|
|
|
301
304
|
self.busy = False
|
|
302
305
|
self.suggestions: list[str] = []
|
|
303
306
|
self.setup_done = False
|
|
307
|
+
self.prompt_history: list[str] = []
|
|
308
|
+
self.history_index: int | None = None
|
|
309
|
+
self.last_ctrl_c_at = 0.0
|
|
304
310
|
|
|
305
311
|
def compose(self) -> ComposeResult:
|
|
306
312
|
yield Header(show_clock=True)
|
|
@@ -308,13 +314,28 @@ def run_textual_tui(context: McpContext) -> bool:
|
|
|
308
314
|
yield Static(id="sidebar")
|
|
309
315
|
with Vertical(id="main"):
|
|
310
316
|
with Vertical(id="setup"):
|
|
311
|
-
yield Static("Model setup
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
317
|
+
yield Static("Model setup - configure each agent separately", id="setup-title")
|
|
318
|
+
for slot, label, default_model in [
|
|
319
|
+
("supervisor", "Supervisor", ""),
|
|
320
|
+
("main", "Main coding", ""),
|
|
321
|
+
("small", "Small memory", ""),
|
|
322
|
+
]:
|
|
323
|
+
with Vertical(classes="agent-setup"):
|
|
324
|
+
yield Static(f"{label} agent")
|
|
325
|
+
yield Select(
|
|
326
|
+
[
|
|
327
|
+
("OpenAI Compatible", "openai-compatible"),
|
|
328
|
+
("Anthropic Compatible", "anthropic"),
|
|
329
|
+
("Custom", "custom"),
|
|
330
|
+
],
|
|
331
|
+
value="openai-compatible",
|
|
332
|
+
id=f"setup-{slot}-provider",
|
|
333
|
+
classes="setup-input",
|
|
334
|
+
)
|
|
335
|
+
yield Input(placeholder="Custom provider if preset=Custom", id=f"setup-{slot}-custom-provider", classes="setup-input")
|
|
336
|
+
yield Input(value=default_model, placeholder="Model name", id=f"setup-{slot}-model", classes="setup-input")
|
|
337
|
+
yield Input(placeholder="Base URL, e.g. http://localhost:20128/v1", id=f"setup-{slot}-base-url", classes="setup-input")
|
|
338
|
+
yield Input(value="OPENAI_API_KEY", placeholder="API key env name, not raw key", id=f"setup-{slot}-api-key-env", classes="setup-input")
|
|
318
339
|
with Horizontal(id="setup-actions"):
|
|
319
340
|
yield Button("Save setup", id="setup-save", variant="primary")
|
|
320
341
|
yield Button("Skip", id="setup-skip")
|
|
@@ -331,7 +352,7 @@ def run_textual_tui(context: McpContext) -> bool:
|
|
|
331
352
|
self.refresh_sidebar()
|
|
332
353
|
self.set_interval(1.0, self.refresh_sidebar)
|
|
333
354
|
try:
|
|
334
|
-
self.query_one("#setup-
|
|
355
|
+
self.query_one("#setup-supervisor-model", Input).focus()
|
|
335
356
|
except Exception: # noqa: BLE001 - fallback if setup is hidden/unmounted
|
|
336
357
|
self.query_one("#input", Input).focus()
|
|
337
358
|
|
|
@@ -346,30 +367,39 @@ def run_textual_tui(context: McpContext) -> bool:
|
|
|
346
367
|
agents = self.context.profile_store.list_agents()
|
|
347
368
|
if not agents:
|
|
348
369
|
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
370
|
for slot in ("supervisor", "main", "small"):
|
|
354
371
|
endpoint = agents.get(slot)
|
|
355
|
-
if endpoint:
|
|
356
|
-
|
|
372
|
+
if not endpoint:
|
|
373
|
+
continue
|
|
374
|
+
provider = endpoint.provider
|
|
375
|
+
preset = provider if provider in {"openai-compatible", "anthropic"} else "custom"
|
|
376
|
+
self.query_one(f"#setup-{slot}-provider", Select).value = preset
|
|
377
|
+
self.query_one(f"#setup-{slot}-custom-provider", Input).value = "" if preset != "custom" else provider
|
|
378
|
+
self.query_one(f"#setup-{slot}-model", Input).value = endpoint.model
|
|
379
|
+
self.query_one(f"#setup-{slot}-base-url", Input).value = endpoint.base_url
|
|
380
|
+
self.query_one(f"#setup-{slot}-api-key-env", Input).value = endpoint.api_key_env
|
|
357
381
|
self.chat.write("system > Existing workflow models loaded. Save to update, or Skip to keep.")
|
|
358
382
|
|
|
359
383
|
def setup_value(self, widget_id: str) -> str:
|
|
360
384
|
return self.query_one(f"#{widget_id}", Input).value.strip()
|
|
361
385
|
|
|
386
|
+
def setup_provider(self, slot: str) -> str:
|
|
387
|
+
preset = str(self.query_one(f"#setup-{slot}-provider", Select).value or "openai-compatible")
|
|
388
|
+
if preset == "custom":
|
|
389
|
+
return self.setup_value(f"setup-{slot}-custom-provider") or "custom"
|
|
390
|
+
return preset
|
|
391
|
+
|
|
362
392
|
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
393
|
role_by_slot = {"supervisor": "supervisor", "main": "coder", "small": "memory"}
|
|
370
394
|
bound: list[str] = []
|
|
371
395
|
for slot, role in role_by_slot.items():
|
|
372
|
-
|
|
396
|
+
provider = self.setup_provider(slot)
|
|
397
|
+
model = self.setup_value(f"setup-{slot}-model")
|
|
398
|
+
base_url = self.setup_value(f"setup-{slot}-base-url")
|
|
399
|
+
api_key_env = self.setup_value(f"setup-{slot}-api-key-env")
|
|
400
|
+
if api_key_env.startswith(("sk-", "gsk_", "sk_")):
|
|
401
|
+
self.chat.write(f"agentram > setup error: {slot} api key env must be variable name, not raw key")
|
|
402
|
+
return
|
|
373
403
|
if not model:
|
|
374
404
|
continue
|
|
375
405
|
endpoint = AgentModelEndpoint(
|
|
@@ -381,7 +411,7 @@ def run_textual_tui(context: McpContext) -> bool:
|
|
|
381
411
|
modalities=["text"],
|
|
382
412
|
)
|
|
383
413
|
self.context.profile_store.set_agent(slot, endpoint)
|
|
384
|
-
bound.append(f"{slot}={model}")
|
|
414
|
+
bound.append(f"{slot}={provider}:{model}")
|
|
385
415
|
if not bound:
|
|
386
416
|
self.chat.write("agentram > setup skipped: no model entered")
|
|
387
417
|
else:
|
|
@@ -394,6 +424,47 @@ def run_textual_tui(context: McpContext) -> bool:
|
|
|
394
424
|
self.setup_done = True
|
|
395
425
|
self.query_one("#setup", Vertical).styles.display = "none"
|
|
396
426
|
|
|
427
|
+
def show_setup(self) -> None:
|
|
428
|
+
self.setup_done = False
|
|
429
|
+
self.query_one("#setup", Vertical).styles.display = "block"
|
|
430
|
+
self.query_one("#setup-supervisor-model", Input).focus()
|
|
431
|
+
|
|
432
|
+
def action_history_prev(self) -> None:
|
|
433
|
+
input_widget = self.query_one("#input", Input)
|
|
434
|
+
if input_widget.has_focus and self.prompt_history:
|
|
435
|
+
self.history_index = len(self.prompt_history) - 1 if self.history_index is None else max(0, self.history_index - 1)
|
|
436
|
+
input_widget.value = self.prompt_history[self.history_index]
|
|
437
|
+
input_widget.cursor_position = len(input_widget.value)
|
|
438
|
+
|
|
439
|
+
def action_history_next(self) -> None:
|
|
440
|
+
input_widget = self.query_one("#input", Input)
|
|
441
|
+
if input_widget.has_focus and self.prompt_history and self.history_index is not None:
|
|
442
|
+
if self.history_index >= len(self.prompt_history) - 1:
|
|
443
|
+
self.history_index = None
|
|
444
|
+
input_widget.value = ""
|
|
445
|
+
else:
|
|
446
|
+
self.history_index += 1
|
|
447
|
+
input_widget.value = self.prompt_history[self.history_index]
|
|
448
|
+
input_widget.cursor_position = len(input_widget.value)
|
|
449
|
+
|
|
450
|
+
def action_interrupt_or_quit(self) -> None:
|
|
451
|
+
input_widget = self.query_one("#input", Input)
|
|
452
|
+
now = time.time()
|
|
453
|
+
if input_widget.has_focus and input_widget.value:
|
|
454
|
+
input_widget.value = ""
|
|
455
|
+
self.chat.write("system > Prompt cleared. Press Ctrl+C again to quit.")
|
|
456
|
+
self.last_ctrl_c_at = now
|
|
457
|
+
return
|
|
458
|
+
if self.busy:
|
|
459
|
+
self.chat.write("system > Current task cannot be cancelled safely yet. Press Ctrl+C again to quit UI.")
|
|
460
|
+
self.last_ctrl_c_at = now
|
|
461
|
+
return
|
|
462
|
+
if now - self.last_ctrl_c_at < 2.0:
|
|
463
|
+
self.exit()
|
|
464
|
+
else:
|
|
465
|
+
self.chat.write("system > Press Ctrl+C again to quit.")
|
|
466
|
+
self.last_ctrl_c_at = now
|
|
467
|
+
|
|
397
468
|
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
398
469
|
if event.button.id == "setup-save":
|
|
399
470
|
self.action_save_setup()
|
|
@@ -457,14 +528,22 @@ def run_textual_tui(context: McpContext) -> bool:
|
|
|
457
528
|
return
|
|
458
529
|
text = event.value.strip()
|
|
459
530
|
event.input.value = ""
|
|
531
|
+
self.history_index = None
|
|
460
532
|
self.hide_palette()
|
|
461
533
|
if not text:
|
|
462
534
|
return
|
|
463
535
|
if text in {"0", "q", "quit", "exit", "/exit", "/quit"}:
|
|
464
536
|
self.exit()
|
|
465
537
|
return
|
|
538
|
+
if text == "/setup":
|
|
539
|
+
self.show_setup()
|
|
540
|
+
self.chat.write("system > Model setup opened.")
|
|
541
|
+
return
|
|
466
542
|
if text in BUTTONS:
|
|
467
543
|
text = BUTTONS[text]
|
|
544
|
+
if text and (not self.prompt_history or self.prompt_history[-1] != text):
|
|
545
|
+
self.prompt_history.append(text)
|
|
546
|
+
del self.prompt_history[:-100]
|
|
468
547
|
self.chat.write(f"user > {text}")
|
|
469
548
|
if text == "/clear":
|
|
470
549
|
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