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 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", "quit", "Quit"), ("tab", "accept_suggestion", "Complete slash"), ("ctrl+s", "save_setup", "Save setup")]
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 ? bind supervisor/main/small before prompt", id="setup-title")
312
- yield Input(value="openai-compatible", placeholder="Provider, e.g. openai-compatible / claude / claude-subagent", id="setup-provider", classes="setup-input")
313
- yield Input(placeholder="Base URL or command, e.g. http://localhost:20128/v1", id="setup-base-url", classes="setup-input")
314
- yield Input(value="OPENAI_API_KEY", placeholder="API key env name, not raw key", id="setup-api-key-env", classes="setup-input")
315
- yield Input(placeholder="Supervisor model, e.g. claude-sonnet-4.5-thinking-agentic", id="setup-supervisor", classes="setup-input")
316
- yield Input(placeholder="Main coding model, e.g. cx/gpt5.5", id="setup-main", classes="setup-input")
317
- yield Input(placeholder="Small memory model, e.g. qwen2.5:7b", id="setup-small", classes="setup-input")
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-provider", Input).focus()
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
- self.query_one(f"#setup-{slot}", Input).value = endpoint.model
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
- model = self.setup_value(f"setup-{slot}")
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
- bound.append(f"{slot}={model}")
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
- @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.17",
3
+ "version": "0.1.21",
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.17"
7
+ version = "0.1.21"
8
8
  description = "Async, model-agnostic RAM layer for agentic coding tools."
9
9
  readme = "README.md"
10
10
  license = "MIT"