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 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,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 ? 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")
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-provider", Input).focus()
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
- 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
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
- 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
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
- @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.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.17"
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"