agentram 0.1.5 → 0.1.7

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/README.md CHANGED
@@ -358,12 +358,20 @@ Minimum usage pattern:
358
358
 
359
359
  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.
360
360
 
361
- Bind models:
362
-
363
- ```bash
364
- agentram bind-model claude claude-3-5-sonnet-latest --role reviewer --api-key-env ANTHROPIC_API_KEY
365
- agentram bind-model openai-compatible qwen2.5-coder --role coder --base-url http://localhost:8000/v1 --api-key-env OPENAI_API_KEY
366
- ```
361
+ Bind models:
362
+
363
+ ```bash
364
+ agentram bind-model claude-subagent agentram-reviewer --role reviewer
365
+ agentram bind-model claude claude-3-5-sonnet-latest --role reviewer --api-key-env ANTHROPIC_API_KEY
366
+ agentram bind-model openai-compatible qwen2.5-coder --role coder --base-url http://localhost:8000/v1 --api-key-env OPENAI_API_KEY
367
+ ```
368
+
369
+ Claude Code subagent endpoint uses your local Claude CLI entrypoint. `model` is the subagent name from `.claude/agents`, for example `agentram-reviewer` or `agentram-planner`. Default command is `claude -p`. Override command when needed:
370
+
371
+ ```bash
372
+ set AGENTRAM_CLAUDE_COMMAND=claude
373
+ agentram bind-model claude-subagent agentram-reviewer --role reviewer
374
+ ```
367
375
 
368
376
  Build a dry-run orchestration plan:
369
377
 
@@ -388,10 +396,11 @@ agentram_run_coding_orchestration
388
396
 
389
397
  TUI slash commands. Plain text only records/retrieves RAM; use `/ask` to call bound models:
390
398
 
391
- ```text
392
- /bind-model claude claude-3-5-sonnet-latest role=reviewer api_key_env=ANTHROPIC_API_KEY
393
- /models
394
- /orchestrate review CLI orchestration
399
+ ```text
400
+ /bind-model claude-subagent agentram-reviewer role=reviewer
401
+ /bind-model claude claude-3-5-sonnet-latest role=reviewer api_key_env=ANTHROPIC_API_KEY
402
+ /models
403
+ /orchestrate review CLI orchestration
395
404
  /ask xin chào
396
405
  ```
397
406
 
@@ -882,3 +891,110 @@ pip install agentram[tui]
882
891
  ```
883
892
 
884
893
  Without `prompt_toolkit`, AgentRAM still works with basic terminal input; `/` then Enter opens the full slash palette.
894
+
895
+
896
+ ### Fix Bad Model Endpoints
897
+
898
+ If `/ask` shows `base_url is required`, old model endpoints were bound without a URL. Remove or clear them:
899
+
900
+ ```text
901
+ /models
902
+ /remove-model model_xxx
903
+ # or
904
+ /clear-models
905
+ ```
906
+
907
+ If `/ask` shows connection refused, the configured OpenAI-compatible server is not running at `base_url`:
908
+
909
+ ```text
910
+ /bind-model openai-compatible Agentic --role coder --base-url http://localhost:8000/v1 --api-key-env OPENAI_API_KEY
911
+ /ask xin chào
912
+ ```
913
+
914
+ Start your local server first, or change `--base-url` to the provider URL.
915
+
916
+ ## Prompt Router Model
917
+
918
+ AgentRAM can use a separate small router model before normal prompts. The router classifies each prompt, then sends it to the right path.
919
+
920
+ ```text
921
+ coding -> bound coding endpoints / Claude subagents
922
+ planning -> Goal Stack and drift check
923
+ memory -> AgentRAM note ingest
924
+ chat -> RAM retrieve only
925
+ ```
926
+
927
+ Bind a router model:
928
+
929
+ ```text
930
+ /bind-router openai-compatible qwen2.5:1.5b base_url=http://localhost:8000/v1 api_key_env=OPENAI_API_KEY
931
+ /route on
932
+ ```
933
+
934
+ Then prompt without slash:
935
+
936
+ ```text
937
+ fix adapter.py
938
+ c?p nh?t goal stack
939
+ ghi nh? d�ng Claude subagent
940
+ ```
941
+
942
+ Disable auto routing:
943
+
944
+ ```text
945
+ /route off
946
+ ```
947
+
948
+ ## Supervisor + Small Agent Workflow
949
+
950
+ AgentRAM can run as a two-flow coding agent system, not only as a flat multi-model router.
951
+
952
+ ```text
953
+ agentram
954
+ |
955
+ v
956
+ Parse User Request
957
+ |
958
+ v
959
+ Supervisor Memory
960
+ |
961
+ v
962
+ Task Classifier / Supervisor Plan
963
+ |----------------------|
964
+ v v
965
+ Main Agent Small Agent
966
+ (GPT-5.5 Codex) (Qwen Codex)
967
+ planning/coding/fix notes/RAM/coding facts
968
+ | |
969
+ |----------|-----------|
970
+ v
971
+ Merge + Update Memory
972
+ |
973
+ v
974
+ Return Result
975
+ ```
976
+
977
+ Bind workflow agents:
978
+
979
+ ```text
980
+ /bind-agent supervisor claude-subagent agentram-planner
981
+ /bind-agent main openai-compatible gpt-5.5-codex base_url=https://api.example.com/v1 api_key_env=MAIN_AGENT_KEY
982
+ /bind-agent small openai-compatible qwen-codex base_url=http://localhost:8001/v1 api_key_env=SMALL_AGENT_KEY
983
+ /workflow
984
+ ```
985
+
986
+ Then use normal prompts without slash:
987
+
988
+ ```text
989
+ refactor orchestration workflow
990
+ fix bug in adapter.py
991
+ plan next milestone
992
+ ```
993
+
994
+ Runtime behavior:
995
+
996
+ ```text
997
+ Supervisor creates plan -> Main agent executes -> Small agent records RAM notes -> AgentRAM merges result
998
+ ```
999
+
1000
+ Small agent should not own big direction. It records plans, decisions, files, risks, next tasks, and coding facts into RAM.
package/agentram/cli.py CHANGED
@@ -1,15 +1,19 @@
1
1
  from __future__ import annotations
