agentram 0.1.1 → 0.1.3

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.
@@ -0,0 +1,15 @@
1
+ ---
2
+ description: Build or run AgentRAM multi-model coding orchestration.
3
+ argument-hint: [task]
4
+ ---
5
+
6
+ Use AgentRAM multi-model orchestration for this task.
7
+
8
+ 1. Call `agentram_read_coding_models`.
9
+ 2. If no endpoints exist, ask user to bind a model with `agentram_bind_coding_model`.
10
+ 3. Call `agentram_build_coding_orchestration` with:
11
+ - `task`: `$ARGUMENTS`
12
+ - `files`: relevant files if known
13
+ - `modalities`: `["text"]` unless images/audio are included
14
+ 4. If user explicitly requests execution, call `agentram_run_coding_orchestration` with `execute: true`.
15
+ 5. Summarize per-model outputs and disagreements.
package/README.md CHANGED
@@ -170,7 +170,7 @@ Run TUI:
170
170
  agentram tui
171
171
  ```
172
172
 
173
- Supported TUI slash commands:
173
+ Supported TUI slash commands. Plain text only records/retrieves RAM; use `/ask` to call bound models:
174
174
 
175
175
  ```text
176
176
  /help
@@ -352,6 +352,54 @@ Minimum usage pattern:
352
352
  3. Call `agentram_check_goal_drift` before switching direction.
353
353
  4. Call `agentram_retrieve` again near context pressure or resume.
354
354
 
355
+
356
+ ## Multi-Model Coding Orchestration
357
+
358
+ AgentRAM can bind multiple coding model endpoints and let Claude Code or MCP clients orchestrate them in parallel. This keeps the `free-code` style command registry, but adds project RAM, Goal Stack, and multi-agent model routing.
359
+
360
+ Bind models:
361
+
362
+ ```bash
363
+ agentram bind-model claude claude-3-5-sonnet-latest --role reviewer --api-key-env ANTHROPIC_API_KEY
364
+ agentram bind-model openai-compatible qwen2.5-coder --role coder --base-url http://localhost:8000/v1 --api-key-env OPENAI_API_KEY
365
+ ```
366
+
367
+ Build a dry-run orchestration plan:
368
+
369
+ ```bash
370
+ agentram orchestrate "review CLI orchestration" --file agentram/cli.py
371
+ ```
372
+
373
+ Run endpoints concurrently when keys and base URLs are configured:
374
+
375
+ ```bash
376
+ agentram orchestrate "review CLI orchestration" --file agentram/cli.py --execute
377
+ ```
378
+
379
+ Claude/MCP tools:
380
+
381
+ ```text
382
+ agentram_bind_coding_model
383
+ agentram_read_coding_models
384
+ agentram_build_coding_orchestration
385
+ agentram_run_coding_orchestration
386
+ ```
387
+
388
+ TUI slash commands. Plain text only records/retrieves RAM; use `/ask` to call bound models:
389
+
390
+ ```text
391
+ /bind-model claude claude-3-5-sonnet-latest role=reviewer api_key_env=ANTHROPIC_API_KEY
392
+ /models
393
+ /orchestrate review CLI orchestration
394
+ /ask xin chào
395
+ ```
396
+
397
+ Storage:
398
+
399
+ ```text
400
+ .agentram/agent_profile.json
401
+ ```
402
+
355
403
  ## MCP Tools
356
404
 
357
405
  | Tool | Purpose |
@@ -364,6 +412,10 @@ Minimum usage pattern:
364
412
  | `agentram_read_goal_state` | Read mission, goals, current task, histories. |
365
413
  | `agentram_update_goal_state` | Update Goal Stack fields. |
366
414
  | `agentram_check_goal_drift` | Warn only when task appears off-goal. |
415
+ | `agentram_bind_coding_model` | Bind provider/model/role/modalities endpoint. |
416
+ | `agentram_read_coding_models` | Read bound coding model endpoints. |
417
+ | `agentram_build_coding_orchestration` | Build multi-agent prompts without model calls. |
418
+ | `agentram_run_coding_orchestration` | Dry-run or execute endpoints concurrently. |
367
419
 
368
420
  ### Init
369
421
 
@@ -793,3 +845,28 @@ Use `.env.example` as the shareable template.
793
845
  - Model-based ingestion is best run through daemon/wrapper configuration.
