agentram 0.1.16 → 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 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", "quit", "Quit"), ("tab", "accept_suggestion", "Complete slash"), ("ctrl+s", "save_setup", "Save setup")]
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,14 +304,44 @@ 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)
307
313
  with Horizontal(id="layout"):
308
314
  yield Static(id="sidebar")
309
315
  with Vertical(id="main"):
316
+ with Vertical(id="setup"):
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")
339
+ with Horizontal(id="setup-actions"):
340
+ yield Button("Save setup", id="setup-save", variant="primary")
341
+ yield Button("Skip", id="setup-skip")
310
342
  yield RichLog(id="chat", wrap=True, highlight=True, markup=False, auto_scroll=True)
311
- yield Input(placeholder="Prompt hoặc /command. Mouse wheel scroll trong khung chat.", id="input")
343
+ yield Static(id="palette")
344
+ yield Input(placeholder="Prompt ho?c /command. Mouse wheel scroll trong khung chat.", id="input")
312
345
  yield Footer()
313
346
 
314
347
  def on_mount(self) -> None:
@@ -318,7 +351,10 @@ def run_textual_tui(context: McpContext) -> bool:
318
351
  self.prefill_setup_from_profile()
319
352
  self.refresh_sidebar()
320
353
  self.set_interval(1.0, self.refresh_sidebar)
321
- self.query_one("#setup-provider", Input).focus()
354
+ try:
355
+ self.query_one("#setup-supervisor-model", Input).focus()
356
+ except Exception: # noqa: BLE001 - fallback if setup is hidden/unmounted
357
+ self.query_one("#input", Input).focus()
322
358
 
323
359
  @property
324
360
  def chat(self) -> RichLog:
@@ -331,30 +367,39 @@ def run_textual_tui(context: McpContext) -> bool:
331
367
  agents = self.context.profile_store.list_agents()
332
368
  if not agents:
333
369
  return
334
- first = next(iter(agents.values()))
335
- self.query_one("#setup-provider", Input).value = first.provider
336
- self.query_one("#setup-base-url", Input).value = first.base_url
337
- self.query_one("#setup-api-key-env", Input).value = first.api_key_env
338
370
  for slot in ("supervisor", "main", "small"):
339
371
  endpoint = agents.get(slot)
340
- if endpoint:
341
- self.query_one(f"#setup-{slot}", Input).value = endpoint.model
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
342
381
  self.chat.write("system > Existing workflow models loaded. Save to update, or Skip to keep.")
343
382
 
344
383
  def setup_value(self, widget_id: str) -> str:
345
384
  return self.query_one(f"#{widget_id}", Input).value.strip()
346
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
+
347
392
  def action_save_setup(self) -> None:
348
- provider = self.setup_value("setup-provider") or "openai-compatible"
349
- base_url = self.setup_value("setup-base-url")
350
- api_key_env = self.setup_value("setup-api-key-env")
351
- if api_key_env.startswith(("sk-", "gsk_", "sk_")):
352
- self.chat.write("agentram > setup error: api key env must be variable name, not raw key")
353
- return
354
393
  role_by_slot = {"supervisor": "supervisor", "main": "coder", "small": "memory"}
355
394
  bound: list[str] = []
356
395
  for slot, role in role_by_slot.items():
357
- model = self.setup_value(f"setup-{slot}")
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
358
403
  if not model:
359
404
  continue
360
405
  endpoint = AgentModelEndpoint(
@@ -366,7 +411,7 @@ def run_textual_tui(context: McpContext) -> bool:
366
411
  modalities=["text"],
367
412
  )
368
413
  self.context.profile_store.set_agent(slot, endpoint)
369
- bound.append(f"{slot}={model}")
414
+ bound.append(f"{slot}={provider}:{model}")
370
415
  if not bound:
371
416
  self.chat.write("agentram > setup skipped: no model entered")
372
417
  else:
@@ -379,6 +424,47 @@ def run_textual_tui(context: McpContext) -> bool:
379
424
  self.setup_done = True