2
2
 
3
- import argparse
4
- import os
5
- import shlex
6
- import shutil
7
- import textwrap
3
+ import argparse
4
+ from concurrent.futures import ThreadPoolExecutor
5
+ import json
6
+ import os
7
+ import shlex
8
+ import shutil
9
+ import textwrap
10
+ import time
8
11
  from pathlib import Path
9
12
  from typing import Any
10
13
 
11
- from .config import default_ram_root
12
- from .mcp_server import McpContext, call_tool
14
+ from .config import default_ram_root
15
+ from .mcp_server import McpContext, call_tool
16
+ from .orchestration import AgentModelEndpoint, MultiModelCodingOrchestrator
13
17
 
14
18
  SUBAGENTS: dict[str, str] = {
15
19
  "memory": "Summarize important facts, decisions, files, risks, and next tasks into AgentRAM events. Do not edit code.",
@@ -31,8 +35,15 @@ SLASH_COMMANDS: list[tuple[str, str]] = [
31
35
  ("/agents", "List Claude-style subagent presets"),
32
36
  ("/agent <name> <task>", "Build Claude Code subagent prompt"),
33
37
  ("/models", "List bound coding models"),
34
- ("/bind-model provider model", "Bind model endpoint"),
35
- ("/orchestrate <task>", "Build multi-model agent plan"),
38
+ ("/remove-model <id>", "Remove bound coding model"),
39
+ ("/disable-model <id>", "Disable bound coding model"),
40
+ ("/clear-models", "Remove all bound coding models"),
41
+ ("/bind-model provider model", "Bind model endpoint"),
42
+ ("/bind-router provider model", "Bind intent router model"),
43
+ ("/bind-agent slot provider model", "Bind supervisor/main/small agent"),
44
+ ("/workflow", "Show supervisor workflow agents"),
45
+ ("/route on|off|status", "Toggle auto prompt routing"),
46
+ ("/orchestrate <task>", "Build multi-model agent plan"),
36
47
  ("/ask <prompt>", "Execute bound models and return outputs"),
37
48
  ("/claude-install [path]", "Install .claude commands and agents"),
38
49
  ("/note <text>", "Save decision note"),
@@ -55,15 +66,22 @@ SLASH_HELP = """Slash commands:
55
66
  /agents List subagent presets
56
67
  /agent <name> <task> Build Claude Code subagent prompt
57
68
  /models List bound coding models
58
- /bind-model provider model Bind model endpoint
59
- /orchestrate <task> Build multi-model agent plan
69
+ /remove-model <id> Remove bound coding model
70
+ /disable-model <id> Disable bound coding model
71
+ /clear-models Remove all bound coding models
72
+ /bind-model provider model Bind model endpoint
73
+ /bind-router provider model Bind intent router model
74
+ /bind-agent slot provider model Bind supervisor/main/small agent
75
+ /workflow Show supervisor workflow agents
76
+ /route on|off|status Toggle auto prompt routing
77
+ /orchestrate <task> Build multi-model agent plan
60
78
  /ask <prompt> Execute bound models and return outputs
61
79
  /claude-install [path] Install .claude commands and agents
62
80
  /note <text> Save decision note
63
81
  /clear Clear chat
64
82
  /exit Quit
65
- Plain text Capture coding chat message and retrieve context
66
- """.strip()
83
+ Plain text Capture coding chat message and retrieve context
84
+ """.strip()
67
85
 
68
86
 
69
87
  def build_parser() -> argparse.ArgumentParser:
@@ -77,14 +95,39 @@ def build_parser() -> argparse.ArgumentParser:
77
95
  subparsers.add_parser("goal", help="Show goal stack.")
78
96
  subparsers.add_parser("agents", help="List subagent presets.")
79
97
  subparsers.add_parser("models", help="List bound coding model endpoints.")
98
+ subparsers.add_parser("clear-models", help="Remove all bound coding model endpoints.")
99
+
100
+ remove_model = subparsers.add_parser("remove-model", help="Remove one bound coding model endpoint.")
101
+ remove_model.add_argument("id", help="Endpoint id.")
102
+
103
+ disable_model = subparsers.add_parser("disable-model", help="Disable one bound coding model endpoint.")
104
+ disable_model.add_argument("id", help="Endpoint id.")
80
105
 
81
106
  bind_model = subparsers.add_parser("bind-model", help="Bind a coding model endpoint.")
82
- bind_model.add_argument("provider", help="Provider name, e.g. claude, openai-compatible, groq.")
83
- bind_model.add_argument("model", help="Model name.")
107
+ bind_model.add_argument("provider", help="Provider name, e.g. claude, openai-compatible, groq, claude-subagent.")
108
+ bind_model.add_argument("model", help="Model name or Claude subagent name.")
84
109
  bind_model.add_argument("--role", default="coder", help="Agent role.")
85
110
  bind_model.add_argument("--base-url", default="", help="Provider base URL.")
86
- bind_model.add_argument("--api-key-env", default="", help="Environment variable containing API key.")
87
- bind_model.add_argument("--modalities", default="text", help="Comma-separated modalities.")
111
+ bind_model.add_argument("--api-key-env", default="", help="Environment variable containing API key.")
112
+ bind_model.add_argument("--modalities", default="text", help="Comma-separated modalities.")
113
+
114
+ bind_router = subparsers.add_parser("bind-router", help="Bind a separate intent router model endpoint.")
115
+ bind_router.add_argument("provider", help="Provider name, e.g. openai-compatible, groq, claude-subagent.")
116
+ bind_router.add_argument("model", help="Router model name.")
117
+ bind_router.add_argument("--base-url", default="", help="Provider base URL or local command for claude-subagent.")
118
+ bind_router.add_argument("--api-key-env", default="", help="Environment variable containing API key.")
119
+
120
+ bind_agent = subparsers.add_parser("bind-agent", help="Bind supervisor/main/small workflow agent.")
121
+ bind_agent.add_argument("slot", choices=["supervisor", "main", "small"], help="Workflow slot.")
122
+ bind_agent.add_argument("provider", help="Provider name, e.g. claude-subagent, openai-compatible, groq.")
123
+ bind_agent.add_argument("model", help="Model or subagent name.")
124
+ bind_agent.add_argument("--base-url", default="", help="Provider base URL or local command for claude-subagent.")
125
+ bind_agent.add_argument("--api-key-env", default="", help="Environment variable containing API key.")
126
+
127
+ subparsers.add_parser("workflow", help="Show supervisor/main/small workflow agents.")
128
+
129
+ route = subparsers.add_parser("route", help="Toggle or show auto prompt routing.")
130
+ route.add_argument("mode", nargs="?", default="status", choices=["on", "off", "status"], help="Auto route mode.")
88
131
 
89
132
  orchestrate = subparsers.add_parser("orchestrate", help="Build or run multi-model coding orchestration.")
90
133
  orchestrate.add_argument("task", nargs="?", default="", help="Coding task.")
@@ -156,9 +199,30 @@ def run_command(args: argparse.Namespace) -> int:
156
199
  if command == "models":
157
200
  print(models_text(context))
158
201
  return 0
159
- if command == "bind-model":
160
- print(bind_model_text(context, vars(args)))
202
+ if command == "remove-model":
203
+ print(remove_model_text(context, args.id))
204
+ return 0
205
+ if command == "disable-model":
206
+ print(disable_model_text(context, args.id))
207
+ return 0
208
+ if command == "clear-models":
209
+ print(clear_models_text(context))
161
210
  return 0
211
+ if command == "bind-model":
212
+ print(bind_model_text(context, vars(args)))
213
+ return 0
214
+ if command == "bind-router":
215
+ print(bind_router_text(context, vars(args)))
216
+ return 0
217
+ if command == "bind-agent":
218
+ print(bind_agent_text(context, vars(args)))
219
+ return 0
220
+ if command == "workflow":
221
+ print(workflow_text(context))
222
+ return 0
223
+ if command == "route":
224
+ print(route_text(context, args.mode))
225
+ return 0
162
226
  if command == "orchestrate":
163
227
  task = args.task or input("Task > ").strip()
164
228
  print(orchestrate_text(context, task, files=args.file, execute=args.execute))
@@ -194,13 +258,24 @@ def run_tui(context: McpContext) -> None:
194
258
  user_input = BUTTONS[user_input]
195
259
  if user_input:
196
260
  messages.append(f"user > {user_input}")
197
- response = handle_chat_input(context, user_input, messages)
261
+ response = run_with_activity(context, messages, user_input)
198
262
  if response == "__clear__":
199
263
  messages.clear()
200
264
  messages.append("system > Chat cleared.")
201
265
  elif response:
202
266
  messages.append("agentram > " + response)
203
- del messages[:-10]
267
+ del messages[:-10]
268
+
269
+ def run_with_activity(context: McpContext, messages: list[str], user_input: str) -> str:
270
+ with ThreadPoolExecutor(max_workers=1) as executor:
271
+ future = executor.submit(handle_chat_input, context, user_input, messages)
272
+ frames = ["thinking", "thinking.", "thinking..", "thinking...", "acting", "acting.", "acting..", "acting..."]
273
+ index = 0
274
+ while not future.done():
275
+ draw_screen(context, messages, activity=f"agentram > {frames[index % len(frames)]}")
276
+ index += 1
277
+ time.sleep(0.18)
278
+ return future.result()
204
279
 
205
280
 
206
281
 
@@ -238,20 +313,24 @@ def read_tui_input(prompt_session: Any | None) -> str:
238
313
  return input("You > ")
239
314
  return prompt_session.prompt("You > ")
240
315
 
241
- def handle_chat_input(context: McpContext, text: str, messages: list[str] | None = None) -> str:
316
+ def handle_chat_input(context: McpContext, text: str, messages: list[str] | None = None) -> str:
242
317
  if not text:
243
318
  return SLASH_HELP
244
319
  if text == "/":
245
320
  return slash_palette_text()
246
321
  if text.startswith("/"):
247
322
  return handle_slash_command(context, text, messages)
248
- call_tool(
249
- context,
250
- "agentram_emit_event",
251
- {"type": "USER_MESSAGE", "payload": {"content": text}, "task_id": "codex", "ingest": False},
252
- )
253
- context_block = retrieve_text(context, text, task_id="codex", limit=6)
254
- return "Coding chat captured. Relevant context:\n" + context_block
323
+ call_tool(
324
+ context,
325
+ "agentram_emit_event",
326
+ {"type": "USER_MESSAGE", "payload": {"content": text}, "task_id": "codex", "ingest": False},
327
+ )
328
+ if context.profile_store.list_agents():
329
+ return supervisor_workflow_text(context, text)
330
+ if context.profile_store.auto_route_enabled():
331
+ return route_plain_prompt_text(context, text)
332
+ context_block = retrieve_text(context, text, task_id="codex", limit=6)
333
+ return "Coding chat captured. Relevant context:\n" + context_block
255
334
 
256
335
 
257
336
  def handle_slash_command(context: McpContext, text: str, messages: list[str] | None = None) -> str:
@@ -282,10 +361,30 @@ def handle_slash_command(context: McpContext, text: str, messages: list[str] | N
282
361
  return agents_text()
283
362
  if command == "/models":
284
363
  return models_text(context)
285
- if command == "/bind-model":
286
- if len(rest) < 2:
287
- return "Usage: /bind-model <provider> <model> [--role coder] [--base-url URL] [--api-key-env ENV_NAME]"
288
- return bind_model_text(context, parse_bind_model_args(rest))
364
+ if command == "/remove-model":
365
+ return remove_model_text(context, raw_rest)
366
+ if command == "/disable-model":
367
+ return disable_model_text(context, raw_rest)
368
+ if command == "/clear-models":
369
+ return clear_models_text(context)
370
+ if command == "/bind-model":
371
+ if len(rest) < 2:
372
+ return "Usage: /bind-model <provider> <model> [--role coder] [--base-url URL_OR_COMMAND] [--api-key-env ENV_NAME]"
373
+ return bind_model_text(context, parse_bind_model_args(rest))
374
+ if command == "/bind-router":
375
+ if len(rest) < 2:
376
+ return "Usage: /bind-router <provider> <model> [--base-url URL_OR_COMMAND] [--api-key-env ENV_NAME]"
377
+ return bind_router_text(context, parse_bind_model_args(rest))
378
+ if command == "/bind-agent":
379
+ if len(rest) < 3:
380
+ return "Usage: /bind-agent <supervisor|main|small> <provider> <model> [--base-url URL_OR_COMMAND] [--api-key-env ENV_NAME]"
381
+ options = parse_bind_model_args(rest[1:])
382
+ options["slot"] = rest[0]
383
+ return bind_agent_text(context, options)
384
+ if command == "/workflow":
385
+ return workflow_text(context)
386
+ if command == "/route":
387
+ return route_text(context, rest[0] if rest else "status")
289
388
  if command == "/orchestrate":
290
389
  execute = "--execute" in rest
291
390
  task = raw_rest.replace("--execute", "").strip()
@@ -439,7 +538,7 @@ def models_text(context: McpContext) -> str:
439
538
  data = payload(call_tool(context, "agentram_read_coding_models", {}))
440
539
  endpoints = data.get("endpoints", [])
441
540
  if not endpoints:
442
- return "No coding models bound. Use /bind-model claude claude-3-5-sonnet-latest role=reviewer api_key_env=ANTHROPIC_API_KEY"
541
+ return "No coding models bound. Use /bind-model claude-subagent agentram-reviewer role=reviewer or /bind-model claude claude-3-5-sonnet-latest role=reviewer api_key_env=ANTHROPIC_API_KEY"
443
542
  lines = ["Bound coding models:"]
444
543
  for endpoint in endpoints:
445
544
  lines.append(f"- {endpoint.get('id')} provider={endpoint.get('provider')} model={endpoint.get('model')} role={endpoint.get('role')} modalities={','.join(endpoint.get('modalities', []))}")
@@ -468,6 +567,28 @@ def parse_bind_model_args(parts: list[str]) -> dict[str, object]:
468
567
  return options
469
568
 
470
569
 
570
+
571
+
572
+ def remove_model_text(context: McpContext, endpoint_id: str) -> str:
573
+ endpoint_id = endpoint_id.strip()
574
+ if not endpoint_id:
575
+ return "Usage: /remove-model <id>"
576
+ data = payload(call_tool(context, "agentram_remove_coding_model", {"id": endpoint_id}))
577
+ return f"Removed models: {data.get('removed', 0)}"
578
+
579
+
580
+ def disable_model_text(context: McpContext, endpoint_id: str) -> str:
581
+ endpoint_id = endpoint_id.strip()
582
+ if not endpoint_id:
583
+ return "Usage: /disable-model <id>"
584
+ data = payload(call_tool(context, "agentram_disable_coding_model", {"id": endpoint_id}))
585
+ return f"Disabled models: {data.get('changed', 0)}"
586
+
587
+
588
+ def clear_models_text(context: McpContext) -> str:
589
+ payload(call_tool(context, "agentram_clear_coding_models", {}))
590
+ return "Cleared all bound coding models."
591
+
471
592
  def bind_model_text(context: McpContext, options: dict[str, object]) -> str:
472
593
  api_key_env = str(options.get("api_key_env", ""))
473
594
  if api_key_env.startswith(("sk-", "gsk_", "sk_")):
@@ -489,7 +610,194 @@ def bind_model_text(context: McpContext, options: dict[str, object]) -> str:
489
610
  return f"Bound model: {endpoint.get('id')} provider={endpoint.get('provider')} model={endpoint.get('model')} role={endpoint.get('role')}"
490
611
 
491
612
 
492
- def orchestrate_text(context: McpContext, task: str, files: list[str] | None = None, execute: bool = False) -> str:
613
+ def bind_agent_text(context: McpContext, options: dict[str, object]) -> str:
614
+ slot = str(options.get("slot", "")).strip().lower()
615
+ if slot not in {"supervisor", "main", "small"}:
616
+ return "Usage: /bind-agent <supervisor|main|small> <provider> <model>"
617
+ api_key_env = str(options.get("api_key_env", ""))
618
+ if api_key_env.startswith(("sk-", "gsk_", "sk_")):
619
+ return "Do not paste raw API key into api_key_env. Set env var first, then use api_key_env=ENV_NAME."
620
+ role = {"supervisor": "supervisor", "main": "coder", "small": "memory"}[slot]
621
+ endpoint = AgentModelEndpoint(
622
+ provider=str(options.get("provider", "openai-compatible")),
623
+ model=str(options.get("model", "")),
624
+ role=role,
625
+ base_url=str(options.get("base_url", "")),
626
+ api_key_env=api_key_env,
627
+ modalities=["text"],
628
+ )
629
+ context.profile_store.set_agent(slot, endpoint)
630
+ return f"Bound {slot} agent: provider={endpoint.provider} model={endpoint.model} role={endpoint.role}"
631
+
632
+ def workflow_text(context: McpContext) -> str:
633
+ agents = context.profile_store.list_agents()
634
+ if not agents:
635
+ return "No supervisor workflow agents bound. Use /bind-agent supervisor|main|small <provider> <model>."
636
+ lines = ["Supervisor workflow agents:"]
637
+ for slot in ["supervisor", "main", "small"]:
638
+ endpoint = agents.get(slot)
639
+ if endpoint:
640
+ lines.append(f"- {slot}: {endpoint.provider}:{endpoint.model} role={endpoint.role}")
641
+ else:
642
+ lines.append(f"- {slot}: not bound")
643
+ return "\n".join(lines)
644
+
645
+ def supervisor_workflow_text(context: McpContext, prompt: str) -> str:
646
+ agents = context.profile_store.list_agents()
647
+ supervisor = agents.get("supervisor")
648
+ main_agent = agents.get("main")
649
+ small_agent = agents.get("small")
650
+ memory_context = retrieve_text(context, prompt, task_id="codex", limit=8)
651
+ plan = supervisor_plan(supervisor, prompt, memory_context)
652
+ outputs: dict[str, str] = {}
653
+ with ThreadPoolExecutor(max_workers=2) as executor:
654
+ futures = {}
655
+ if main_agent:
656
+ futures[executor.submit(call_agent_endpoint, main_agent, build_main_agent_prompt(prompt, plan, memory_context))] = "main"
657
+ if small_agent:
658
+ futures[executor.submit(call_agent_endpoint, small_agent, build_small_agent_prompt(prompt, plan, memory_context))] = "small"
659
+ for future, slot in list(futures.items()):
660
+ try:
661
+ outputs[slot] = future.result()
662
+ except Exception as error: # noqa: BLE001 - agent boundary
663
+ outputs[slot] = f"error: {model_error_hint(str(error))}"
664
+ if not main_agent:
665
+ outputs["main"] = "No main agent bound. Use /bind-agent main <provider> <model>."
666
+ if small_agent and outputs.get("small") and not outputs["small"].startswith("error:"):
667
+ call_tool(
668
+ context,
669
+ "agentram_emit_event",
670
+ {"type": "DECISION", "payload": {"content": outputs["small"]}, "task_id": "codex", "ingest": True},
671
+ )
672
+ return "\n".join(
673
+ [
674
+ "Workflow: supervisor -> main + small -> merge",
675
+ "Supervisor plan:",
676
+ plan,
677
+ "",
678
+ "Main agent result:",
679
+ outputs.get("main", "-"),
680
+ "",
681
+ "Small memory agent notes:",
682
+ outputs.get("small", "No small agent bound."),
683
+ ]
684
+ )
685
+
686
+ def supervisor_plan(supervisor: AgentModelEndpoint | None, prompt: str, memory_context: str) -> str:
687
+ if not supervisor:
688
+ return "No supervisor bound. Fallback plan: handle user request, run main coding agent, ask small agent to update RAM notes."
689
+ try:
690
+ return call_agent_endpoint(supervisor, build_supervisor_prompt(prompt, memory_context))
691
+ except Exception as error: # noqa: BLE001 - supervisor boundary
692
+ return f"Supervisor error: {error}\nFallback plan: handle user request, run main coding agent, ask small agent to update RAM notes."
693
+
694
+ def call_agent_endpoint(endpoint: AgentModelEndpoint, prompt: str) -> str:
695
+ return MultiModelCodingOrchestrator([endpoint])._call_endpoint(endpoint, prompt)
696
+
697
+ def build_supervisor_prompt(prompt: str, memory_context: str) -> str:
698
+ return "\n".join(["You are AgentRAM supervisor. Act like a senior coding-agent boss.", "Create concise plan for main coding agent and small memory agent.", "Return only actionable plan. No markdown table.", "User request:", prompt, "AgentRAM context:", memory_context])
699
+
700
+ def build_main_agent_prompt(prompt: str, plan: str, memory_context: str) -> str:
701
+ return "\n".join(["You are AgentRAM main coding agent.", "Follow supervisor plan. Focus on planning, orchestration, coding, refactor, bug fix, and final result.", "Do not invent repo facts. Mention files to inspect/change.", "User request:", prompt, "Supervisor plan:", plan, "AgentRAM context:", memory_context])
702
+
703
+ def build_small_agent_prompt(prompt: str, plan: str, memory_context: str) -> str:
704
+ return "\n".join(["You are AgentRAM small memory agent.", "Do not edit code. Note plans, decisions, risks, files, next tasks, and coding facts for RAM.", "Return concise memory notes only.", "User request:", prompt, "Supervisor plan:", plan, "AgentRAM context:", memory_context])
705
+
706
+ def bind_router_text(context: McpContext, options: dict[str, object]) -> str:
707
+ api_key_env = str(options.get("api_key_env", ""))
708
+ if api_key_env.startswith(("sk-", "gsk_", "sk_")):
709
+ return "Do not paste raw API key into api_key_env. Set env var first, then use api_key_env=ENV_NAME."
710
+ endpoint = AgentModelEndpoint(
711
+ provider=str(options.get("provider", "openai-compatible")),
712
+ model=str(options.get("model", "")),
713
+ role="router",
714
+ base_url=str(options.get("base_url", "")),
715
+ api_key_env=api_key_env,
716
+ modalities=["text"],
717
+ )
718
+ context.profile_store.set_router(endpoint)
719
+ return f"Bound router: provider={endpoint.provider} model={endpoint.model}"
720
+
721
+ def route_text(context: McpContext, mode: str) -> str:
722
+ normalized = mode.strip().lower() or "status"
723
+ if normalized == "on":
724
+ context.profile_store.set_auto_route(True)
725
+ elif normalized == "off":
726
+ context.profile_store.set_auto_route(False)
727
+ elif normalized != "status":
728
+ return "Usage: /route on|off|status"
729
+ router = context.profile_store.get_router()
730
+ return "\n".join(
731
+ [
732
+ f"Auto route: {'on' if context.profile_store.auto_route_enabled() else 'off'}",
733
+ f"Router model: {router.provider}:{router.model}" if router else "Router model: heuristic fallback",
734
+ ]
735
+ )
736
+
737
+ def route_plain_prompt_text(context: McpContext, prompt: str) -> str:
738
+ decision = classify_prompt_intent(context, prompt)
739
+ intent = str(decision.get("intent", "chat"))
740
+ confidence = float(decision.get("confidence", 0.0))
741
+ prefix = f"Route: {intent} confidence={confidence:.2f}"
742
+ if intent == "coding":
743
+ if context.profile_store.list_endpoints():
744
+ return prefix + "\n" + orchestrate_text(context, prompt, execute=True)
745
+ context_block = retrieve_text(context, prompt, task_id="codex", limit=6)
746
+ return prefix + "\nNo coding models bound. Use /bind-model.\nCoding chat captured. Relevant context:\n" + context_block
747
+ if intent == "planning":
748
+ return prefix + "\n" + drift_text(context, prompt) + "\n\n" + goal_text(context)
749
+ if intent == "memory":
750
+ return prefix + "\n" + note_text(context, prompt)
751
+ context_block = retrieve_text(context, prompt, task_id="codex", limit=6)
752
+ return prefix + "\nCoding chat captured. Relevant context:\n" + context_block
753
+
754
+ def classify_prompt_intent(context: McpContext, prompt: str) -> dict[str, object]:
755
+ router = context.profile_store.get_router()
756
+ if router:
757
+ try:
758
+ response = MultiModelCodingOrchestrator([router])._call_endpoint(router, build_router_prompt(prompt))
759
+ parsed = json.loads(extract_json_object(response))
760
+ if isinstance(parsed, dict) and parsed.get("intent") in {"coding", "planning", "memory", "chat"}:
761
+ return {"intent": parsed.get("intent"), "confidence": float(parsed.get("confidence", 0.0)), "source": "model"}
762
+ except Exception as error: # noqa: BLE001 - router fallback boundary
763
+ return {**heuristic_intent(prompt), "source": "heuristic", "router_error": str(error)}
764
+ return {**heuristic_intent(prompt), "source": "heuristic"}
765
+
766
+ def build_router_prompt(prompt: str) -> str:
767
+ return "\n".join(
768
+ [
769
+ "Classify user prompt for AgentRAM routing.",
770
+ "Return only JSON, no markdown.",
771
+ "Schema: {\"intent\":\"coding|planning|memory|chat\",\"confidence\":0.0,\"reason\":\"short\"}",
772
+ "coding = edit/review/debug/refactor/test/code/file requests.",
773
+ "planning = goal stack, milestone, mission, next task, drift, roadmap.",
774
+ "memory = remember/note/decision/fact to save.",
775
+ "chat = other normal conversation.",
776
+ f"Prompt: {prompt}",
777
+ ]
778
+ )
779
+
780
+ def extract_json_object(text: str) -> str:
781
+ start = text.find("{")
782
+ end = text.rfind("}")
783
+ if start == -1 or end == -1 or end < start:
784
+ return "{}"
785
+ return text[start : end + 1]
786
+
787
+ def heuristic_intent(prompt: str) -> dict[str, object]:
788
+ lower = prompt.lower()
789
+ memory_words = ["ghi nhớ", "remember", "note", "decision", "lưu lại", "save this"]
790
+ planning_words = ["goal", "kế hoạch", "ke hoach", "milestone", "mission", "drift", "next task", "roadmap", "mục tiêu", "muc tieu"]
791
+ coding_words = ["sửa", "sua", "fix", "bug", "code", "test", "refactor", "review", "file", ".py", ".js", "endpoint", "class", "function"]
792
+ if any(word in lower for word in memory_words):
793
+ return {"intent": "memory", "confidence": 0.75}
794
+ if any(word in lower for word in planning_words):
795
+ return {"intent": "planning", "confidence": 0.75}
796
+ if any(word in lower for word in coding_words):
797
+ return {"intent": "coding", "confidence": 0.72}
798
+ return {"intent": "chat", "confidence": 0.55}
799
+
800
+ def orchestrate_text(context: McpContext, task: str, files: list[str] | None = None, execute: bool = False) -> str:
493
801
  if not task.strip():
494
802
  return "Usage: /orchestrate <coding task>"
495
803
  tool = "agentram_run_coding_orchestration" if execute else "agentram_build_coding_orchestration"
@@ -505,9 +813,24 @@ def orchestrate_text(context: McpContext, task: str, files: list[str] | None = N
505
813
  endpoint = result.get("endpoint", {})
506
814
  status = "ok" if result.get("ok") else "error"
507
815
  output = result.get("output") or result.get("error", "")
508
- lines.append(f"- {endpoint.get('id')} {status}: {str(output)[:300]}")
816
+ lines.append(f"- {endpoint.get('id')} {status}: {model_error_hint(str(output))[:360]}")
509
817
  return "\n".join(lines)
510
818
 
819
+
820
+ def model_error_hint(message: str) -> str:
821
+ lower = message.lower()
822
+ if "base_url is required" in lower:
823
+ return message + " | Fix: /remove-model <id> or re-bind with --base-url http://localhost:8000/v1"
824
+ if "actively refused" in lower or "connection refused" in lower or "winerror 10061" in lower:
825
+ return message + " | Fix: start your OpenAI-compatible server at base_url, or change --base-url."
826
+ if "http 404" in lower or "404: not found" in lower:
827
+ return message + " | Fix: verify base_url includes /v1, provider supports /chat/completions, and model name exists."
828
+ if "returned non-json" in lower or "expecting value" in lower:
829
+ return message + " | Fix: endpoint returned empty/HTML/non-JSON. Check base_url, auth key, proxy, and server logs."
830
+ if "missing api key env" in lower:
831
+ return message + " | Fix: set environment variable, then bind using --api-key-env ENV_NAME, not raw key."
832
+ return message
833
+
511
834
  def claude_template_root() -> Path:
512
835
  return Path(__file__).resolve().parent.parent / ".claude"
513
836
 
@@ -574,7 +897,7 @@ BUTTONS = {
574
897
  }
575
898
 
576
899
 
577
- def draw_screen(context: McpContext, messages: list[str]) -> None:
900
+ def draw_screen(context: McpContext, messages: list[str], activity: str | None = None) -> None:
578
901
  clear_terminal()
579
902
  width = min(max(shutil.get_terminal_size((110, 34)).columns, 86), 132)
580
903
  left_width = 30
@@ -584,14 +907,16 @@ def draw_screen(context: McpContext, messages: list[str]) -> None:
584
907
  print("+" + "-" * (width - 2) + "+")
585
908
 
586
909
  sidebar = sidebar_lines(context)
587
- chat = wrap_messages(messages, right_width - 4)[-22:]
910
+ display_messages = [*messages, activity] if activity else messages
911
+ chat = wrap_messages(display_messages, right_width - 4)[-22:]
588
912
  rows = max(len(sidebar), len(chat), 22)
589
913
  for index in range(rows):
590
914
  left = sidebar[index] if index < len(sidebar) else ""
591
915
  right = chat[index] if index < len(chat) else ""
592
916
  print("| " + left[: left_width - 2].ljust(left_width - 2) + "| " + right[: right_width - 2].ljust(right_width - 2) + "|")
593
917
  print("+" + "-" * (left_width - 1) + "+" + "-" * (right_width) + "+")
594
- print(box_text("Input: type / for live dropdown. Enter '/' for full palette. /ask calls models.", width))
918
+ footer = "Thinking/acting..." if activity else "Input: type / for live dropdown. Enter '/' for full palette. /ask calls models."
919
+ print(box_text(footer, width))
595
920
  print("+" + "-" * (width - 2) + "+")
596
921
 
597
922
 
@@ -626,6 +951,7 @@ def sidebar_lines(context: McpContext) -> list[str]:
626
951
  " /agents",
627
952
  " /claude-install .",
628
953
  " /models",
954
+ " /clear-models",
629
955
  " /ask <prompt>",
630
956
  ]
631
957
 
@@ -244,6 +244,29 @@ def tools_list() -> dict[str, Any]:
244
244
  "required": ["provider", "model"],
245
245
  },
246
246
  },
247
+ {
248
+ "name": "agentram_remove_coding_model",
249
+ "description": "Remove one bound coding model endpoint by id.",
250
+ "inputSchema": {
251
+ "type": "object",
252
+ "properties": {"id": {"type": "string"}},
253
+ "required": ["id"],
254
+ },
255
+ },
256
+ {
257
+ "name": "agentram_disable_coding_model",
258
+ "description": "Disable one bound coding model endpoint by id without deleting it.",
259
+ "inputSchema": {
260
+ "type": "object",
261
+ "properties": {"id": {"type": "string"}},
262
+ "required": ["id"],
263
+ },
264
+ },
265
+ {
266
+ "name": "agentram_clear_coding_models",
267
+ "description": "Remove all bound coding model endpoints from this project RAM.",
268
+ "inputSchema": {"type": "object", "properties": {}},
269
+ },
247
270
  {
248
271
  "name": "agentram_read_coding_models",
249
272
  "description": "Read coding-agent model endpoints bound to this project RAM.",
@@ -349,6 +372,24 @@ def call_tool(context: McpContext, name: str, arguments: dict[str, Any]) -> dict
349
372
  data = context.profile_store.upsert(endpoint)
350
373
  return tool_result({"ok": True, "endpoint": endpoint.to_dict(), "profile": data})
351
374
 
375
+ if name == "agentram_remove_coding_model":
376
+ endpoint_id = str(arguments.get("id", ""))
377
+ if not endpoint_id.strip():
378
+ raise ValueError("id is required")
379
+ data = context.profile_store.remove(endpoint_id)
380
+ return tool_result({"ok": True, **data})
381
+
382
+ if name == "agentram_disable_coding_model":
383
+ endpoint_id = str(arguments.get("id", ""))
384
+ if not endpoint_id.strip():
385
+ raise ValueError("id is required")
386
+ data = context.profile_store.set_enabled(endpoint_id, False)
387
+ return tool_result({"ok": True, **data})
388
+
389
+ if name == "agentram_clear_coding_models":
390
+ data = context.profile_store.clear()
391
+ return tool_result({"ok": True, **data})
392
+
352
393
  if name == "agentram_read_coding_models":
353
394
  endpoints = context.profile_store.list_endpoints()
354
395
  return tool_result({"endpoints": [endpoint.to_dict() for endpoint in endpoints]})
@@ -4,6 +4,9 @@ from concurrent.futures import ThreadPoolExecutor, as_completed
4
4
  from dataclasses import dataclass, field
5
5
  import json
6
6
  import os
7
+ import shlex
8
+ import subprocess
9
+ import urllib.error
7
10
  import urllib.request
8
11
  from typing import Any, Callable
9
12
  from uuid import uuid4
@@ -106,6 +109,8 @@ class MultiModelCodingOrchestrator:
106
109
  return self._call_openai_compatible(endpoint, prompt)
107
110
  if provider in {"anthropic", "claude"}:
108
111
  return self._call_anthropic(endpoint, prompt)
112
+ if provider in {"claude-code", "claude-cli", "claude-subagent", "claude-code-subagent"}:
113
+ return self._call_claude_code_subagent(endpoint, prompt)
109
114
  raise ValueError(f"Unsupported provider for execution: {endpoint.provider}")
110
115
 
111
116
  def _call_openai_compatible(self, endpoint: AgentModelEndpoint, prompt: str) -> str:
@@ -121,8 +126,20 @@ class MultiModelCodingOrchestrator:
121
126
  headers={"Content-Type": "application/json", "Authorization": f"Bearer {api_key}"} if api_key else {"Content-Type": "application/json"},
122
127
  method="POST",
123
128
  )
124
- with urllib.request.urlopen(request, timeout=120) as response:
125
- data = json.loads(response.read().decode("utf-8"))
129
+ url = endpoint.base_url.rstrip("/") + "/chat/completions"
130
+ try:
131
+ with urllib.request.urlopen(request, timeout=120) as response:
132
+ raw = response.read().decode("utf-8")
133
+ except urllib.error.HTTPError as error:
134
+ body = error.read().decode("utf-8", errors="replace") if error.fp else ""
135
+ hint = f"OpenAI-compatible HTTP {error.code} at {url}"
136
+ if error.code == 404:
137
+ hint += " | base_url should include /v1 but not /chat/completions; verify provider URL and model name"
138
+ raise RuntimeError(f"{hint} | response={body[:500]}") from error
139
+ try:
140
+ data = json.loads(raw)
141
+ except json.JSONDecodeError as error:
142
+ raise RuntimeError(f"OpenAI-compatible returned non-JSON at {url} | response={raw[:500]}") from error
126
143
  return str(data.get("choices", [{}])[0].get("message", {}).get("content", ""))
127
144
 
128
145
  def _call_anthropic(self, endpoint: AgentModelEndpoint, prompt: str) -> str:
@@ -142,4 +159,32 @@ class MultiModelCodingOrchestrator:
142
159
  content = data.get("content", [])
143
160
  if content and isinstance(content, list):
144
161
  return "\n".join(str(item.get("text", "")) for item in content if isinstance(item, dict))
145
- return str(data)
162
+ return str(data)
163
+
164
+ def _call_claude_code_subagent(self, endpoint: AgentModelEndpoint, prompt: str) -> str:
165
+ command = endpoint.base_url.strip() or os.getenv("AGENTRAM_CLAUDE_COMMAND", "claude")
166
+ args = shlex.split(command)
167
+ if not args:
168
+ raise ValueError("claude command is required")
169
+ subagent_prompt = "\n".join(
170
+ [
171
+ f"Use Claude Code subagent entrypoint: {endpoint.model}",
172
+ f"Subagent role: {endpoint.role}",
173
+ "If that subagent exists in .claude/agents, follow it. If not, act as this role.",
174
+ "Return concise coding-agent output only.",
175
+ "",
176
+ prompt,
177
+ ]
178
+ )
179
+ process = subprocess.run(
180
+ [*args, "-p", subagent_prompt],
181
+ check=False,
182
+ capture_output=True,
183
+ encoding="utf-8",
184
+ timeout=300,
185
+ )
186
+ output = (process.stdout or "").strip()
187
+ error = (process.stderr or "").strip()
188
+ if process.returncode != 0:
189
+ raise RuntimeError(error or f"claude command failed with exit code {process.returncode}")
190
+ return output or error
@@ -80,15 +80,27 @@ class JsonAgentProfileStore:
80
80
  self.path = Path(path)
81
81
  self.path.parent.mkdir(parents=True, exist_ok=True)
82
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
83
+ def load(self) -> dict[str, object]:
84
+ if not self.path.exists():
85
+ return {"updated_at": utc_now(), "endpoints": [], "router": None, "auto_route": True, "agents": {}}
86
+ data = json.loads(self.path.read_text(encoding="utf-8"))
87
+ data.setdefault("endpoints", [])
88
+ data.setdefault("router", None)
89
+ data.setdefault("auto_route", True)
90
+ data.setdefault("agents", {})
91
+ return data
92
+
93
+ def save(self, endpoints: list[AgentModelEndpoint]) -> dict[str, object]:
94
+ current = self.load()
95
+ payload = {
96
+ "updated_at": utc_now(),
97
+ "endpoints": [endpoint.to_dict() for endpoint in endpoints],
98
+ "router": current.get("router"),
99
+ "auto_route": bool(current.get("auto_route", True)),
100
+ "agents": current.get("agents", {}),
101
+ }
102
+ self.path.write_text(json.dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8")
103
+ return payload
92
104
 
93
105
  def list_endpoints(self) -> list[AgentModelEndpoint]:
94
106
  data = self.load()
@@ -108,6 +120,86 @@ class JsonAgentProfileStore:
108
120
  updated.append(endpoint)
109
121
  return self.save(updated)
110
122
 
123
+ def remove(self, endpoint_id: str) -> dict[str, object]:
124
+ endpoints = self.list_endpoints()
125
+ kept = [endpoint for endpoint in endpoints if endpoint.id != endpoint_id]
126
+ payload = self.save(kept)
127
+ payload["removed"] = len(endpoints) - len(kept)
128
+ return payload
129
+
130
+ def set_enabled(self, endpoint_id: str, enabled: bool) -> dict[str, object]:
131
+ endpoints = self.list_endpoints()
132
+ updated: list[AgentModelEndpoint] = []
133
+ changed = 0
134
+ for endpoint in endpoints:
135
+ if endpoint.id == endpoint_id:
136
+ data = endpoint.to_dict()
137
+ data["enabled"] = enabled
138
+ updated.append(AgentModelEndpoint.from_dict(data))
139
+ changed += 1
140
+ else:
141
+ updated.append(endpoint)
142
+ payload = self.save(updated)
143
+ payload["changed"] = changed
144
+ return payload
145
+
146
+ def clear(self) -> dict[str, object]:
147
+ payload = self.save([])
148
+ payload["cleared"] = True
149
+ return payload
150
+
151
+ def get_router(self) -> AgentModelEndpoint | None:
152
+ router = self.load().get("router")
153
+ if not isinstance(router, dict):
154
+ return None
155
+ return AgentModelEndpoint.from_dict(router)
156
+
157
+ def set_router(self, endpoint: AgentModelEndpoint | None) -> dict[str, object]:
158
+ current = self.load()
159
+ current["updated_at"] = utc_now()
160
+ current["router"] = endpoint.to_dict() if endpoint else None
161
+ self.path.write_text(json.dumps(current, indent=2, ensure_ascii=False), encoding="utf-8")
162
+ return current
163
+
164
+ def auto_route_enabled(self) -> bool:
165
+ return bool(self.load().get("auto_route", True))
166
+
167
+ def set_auto_route(self, enabled: bool) -> dict[str, object]:
168
+ current = self.load()
169
+ current["updated_at"] = utc_now()
170
+ current["auto_route"] = enabled
171
+ self.path.write_text(json.dumps(current, indent=2, ensure_ascii=False), encoding="utf-8")
172
+ return current
173
+
174
+ def get_agent(self, slot: str) -> AgentModelEndpoint | None:
175
+ agents = self.load().get("agents", {})
176
+ if not isinstance(agents, dict):
177
+ return None
178
+ item = agents.get(slot)
179
+ if not isinstance(item, dict):
180
+ return None
181
+ return AgentModelEndpoint.from_dict(item)
182
+
183
+ def set_agent(self, slot: str, endpoint: AgentModelEndpoint | None) -> dict[str, object]:
184
+ current = self.load()
185
+ agents = current.get("agents", {})
186
+ if not isinstance(agents, dict):
187
+ agents = {}
188
+ if endpoint is None:
189
+ agents.pop(slot, None)
190
+ else:
191
+ agents[slot] = endpoint.to_dict()
192
+ current["agents"] = agents
193
+ current["updated_at"] = utc_now()
194
+ self.path.write_text(json.dumps(current, indent=2, ensure_ascii=False), encoding="utf-8")
195
+ return current
196
+
197
+ def list_agents(self) -> dict[str, AgentModelEndpoint]:
198
+ agents = self.load().get("agents", {})
199
+ if not isinstance(agents, dict):
200
+ return {}
201
+ return {slot: AgentModelEndpoint.from_dict(item) for slot, item in agents.items() if isinstance(item, dict)}
202
+
111
203
 
112
204
  def ensure_ram_storage(root: str | Path) -> dict[str, object]:
113
205
  ram_root = Path(root)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentram",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
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.5"
7
+ version = "0.1.7"
8
8
  description = "Async, model-agnostic RAM layer for agentic coding tools."
9
9
  readme = "README.md"
10
10
  license = "MIT"