794
846
  - Wrapper CLI records command-level events, not deep internal agent tool events.
795
847
  - Rich file/tool hooks need direct agent hooks or disciplined MCP calls.
848
+
849
+
850
+ ### Why TUI May Not Respond Like A Model
851
+
852
+ Plain text in `agentram tui` records a user message and retrieves RAM context. It does not call paid/local models by default. To get a model response:
853
+
854
+ 1. Put the real API key in an environment variable.
855
+ 2. Bind the model using the env var name, not the raw key.
856
+ 3. Use `/ask` or `agentram orchestrate --execute`.
857
+
858
+ Example:
859
+
860
+ ```bash
861
+ set OPENAI_API_KEY=sk-your-key
862
+ agentram tui
863
+ ```
864
+
865
+ Inside TUI:
866
+
867
+ ```text
868
+ /bind-model openai-compatible Agentic --role coder --base-url http://localhost:8000/v1 --api-key-env OPENAI_API_KEY
869
+ /ask xin chào
870
+ ```
871
+
872
+ Do not paste raw API keys into `/bind-model`. `api_key_env` means environment variable name, e.g. `OPENAI_API_KEY`.
@@ -1,6 +1,7 @@
1
1
  from .adapter import AnthropicMemoryAdapter, HeuristicMemoryAdapter, OllamaMemoryAdapter, OpenAICompatibleMemoryAdapter
2
2
  from .config import anthropic_from_env, load_env_file, merged_env, openai_compatible_from_env
3
3
  from .daemon import AgentRAMDaemon
4
+ from .orchestration import AgentModelEndpoint, CodingOrchestrationRequest, MultiModelCodingOrchestrator
4
5
  from .orchestrator import AgentRAM
5
6
  from .retriever import MemoryRetriever
6
7
  from .schema import Event, EventType, MemoryItem, MemoryPatch, MemoryScope, MemoryType