380
425
  self.query_one("#setup", Vertical).styles.display = "none"
381
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
+
382
468
  def on_button_pressed(self, event: Button.Pressed) -> None:
383
469
  if event.button.id == "setup-save":
384
470
  self.action_save_setup()
@@ -442,14 +528,22 @@ def run_textual_tui(context: McpContext) -> bool:
442
528
  return
443
529
  text = event.value.strip()
444
530
  event.input.value = ""
531
+ self.history_index = None
445
532
  self.hide_palette()
446
533
  if not text:
447
534
  return
448
535
  if text in {"0", "q", "quit", "exit", "/exit", "/quit"}:
449
536
  self.exit()
450
537
  return
538
+ if text == "/setup":
539
+ self.show_setup()
540
+ self.chat.write("system > Model setup opened.")
541
+ return
451
542
  if text in BUTTONS:
452
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]
453
547
  self.chat.write(f"user > {text}")
454
548
  if text == "/clear":
455
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
- @dataclass(frozen=True)
17
- class AgentModelEndpoint:
18
- provider: str
19
- model: str
20
- role: str = "coder"
21
- base_url: str = ""
22
- api_key_env: str = ""
23
- modalities: list[str] = field(default_factory=lambda: ["text"])
24
- enabled: bool = True
25
- id: str = field(default_factory=lambda: f"model_{uuid4().hex[:10]}")
26
-
27
- def to_dict(self) -> dict[str, Any]:
28
- return {
29
- "id": self.id,
30
- "provider": self.provider,
31
- "model": self.model,
32
- "role": self.role,
33
- "base_url": self.base_url,
34
- "api_key_env": self.api_key_env,
35
- "modalities": self.modalities,
36
- "enabled": self.enabled,
37
- }
38
-
39
- @classmethod
40
- def from_dict(cls, data: dict[str, Any]) -> "AgentModelEndpoint":
41
- return cls(
42
- id=str(data.get("id") or f"model_{uuid4().hex[:10]}"),
43
- provider=str(data.get("provider", "openai-compatible")),
44
- model=str(data.get("model", "")),
45
- role=str(data.get("role", "coder")),
46
- base_url=str(data.get("base_url", "")),
47
- api_key_env=str(data.get("api_key_env", "")),
48
- modalities=[str(item) for item in data.get("modalities", ["text"])],
49
- enabled=bool(data.get("enabled", True)),
50
- )
51
-
52
-
53
- @dataclass(frozen=True)
54
- class CodingOrchestrationRequest:
55
- task: str
56
- context: str = ""
57
- files: list[str] = field(default_factory=list)
58
- modalities: list[str] = field(default_factory=lambda: ["text"])
59
-
60
- def to_prompt(self, endpoint: AgentModelEndpoint) -> str:
61
- return "\n".join(
62
- [
63
- f"You are AgentRAM {endpoint.role} agent.",
64
- f"Model: {endpoint.model}",
65
- f"Provider: {endpoint.provider}",
66
- f"Modalities: {', '.join(endpoint.modalities)}",
67
- "Task:",
68
- self.task,
69
- "Files:",
70
- "\n".join(f"- {item}" for item in self.files) or "- none provided",
71
- "AgentRAM Context:",
72
- self.context or "No context provided.",
73
- "Output contract:",
74
- "- Return concise coding findings or patch plan.",
75
- "- Mention files to inspect or change.",
76
- "- Do not invent repo facts.",
77
- ]
78
- )
79
-
80
-
81
- class MultiModelCodingOrchestrator:
82
- def __init__(self, endpoints: list[AgentModelEndpoint], client: Callable[[AgentModelEndpoint, str], str] | None = None) -> None:
83
- self.endpoints = [endpoint for endpoint in endpoints if endpoint.enabled]
84
- self.client = client or self._call_endpoint
85
-
86
- def build_plan(self, request: CodingOrchestrationRequest) -> dict[str, Any]:
87
- agents = []
88
- for endpoint in self.endpoints:
89
- agents.append({"endpoint": endpoint.to_dict(), "prompt": request.to_prompt(endpoint)})
90
- return {"task": request.task, "agent_count": len(agents), "agents": agents}
91
-
92
- def run(self, request: CodingOrchestrationRequest, max_workers: int = 4) -> dict[str, Any]:
93
- results: list[dict[str, Any]] = []
94
- if not self.endpoints:
95
- return {"task": request.task, "agent_count": 0, "results": []}
96
- with ThreadPoolExecutor(max_workers=max(1, min(max_workers, len(self.endpoints)))) as executor:
97
- future_map = {executor.submit(self.client, endpoint, request.to_prompt(endpoint)): endpoint for endpoint in self.endpoints}
98
- for future in as_completed(future_map):
99
- endpoint = future_map[future]
100
- try:
101
- output = future.result()
102
- results.append({"endpoint": endpoint.to_dict(), "ok": True, "output": output})
103
- except Exception as error: # noqa: BLE001 - agent boundary
104
- results.append({"endpoint": endpoint.to_dict(), "ok": False, "error": str(error)})
105
- return {"task": request.task, "agent_count": len(self.endpoints), "results": results}
106
-
107
- def _call_endpoint(self, endpoint: AgentModelEndpoint, prompt: str) -> str:
108
- provider = endpoint.provider.lower()
109
- if provider in {"openai", "openai-compatible", "groq", "vllm", "lmstudio", "ollama-v1"}:
110
- return self._call_openai_compatible(endpoint, prompt)
111
- if provider in {"anthropic", "claude"}:
112
- return self._call_anthropic(endpoint, prompt)
113
- if provider in {"claude-code", "claude-cli", "claude-subagent", "claude-code-subagent"}:
114
- return self._call_claude_code_subagent(endpoint, prompt)
115
- raise ValueError(f"Unsupported provider for execution: {endpoint.provider}")
116
-
117
- def _call_openai_compatible(self, endpoint: AgentModelEndpoint, prompt: str) -> str:
118
- api_key = os.getenv(endpoint.api_key_env) if endpoint.api_key_env else ""
119
- if not endpoint.base_url:
120
- raise ValueError("base_url is required")
121
- if endpoint.api_key_env and not api_key:
122
- raise ValueError(f"missing API key env: {endpoint.api_key_env}")
123
- payload = {"model": endpoint.model, "messages": [{"role": "user", "content": prompt}], "temperature": 0}
124
- request = urllib.request.Request(
125
- endpoint.base_url.rstrip("/") + "/chat/completions",
126
- data=json.dumps(payload).encode("utf-8"),
127
- headers={"Content-Type": "application/json", "Authorization": f"Bearer {api_key}"} if api_key else {"Content-Type": "application/json"},
128
- method="POST",
129
- )
130
- url = endpoint.base_url.rstrip("/") + "/chat/completions"
131
- try:
132
- with urllib.request.urlopen(request, timeout=120) as response:
133
- raw = response.read().decode("utf-8")
134
- except urllib.error.HTTPError as error:
135
- body = error.read().decode("utf-8", errors="replace") if error.fp else ""
136
- hint = f"OpenAI-compatible HTTP {error.code} at {url}"
137
- if error.code == 404:
138
- hint += " | base_url should include /v1 but not /chat/completions; verify provider URL and model name"
139
- raise RuntimeError(f"{hint} | response={body[:500]}") from error
140
- try:
141
- data = json.loads(raw)
142
- except json.JSONDecodeError as error:
143
- streamed = parse_openai_sse(raw)
144
- if streamed:
145
- return streamed
146
- raise RuntimeError(f"OpenAI-compatible returned non-JSON at {url} | response={raw[:500]}") from error
147
- return str(data.get("choices", [{}])[0].get("message", {}).get("content", ""))
148
-
149
- def _call_anthropic(self, endpoint: AgentModelEndpoint, prompt: str) -> str:
150
- api_key = os.getenv(endpoint.api_key_env) if endpoint.api_key_env else ""
151
- if endpoint.api_key_env and not api_key:
152
- raise ValueError(f"missing API key env: {endpoint.api_key_env}")
153
- base_url = endpoint.base_url.rstrip("/") if endpoint.base_url else "https://api.anthropic.com/v1"
154
- payload = {"model": endpoint.model, "max_tokens": 1024, "messages": [{"role": "user", "content": prompt}]}
155
- request = urllib.request.Request(
156
- base_url + "/messages",
157
- data=json.dumps(payload).encode("utf-8"),
158
- headers={"Content-Type": "application/json", "x-api-key": api_key, "anthropic-version": "2023-06-01"},
159
- method="POST",
160
- )
161
- with urllib.request.urlopen(request, timeout=120) as response:
162
- data = json.loads(response.read().decode("utf-8"))
163
- content = data.get("content", [])
164
- if content and isinstance(content, list):
165
- return "\n".join(str(item.get("text", "")) for item in content if isinstance(item, dict))
166
- return str(data)
167
-
168
- def _call_claude_code_subagent(self, endpoint: AgentModelEndpoint, prompt: str) -> str:
169
- command = endpoint.base_url.strip() or os.getenv("AGENTRAM_CLAUDE_COMMAND", "claude")
170
- args = shlex.split(command)
171
- if not args:
172
- raise ValueError("claude command is required")
173
- subagent_prompt = "\n".join(
174
- [
175
- f"Use Claude Code subagent entrypoint: {endpoint.model}",
176
- f"Subagent role: {endpoint.role}",
177
- "If that subagent exists in .claude/agents, follow it. If not, act as this role.",
178
- "Return concise coding-agent output only.",
179
- "",
180
- prompt,
181
- ]
182
- )
183
- process = subprocess.run(
184
- [*args, "-p", subagent_prompt],
185
- check=False,
186
- capture_output=True,
187
- encoding="utf-8",
188
- timeout=300,
189
- )
190
- output = (process.stdout or "").strip()
191
- error = (process.stderr or "").strip()
192
- if process.returncode != 0:
193
- raise RuntimeError(error or f"claude command failed with exit code {process.returncode}")
194
- return output or error
195
-
196
- def parse_openai_sse(raw: str) -> str:
197
- parts: list[str] = []
198
- for line in raw.splitlines():
199
- stripped = line.strip()
200
- if not stripped.startswith("data:"):
201
- continue
202
- payload = stripped[5:].strip()
203
- if not payload or payload == "[DONE]":
204
- continue
205
- try:
206
- item = json.loads(payload)
207
- except json.JSONDecodeError:
208
- continue
209
- choices = item.get("choices", [])
210
- if not choices or not isinstance(choices, list):
211
- continue
212
- choice = choices[0]
213
- if not isinstance(choice, dict):
214
- continue
215
- delta = choice.get("delta", {})
216
- if isinstance(delta, dict):
217
- content = delta.get("content")
218
- if content:
219
- parts.append(str(content))
220
- message = choice.get("message", {})
221
- if isinstance(message, dict) and message.get("content"):
222
- parts.append(str(message.get("content")))
223
- return strip_thinking_blocks("".join(parts)).strip()
224
-
225
- def strip_thinking_blocks(text: str) -> str:
226
- text = re.sub(r"<thinking>.*?</thinking>", "", text, flags=re.DOTALL | re.IGNORECASE)
227
- text = re.sub(r"<think>.*?</think>", "", text, flags=re.DOTALL | re.IGNORECASE)
228
- text = re.sub(r"^\s*</?(thinking|think)>\s*", "", text, flags=re.IGNORECASE)
229
- return text.strip()
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentram",
3
- "version": "0.1.16",
3
+ "version": "0.1.20",
4
4
  "description": "Async, model-agnostic Working RAM for coding agents.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/trancongnghia/AGENT_RAM#readme",
package/pyproject.toml CHANGED
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "agentram"
7
- version = "0.1.16"
7
+ version = "0.1.20"
8
8
  description = "Async, model-agnostic RAM layer for agentic coding tools."
9
9
  readme = "README.md"
10
10
  license = "MIT"