@@ -8,6 +9,8 @@ from .schema import Event, EventType, MemoryItem, MemoryPatch, MemoryScope, Memo
8
9
  __all__ = [
9
10
  "AgentRAM",
10
11
  "AgentRAMDaemon",
12
+ "AgentModelEndpoint",
13
+ "CodingOrchestrationRequest",
11
14
  "AnthropicMemoryAdapter",
12
15
  "anthropic_from_env",
13
16
  "Event",
@@ -20,6 +23,7 @@ __all__ = [
20
23
  "MemoryRetriever",
21
24
  "MemoryScope",
22
25
  "MemoryType",
26
+ "MultiModelCodingOrchestrator",
23
27
  "OllamaMemoryAdapter",
24
28
  "openai_compatible_from_env",
25
29
  "OpenAICompatibleMemoryAdapter",
package/agentram/cli.py CHANGED
@@ -30,6 +30,10 @@ SLASH_HELP = """Slash commands:
30
30
  /replay Replay events into memory
31
31
  /agents List subagent presets
32
32
  /agent <name> <task> Build Claude Code subagent prompt
33
+ /models List bound coding models
34
+ /bind-model provider model Bind model endpoint
35
+ /orchestrate <task> Build multi-model agent plan
36
+ /ask <prompt> Execute bound models and return outputs
33
37
  /claude-install [path] Install .claude commands and agents
34
38
  /note <text> Save decision note
35
39
  /clear Clear chat
@@ -48,6 +52,20 @@ def build_parser() -> argparse.ArgumentParser:
48
52
  subparsers.add_parser("status", help="Show RAM file and item counts.")
49
53
  subparsers.add_parser("goal", help="Show goal stack.")
50
54
  subparsers.add_parser("agents", help="List subagent presets.")
55
+ subparsers.add_parser("models", help="List bound coding model endpoints.")
56
+
57
+ bind_model = subparsers.add_parser("bind-model", help="Bind a coding model endpoint.")
58
+ bind_model.add_argument("provider", help="Provider name, e.g. claude, openai-compatible, groq.")
59
+ bind_model.add_argument("model", help="Model name.")
60
+ bind_model.add_argument("--role", default="coder", help="Agent role.")
61
+ bind_model.add_argument("--base-url", default="", help="Provider base URL.")
62
+ bind_model.add_argument("--api-key-env", default="", help="Environment variable containing API key.")
63
+ bind_model.add_argument("--modalities", default="text", help="Comma-separated modalities.")
64
+
65
+ orchestrate = subparsers.add_parser("orchestrate", help="Build or run multi-model coding orchestration.")
66
+ orchestrate.add_argument("task", nargs="?", default="", help="Coding task.")
67
+ orchestrate.add_argument("--file", action="append", default=[], help="File path to include.")
68
+ orchestrate.add_argument("--execute", action="store_true", help="Call model endpoints concurrently.")
51
69
 
52
70
  retrieve = subparsers.add_parser("retrieve", help="Retrieve memory context.")
53
71
  retrieve.add_argument("query", nargs="?", default="", help="Search query.")
@@ -111,6 +129,16 @@ def run_command(args: argparse.Namespace) -> int:
111
129
  if command == "agents":
112
130
  print(agents_text())
113
131
  return 0
132
+ if command == "models":
133
+ print(models_text(context))
134
+ return 0
135
+ if command == "bind-model":
136
+ print(bind_model_text(context, vars(args)))
137
+ return 0
138
+ if command == "orchestrate":
139
+ task = args.task or input("Task > ").strip()
140
+ print(orchestrate_text(context, task, files=args.file, execute=args.execute))
141
+ return 0
114
142
  if command == "claude-install":
115
143
  print(install_claude_templates_text(Path(args.target), force=args.force))
116
144
  return 0
@@ -182,6 +210,18 @@ def handle_slash_command(context: McpContext, text: str, messages: list[str] | N
182
210
  return replay_text(context, {})
183
211
  if command == "/agents":
184
212
  return agents_text()
213
+ if command == "/models":
214
+ return models_text(context)
215
+ if command == "/bind-model":
216
+ if len(rest) < 2:
217
+ return "Usage: /bind-model <provider> <model> [--role coder] [--base-url URL] [--api-key-env ENV_NAME]"
218
+ return bind_model_text(context, parse_bind_model_args(rest))
219
+ if command == "/orchestrate":
220
+ execute = "--execute" in rest
221
+ task = raw_rest.replace("--execute", "").strip()
222
+ return orchestrate_text(context, task, execute=execute)
223
+ if command == "/ask":
224
+ return orchestrate_text(context, raw_rest, execute=True)
185
225
  if command == "/agent":
186
226
  if not rest:
187
227
  return "Usage: /agent <memory|planner|reviewer|documenter> <task>"
@@ -298,6 +338,80 @@ def agent_prompt_text(context: McpContext, name: str, task: str) -> str:
298
338
 
299
339
 
300
340
 
341
+
342
+ def models_text(context: McpContext) -> str:
343
+ data = payload(call_tool(context, "agentram_read_coding_models", {}))
344
+ endpoints = data.get("endpoints", [])
345
+ if not endpoints:
346
+ return "No coding models bound. Use /bind-model claude claude-3-5-sonnet-latest role=reviewer api_key_env=ANTHROPIC_API_KEY"
347
+ lines = ["Bound coding models:"]
348
+ for endpoint in endpoints:
349
+ lines.append(f"- {endpoint.get('id')} provider={endpoint.get('provider')} model={endpoint.get('model')} role={endpoint.get('role')} modalities={','.join(endpoint.get('modalities', []))}")
350
+ return "\n".join(lines)
351
+
352
+
353
+ def parse_bind_model_args(parts: list[str]) -> dict[str, object]:
354
+ options: dict[str, object] = {"provider": parts[0], "model": parts[1]}
355
+ index = 2
356
+ while index < len(parts):
357
+ item = parts[index]
358
+ if item.startswith("--"):
359
+ key = item[2:].replace("-", "_")
360
+ if index + 1 < len(parts) and not parts[index + 1].startswith("--"):
361
+ options[key] = parts[index + 1]
362
+ index += 2
363
+ else:
364
+ options[key] = True
365
+ index += 1
366
+ elif "=" in item:
367
+ key, value = item.split("=", 1)
368
+ options[key.replace("-", "_")] = value
369
+ index += 1
370
+ else:
371
+ index += 1
372
+ return options
373
+
374
+
375
+ def bind_model_text(context: McpContext, options: dict[str, object]) -> str:
376
+ api_key_env = str(options.get("api_key_env", ""))
377
+ if api_key_env.startswith(("sk-", "gsk_", "sk_")):
378
+ return "Do not paste raw API key into api_key_env. Set env var first, e.g. OPENAI_API_KEY=sk-..., then use api_key_env=OPENAI_API_KEY."
379
+ modalities_value = str(options.get("modalities", "text"))
380
+ arguments = {
381
+ "id": str(options.get("id", "")) or None,
382
+ "provider": str(options.get("provider", "openai-compatible")),
383
+ "model": str(options.get("model", "")),
384
+ "role": str(options.get("role", "coder")),
385
+ "base_url": str(options.get("base_url", "")),
386
+ "api_key_env": str(options.get("api_key_env", "")),
387
+ "modalities": [item.strip() for item in modalities_value.split(",") if item.strip()],
388
+ "enabled": bool(options.get("enabled", True)),
389
+ }
390
+ arguments = {key: value for key, value in arguments.items() if value is not None}
391
+ data = payload(call_tool(context, "agentram_bind_coding_model", arguments))
392
+ endpoint = data.get("endpoint", {})
393
+ return f"Bound model: {endpoint.get('id')} provider={endpoint.get('provider')} model={endpoint.get('model')} role={endpoint.get('role')}"
394
+
395
+
396
+ def orchestrate_text(context: McpContext, task: str, files: list[str] | None = None, execute: bool = False) -> str:
397
+ if not task.strip():
398
+ return "Usage: /orchestrate <coding task>"
399
+ tool = "agentram_run_coding_orchestration" if execute else "agentram_build_coding_orchestration"
400
+ data = payload(call_tool(context, tool, {"task": task, "files": files or [], "execute": execute}))
401
+ if not execute:
402
+ lines = [f"Orchestration plan: agents={data.get('agent_count', 0)}"]
403
+ for agent in data.get("agents", []):
404
+ endpoint = agent.get("endpoint", {})
405
+ lines.append(f"- {endpoint.get('id')} {endpoint.get('provider')}:{endpoint.get('model')} role={endpoint.get('role')}")
406
+ return "\n".join(lines)
407
+ lines = [f"Orchestration run: agents={data.get('agent_count', 0)}"]
408
+ for result in data.get("results", []):
409
+ endpoint = result.get("endpoint", {})
410
+ status = "ok" if result.get("ok") else "error"
411
+ output = result.get("output") or result.get("error", "")
412
+ lines.append(f"- {endpoint.get('id')} {status}: {str(output)[:300]}")
413
+ return "\n".join(lines)
414
+
301
415
  def claude_template_root() -> Path:
302
416
  return Path(__file__).resolve().parent.parent / ".claude"
303
417
 
@@ -413,8 +527,10 @@ def sidebar_lines(context: McpContext) -> list[str]:
413
527
  "",
414
528
  "CLAUDE",
415
529
  " /claude-install .",
416
- " /agents",
417
- " /agent reviewer <task>",
530
+ " /models",
531
+ " /bind-model claude <model>",
532
+ " /orchestrate <task>",
533
+ " /ask <prompt>",
418
534
  ]
419
535
 
420
536
 
@@ -425,7 +541,7 @@ def trim(text: str, limit: int) -> str:
425
541
  def menu_lines() -> list[str]:
426
542
  return [
427
543
  "1=/init 2=/status 3=/retrieve 4=/goal 5=/set-goal 6=/drift 7=/events 8=/replay 9=/help",
428
- "Plain text captures coding request. Claude: /claude-install ., /agents, /agent reviewer <task>",
544
+ "Plain text saves RAM only. Use /ask <prompt> to call bound models.",
429
545
  ]
430
546
 
431
547
 
@@ -11,9 +11,10 @@ from typing import Any
11
11
  from .adapter import HeuristicMemoryAdapter
12
12
  from .config import default_ram_root
13
13
  from .merger import MemoryMerger
14
+ from .orchestration import AgentModelEndpoint, CodingOrchestrationRequest, MultiModelCodingOrchestrator
14
15
  from .retriever import MemoryRetriever
15
16
  from .schema import Event, EventType
16
- from .storage import JsonGoalStore, JsonMemoryStore, JsonlEventLog, ensure_ram_storage
17
+ from .storage import JsonAgentProfileStore, JsonGoalStore, JsonMemoryStore, JsonlEventLog, ensure_ram_storage
17
18
 
18
19
  JSONRPC_VERSION = "2.0"
19
20
  SERVER_NAME = "agentram"
@@ -43,6 +44,10 @@ class McpContext:
43
44
  def goal_store(self) -> JsonGoalStore:
44
45
  return JsonGoalStore(self.ram_root / "goal_state.json")
45
46
 
47
+ @property
48
+ def profile_store(self) -> JsonAgentProfileStore:
49
+ return JsonAgentProfileStore(self.ram_root / "agent_profile.json")
50
+
46
51
  @property
47
52
  def merger(self) -> MemoryMerger:
48
53
  return MemoryMerger(self.memory_store)
@@ -220,6 +225,60 @@ def tools_list() -> dict[str, Any]:
220
225
  },
221
226
  },
222
227
  },
228
+
229
+ {
230
+ "name": "agentram_bind_coding_model",
231
+ "description": "Bind a coding-agent model endpoint that Claude or other agents can orchestrate later.",
232
+ "inputSchema": {
233
+ "type": "object",
234
+ "properties": {
235
+ "id": {"type": "string"},
236
+ "provider": {"type": "string", "default": "openai-compatible"},
237
+ "model": {"type": "string"},
238
+ "role": {"type": "string", "default": "coder"},
239
+ "base_url": {"type": "string"},
240
+ "api_key_env": {"type": "string"},
241
+ "modalities": {"type": "array", "items": {"type": "string"}},
242
+ "enabled": {"type": "boolean", "default": True},
243
+ },
244
+ "required": ["provider", "model"],
245
+ },
246
+ },
247
+ {
248
+ "name": "agentram_read_coding_models",
249
+ "description": "Read coding-agent model endpoints bound to this project RAM.",
250
+ "inputSchema": {"type": "object", "properties": {}},
251
+ },
252
+ {
253
+ "name": "agentram_build_coding_orchestration",
254
+ "description": "Build prompts for all enabled coding model endpoints without calling models.",
255
+ "inputSchema": {
256
+ "type": "object",
257
+ "properties": {
258
+ "task": {"type": "string"},
259
+ "query": {"type": "string"},
260
+ "files": {"type": "array", "items": {"type": "string"}},
261
+ "modalities": {"type": "array", "items": {"type": "string"}},
262
+ },
263
+ "required": ["task"],
264
+ },
265
+ },
266
+ {
267
+ "name": "agentram_run_coding_orchestration",
268
+ "description": "Run enabled coding model endpoints concurrently when execute=true, otherwise return dry-run plan.",
269
+ "inputSchema": {
270
+ "type": "object",
271
+ "properties": {
272
+ "task": {"type": "string"},
273
+ "query": {"type": "string"},
274
+ "files": {"type": "array", "items": {"type": "string"}},
275
+ "modalities": {"type": "array", "items": {"type": "string"}},
276
+ "execute": {"type": "boolean", "default": False},
277
+ "max_workers": {"type": "integer", "default": 4},
278
+ },
279
+ "required": ["task"],
280
+ },
281
+ },
223
282
  {
224
283
  "name": "agentram_check_goal_drift",
225
284
  "description": "Check whether a proposed task aligns with the current Goal Stack. Returns no suggestions when aligned.",
@@ -284,6 +343,32 @@ def call_tool(context: McpContext, name: str, arguments: dict[str, Any]) -> dict
284
343
  state = context.goal_store.update(arguments)
285
344
  return tool_result({"ok": True, "goal_state": state.to_dict(), "context": state.context_block()})
286
345
 
346
+
347
+ if name == "agentram_bind_coding_model":
348
+ endpoint = AgentModelEndpoint.from_dict(arguments)
349
+ data = context.profile_store.upsert(endpoint)
350
+ return tool_result({"ok": True, "endpoint": endpoint.to_dict(), "profile": data})
351
+
352
+ if name == "agentram_read_coding_models":
353
+ endpoints = context.profile_store.list_endpoints()
354
+ return tool_result({"endpoints": [endpoint.to_dict() for endpoint in endpoints]})
355
+
356
+ if name == "agentram_build_coding_orchestration":
357
+ request = orchestration_request_from_args(context, arguments)
358
+ endpoints = context.profile_store.list_endpoints()
359
+ plan = MultiModelCodingOrchestrator(endpoints).build_plan(request)
360
+ return tool_result({"ok": True, "execute": False, **plan})
361
+
362
+ if name == "agentram_run_coding_orchestration":
363
+ request = orchestration_request_from_args(context, arguments)
364
+ endpoints = context.profile_store.list_endpoints()
365
+ orchestrator = MultiModelCodingOrchestrator(endpoints)
366
+ if not bool(arguments.get("execute", False)):
367
+ plan = orchestrator.build_plan(request)
368
+ return tool_result({"ok": True, "execute": False, **plan})
369
+ result = orchestrator.run(request, max_workers=int(arguments.get("max_workers", 4)))
370
+ return tool_result({"ok": True, "execute": True, **result})
371
+
287
372
  if name == "agentram_check_goal_drift":
288
373
  task = str(arguments.get("task", ""))
289
374
  if not task.strip():
@@ -293,6 +378,27 @@ def call_tool(context: McpContext, name: str, arguments: dict[str, Any]) -> dict
293
378
  raise ValueError(f"unknown tool: {name}")
294
379
 
295
380
 
381
+
382
+ def orchestration_request_from_args(context: McpContext, arguments: dict[str, Any]) -> CodingOrchestrationRequest:
383
+ task = str(arguments.get("task", ""))
384
+ if not task.strip():
385
+ raise ValueError("task is required")
386
+ query = str(arguments.get("query") or task)
387
+ files = arguments.get("files", [])
388
+ modalities = arguments.get("modalities", ["text"])
389
+ if not isinstance(files, list):
390
+ raise ValueError("files must be an array")
391
+ if not isinstance(modalities, list):
392
+ raise ValueError("modalities must be an array")
393
+ retriever = MemoryRetriever(context.memory_store, context.goal_store)
394
+ context_block = retriever.context_block(query=query, task_id="codex", limit=8)
395
+ return CodingOrchestrationRequest(
396
+ task=task,
397
+ context=context_block,
398
+ files=[str(item) for item in files],
399
+ modalities=[str(item) for item in modalities],
400
+ )
401
+
296
402
  def handle_request(context: McpContext, request: dict[str, Any]) -> dict[str, Any] | None:
297
403
  request_id = request.get("id")
298
404
  method = request.get("method")
@@ -0,0 +1,145 @@
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 urllib.request
8
+ from typing import Any, Callable
9
+ from uuid import uuid4
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class AgentModelEndpoint:
14
+ provider: str
15
+ model: str
16
+ role: str = "coder"
17
+ base_url: str = ""
18
+ api_key_env: str = ""
19
+ modalities: list[str] = field(default_factory=lambda: ["text"])
20
+ enabled: bool = True
21
+ id: str = field(default_factory=lambda: f"model_{uuid4().hex[:10]}")
22
+
23
+ def to_dict(self) -> dict[str, Any]:
24
+ return {
25
+ "id": self.id,
26
+ "provider": self.provider,
27
+ "model": self.model,
28
+ "role": self.role,
29
+ "base_url": self.base_url,
30
+ "api_key_env": self.api_key_env,
31
+ "modalities": self.modalities,
32
+ "enabled": self.enabled,
33
+ }
34
+
35
+ @classmethod
36
+ def from_dict(cls, data: dict[str, Any]) -> "AgentModelEndpoint":
37
+ return cls(
38
+ id=str(data.get("id") or f"model_{uuid4().hex[:10]}"),
39
+ provider=str(data.get("provider", "openai-compatible")),
40
+ model=str(data.get("model", "")),
41
+ role=str(data.get("role", "coder")),
42
+ base_url=str(data.get("base_url", "")),
43
+ api_key_env=str(data.get("api_key_env", "")),
44
+ modalities=[str(item) for item in data.get("modalities", ["text"])],
45
+ enabled=bool(data.get("enabled", True)),
46
+ )
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class CodingOrchestrationRequest:
51
+ task: str
52
+ context: str = ""
53
+ files: list[str] = field(default_factory=list)
54
+ modalities: list[str] = field(default_factory=lambda: ["text"])
55
+
56
+ def to_prompt(self, endpoint: AgentModelEndpoint) -> str:
57
+ return "\n".join(
58
+ [
59
+ f"You are AgentRAM {endpoint.role} agent.",
60
+ f"Model: {endpoint.model}",
61
+ f"Provider: {endpoint.provider}",
62
+ f"Modalities: {', '.join(endpoint.modalities)}",
63
+ "Task:",
64
+ self.task,
65
+ "Files:",
66
+ "\n".join(f"- {item}" for item in self.files) or "- none provided",
67
+ "AgentRAM Context:",
68
+ self.context or "No context provided.",
69
+ "Output contract:",
70
+ "- Return concise coding findings or patch plan.",
71
+ "- Mention files to inspect or change.",
72
+ "- Do not invent repo facts.",
73
+ ]
74
+ )
75
+
76
+
77
+ class MultiModelCodingOrchestrator:
78
+ def __init__(self, endpoints: list[AgentModelEndpoint], client: Callable[[AgentModelEndpoint, str], str] | None = None) -> None:
79
+ self.endpoints = [endpoint for endpoint in endpoints if endpoint.enabled]
80
+ self.client = client or self._call_endpoint
81
+
82
+ def build_plan(self, request: CodingOrchestrationRequest) -> dict[str, Any]:
83
+ agents = []
84
+ for endpoint in self.endpoints:
85
+ agents.append({"endpoint": endpoint.to_dict(), "prompt": request.to_prompt(endpoint)})
86
+ return {"task": request.task, "agent_count": len(agents), "agents": agents}
87
+
88
+ def run(self, request: CodingOrchestrationRequest, max_workers: int = 4) -> dict[str, Any]:
89
+ results: list[dict[str, Any]] = []
90
+ if not self.endpoints:
91
+ return {"task": request.task, "agent_count": 0, "results": []}
92
+ with ThreadPoolExecutor(max_workers=max(1, min(max_workers, len(self.endpoints)))) as executor:
93
+ future_map = {executor.submit(self.client, endpoint, request.to_prompt(endpoint)): endpoint for endpoint in self.endpoints}
94
+ for future in as_completed(future_map):
95
+ endpoint = future_map[future]
96
+ try:
97
+ output = future.result()
98
+ results.append({"endpoint": endpoint.to_dict(), "ok": True, "output": output})
99
+ except Exception as error: # noqa: BLE001 - agent boundary
100
+ results.append({"endpoint": endpoint.to_dict(), "ok": False, "error": str(error)})
101
+ return {"task": request.task, "agent_count": len(self.endpoints), "results": results}
102
+
103
+ def _call_endpoint(self, endpoint: AgentModelEndpoint, prompt: str) -> str:
104
+ provider = endpoint.provider.lower()
105
+ if provider in {"openai", "openai-compatible", "groq", "vllm", "lmstudio", "ollama-v1"}:
106
+ return self._call_openai_compatible(endpoint, prompt)
107
+ if provider in {"anthropic", "claude"}:
108
+ return self._call_anthropic(endpoint, prompt)
109
+ raise ValueError(f"Unsupported provider for execution: {endpoint.provider}")
110
+
111
+ def _call_openai_compatible(self, endpoint: AgentModelEndpoint, prompt: str) -> str:
112
+ api_key = os.getenv(endpoint.api_key_env) if endpoint.api_key_env else ""
113
+ if not endpoint.base_url:
114
+ raise ValueError("base_url is required")
115
+ if endpoint.api_key_env and not api_key:
116
+ raise ValueError(f"missing API key env: {endpoint.api_key_env}")
117
+ payload = {"model": endpoint.model, "messages": [{"role": "user", "content": prompt}], "temperature": 0}
118
+ request = urllib.request.Request(
119
+ endpoint.base_url.rstrip("/") + "/chat/completions",
120
+ data=json.dumps(payload).encode("utf-8"),
121
+ headers={"Content-Type": "application/json", "Authorization": f"Bearer {api_key}"} if api_key else {"Content-Type": "application/json"},
122
+ method="POST",
123
+ )
124
+ with urllib.request.urlopen(request, timeout=120) as response:
125
+ data = json.loads(response.read().decode("utf-8"))
126
+ return str(data.get("choices", [{}])[0].get("message", {}).get("content", ""))
127
+
128
+ def _call_anthropic(self, endpoint: AgentModelEndpoint, prompt: str) -> str:
129
+ api_key = os.getenv(endpoint.api_key_env) if endpoint.api_key_env else ""
130
+ if endpoint.api_key_env and not api_key:
131
+ raise ValueError(f"missing API key env: {endpoint.api_key_env}")
132
+ base_url = endpoint.base_url.rstrip("/") if endpoint.base_url else "https://api.anthropic.com/v1"
133
+ payload = {"model": endpoint.model, "max_tokens": 1024, "messages": [{"role": "user", "content": prompt}]}
134
+ request = urllib.request.Request(
135
+ base_url + "/messages",
136
+ data=json.dumps(payload).encode("utf-8"),
137
+ headers={"Content-Type": "application/json", "x-api-key": api_key, "anthropic-version": "2023-06-01"},
138
+ method="POST",
139
+ )
140
+ with urllib.request.urlopen(request, timeout=120) as response:
141
+ data = json.loads(response.read().decode("utf-8"))
142
+ content = data.get("content", [])
143
+ if content and isinstance(content, list):
144
+ return "\n".join(str(item.get("text", "")) for item in content if isinstance(item, dict))
145
+ return str(data)
@@ -3,6 +3,7 @@ from __future__ import annotations
3
3
  import json
4
4
  from pathlib import Path
5
5
 
6
+ from .orchestration import AgentModelEndpoint
6
7
  from .schema import Event, GoalState, MemoryItem, utc_now
7
8
 
8
9
 
@@ -74,6 +75,40 @@ class JsonGoalStore:
74
75
  return self.save(GoalState.from_dict(current))
75
76
 
76
77
 
78
+ class JsonAgentProfileStore:
79
+ def __init__(self, path: str | Path) -> None:
80
+ self.path = Path(path)
81
+ self.path.parent.mkdir(parents=True, exist_ok=True)
82
+
83
+ def load(self) -> dict[str, object]:
84
+ if not self.path.exists():
85
+ return {"updated_at": utc_now(), "endpoints": []}
86
+ return json.loads(self.path.read_text(encoding="utf-8"))
87
+
88
+ def save(self, endpoints: list[AgentModelEndpoint]) -> dict[str, object]:
89
+ payload = {"updated_at": utc_now(), "endpoints": [endpoint.to_dict() for endpoint in endpoints]}
90
+ self.path.write_text(json.dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8")
91
+ return payload
92
+
93
+ def list_endpoints(self) -> list[AgentModelEndpoint]:
94
+ data = self.load()
95
+ return [AgentModelEndpoint.from_dict(item) for item in data.get("endpoints", []) if isinstance(item, dict)]
96
+
97
+ def upsert(self, endpoint: AgentModelEndpoint) -> dict[str, object]:
98
+ endpoints = self.list_endpoints()
99
+ replaced = False
100
+ updated: list[AgentModelEndpoint] = []
101
+ for current in endpoints:
102
+ if current.id == endpoint.id:
103
+ updated.append(endpoint)
104
+ replaced = True
105
+ else:
106
+ updated.append(current)
107
+ if not replaced:
108
+ updated.append(endpoint)
109
+ return self.save(updated)
110
+
111
+
77
112
  def ensure_ram_storage(root: str | Path) -> dict[str, object]:
78
113
  ram_root = Path(root)
79
114
  ram_root.mkdir(parents=True, exist_ok=True)
@@ -81,6 +116,7 @@ def ensure_ram_storage(root: str | Path) -> dict[str, object]:
81
116
  events_path = ram_root / "events.jsonl"
82
117
  memory_path = ram_root / "memory.json"
83
118
  goal_path = ram_root / "goal_state.json"
119
+ profile_path = ram_root / "agent_profile.json"
84
120
 
85
121
  created: list[str] = []
86
122
  if not events_path.exists():
@@ -92,6 +128,9 @@ def ensure_ram_storage(root: str | Path) -> dict[str, object]:
92
128
  if not goal_path.exists():
93
129
  JsonGoalStore(goal_path).save(GoalState())
94
130
  created.append("goal_state.json")
131
+ if not profile_path.exists():
132
+ JsonAgentProfileStore(profile_path).save([])
133
+ created.append("agent_profile.json")
95
134
 
96
135
  return {
97
136
  "ram_root": str(ram_root),
@@ -100,5 +139,6 @@ def ensure_ram_storage(root: str | Path) -> dict[str, object]:
100
139
  "events": str(events_path),
101
140
  "memory": str(memory_path),
102
141
  "goal_state": str(goal_path),
142
+ "agent_profile": str(profile_path),
103
143
  },
104
144
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentram",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
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.1"
7
+ version = "0.1.3"
8
8
  description = "Async, model-agnostic RAM layer for agentic coding tools."
9
9
  readme = "README.md"
10
10
  license = "MIT"