From 9222fa8ad79e8f848fa04e0a614312636ddfb7e2 Mon Sep 17 00:00:00 2001 From: leetcrypt Date: Sat, 6 Jun 2026 17:26:17 -0700 Subject: [PATCH] feat(ai): name agents after their model, not hardcoded "oracle" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agents now join under their model tag (e.g. "qwen2.5:3b" — model name + parameter size) or profile label, derived at /ai start and tracked on App.agent_name, so the roster shows what's actually answering. Updates the broker re-grant + /ai stop revoke paths, the Python --name default (falls back to provider.model), and the demo/bootstrap/README/docs refs. Co-Authored-By: Claude Opus 4.6 --- README.MD | 3 ++- bootstrap-ai.sh | 2 +- cmd_chat/agent/__main__.py | 12 ++++++++---- docs/demo-save-load-poc.md | 6 +++--- hh/demo-save-load.sh | 14 +++++++------- hh/film-save-load.sh | 6 +++--- hh/src/app.rs | 30 ++++++++++++++++++------------ hh/src/ui.rs | 3 ++- 8 files changed, 44 insertions(+), 32 deletions(-) diff --git a/README.MD b/README.MD index 5d2c6e9..c3a3447 100644 --- a/README.MD +++ b/README.MD @@ -275,7 +275,8 @@ directly: ```bash .venv/bin/python -m cmd_chat.agent \ - --name oracle --password --provider ollama --model qwen2.5:3b + --password --provider ollama --model qwen2.5:3b +# joins as its model tag ("qwen2.5:3b") unless you override with --name # cloud (opt-in): --provider anthropic --model claude-opus-4-6 (needs ANTHROPIC_API_KEY) ``` diff --git a/bootstrap-ai.sh b/bootstrap-ai.sh index a2c2575..c1aa6c6 100755 --- a/bootstrap-ai.sh +++ b/bootstrap-ai.sh @@ -129,6 +129,6 @@ fi echo echo "AI ready. start a local agent against a running room with:" echo " .venv/bin/python -m cmd_chat.agent \\" -echo " --name oracle --password --provider ollama --model $MODEL --no-tls" +echo " --password --provider ollama --model $MODEL --no-tls" echo echo "tip: pick a different model with HH_AI_MODEL=llama3 ./bootstrap-ai.sh" diff --git a/cmd_chat/agent/__main__.py b/cmd_chat/agent/__main__.py index 6024cae..563695d 100644 --- a/cmd_chat/agent/__main__.py +++ b/cmd_chat/agent/__main__.py @@ -2,8 +2,8 @@ Examples -------- - # local Ollama (default, recommended) - python -m cmd_chat.agent 127.0.0.1 3000 --name oracle \ + # local Ollama (default, recommended) — joins as "qwen2.5:3b" + python -m cmd_chat.agent 127.0.0.1 3000 \ --password hunter2 --model qwen2.5:3b --no-tls # cloud, opt-in @@ -102,7 +102,8 @@ def main() -> None: ) ap.add_argument("server", nargs="?", help="room host (omit with --list-models/--check)") ap.add_argument("port", type=int, nargs="?", help="room port") - ap.add_argument("--name", default="oracle", help="agent's room display name") + ap.add_argument("--name", default=None, + help="agent's room display name (default: the model tag, e.g. qwen2.5:3b)") ap.add_argument("--password", default=None, help="room password") ap.add_argument("--provider", default="ollama", help="ollama | anthropic | openai | module:Class") @@ -184,8 +185,11 @@ def main() -> None: if code_provider is not None: print(f"sandbox/code path → {code_provider.name}/{code_provider.model}", file=sys.stderr) + # Default the room handle to the model tag (model name + parameter size, + # e.g. "qwen2.5:3b") so the roster shows what's actually answering. + name = args.name or provider.model bridge = AgentBridge( - args.server, args.port, name=args.name, provider=provider, + args.server, args.port, name=name, provider=provider, password=args.password, insecure=args.insecure, no_tls=args.no_tls, system_prompt=args.system, context_window=args.context_window, token_budget=args.token_budget, embedder=embedder, rag_top_k=args.rag_top_k, diff --git a/docs/demo-save-load-poc.md b/docs/demo-save-load-poc.md index 98b72d1..d203060 100644 --- a/docs/demo-save-load-poc.md +++ b/docs/demo-save-load-poc.md @@ -34,10 +34,10 @@ the chat provider is Ollama and a `qwen2.5-coder` is present (it is — pulled). 1. **Title card** — "Ephemeral by default. Persistent on demand." 2. **Summon** — alice: `/sbx launch docker` → "summoned" sandbox bubble. -3. **Spawn the coder** — alice: `/ai start` → `oracle online — ollama/qwen2.5:3b` +3. **Spawn the coder** — alice: `/ai start` → `qwen2.5:3b (ai) online — ollama/qwen2.5:3b` (the coder model rides along for `!task`). 4. **Build, by the fast model** — alice: - `/ai oracle !write /root/fib.py that prints the first 10 Fibonacci numbers, then run it` + `/ai qwen2.5:3b !write /root/fib.py that prints the first 10 Fibonacci numbers, then run it` → agent drives the shared shell; `fib.py` is written and executed; the sandbox pane shows the Fibonacci output. 5. **Freeze it** — alice: `/sbx save buildbox` → @@ -74,7 +74,7 @@ render. - TUI doesn't bind Ctrl-U (it inserts a literal `u`); clear input with `BSpace`. Send text with `send-keys -l ""` then a separate `Enter`; don't race renders. -- Agent name is hardcoded `oracle`; only one `/ai start` per room. +- Agent joins under its model tag (e.g. `qwen2.5:3b`); only one `/ai start` per client. - Keep `!task` phrasing single-line; the agent's drive output lands in the sandbox pane, not chat. - `/sbx load` refuses if a sandbox is already running — stop first. diff --git a/hh/demo-save-load.sh b/hh/demo-save-load.sh index c4519a0..0c6d093 100755 --- a/hh/demo-save-load.sh +++ b/hh/demo-save-load.sh @@ -2,8 +2,8 @@ # demo-save-load.sh — PoC harness for the "persistent sandbox" video beat. # # Flow (see docs/demo-save-load-poc.md): -# session A: /sbx launch docker → /ai start → /grant oracle → -# /ai oracle !build fib.py & run it → /sbx save buildbox → quit +# session A: /sbx launch docker → /ai start → /grant qwen2.5:3b → +# /ai qwen2.5:3b !build fib.py & run it → /sbx save buildbox → quit # prove: container purged on quit, but hh-snap:buildbox image survives # session B: fresh client → /sbx load buildbox → the model's code is intact # @@ -131,17 +131,17 @@ wait_for "$A_SESS" 'summoned|sandbox|ready|online' 60 >/dev/null snap_evid "$A_SESS" 02-sandbox # ---- 4. spawn the coder agent + grant drive -------------------------------- -step "spawn oracle (qwen2.5:3b chat, qwen2.5-coder:1.5b for !task)" +step "spawn agent (qwen2.5:3b chat, qwen2.5-coder:1.5b for !task)" say "$A_SESS" "/ai start" -wait_for "$A_SESS" 'oracle|online|ollama' 45 && ok "oracle announced" \ - || note "no 'online' line yet - agent log: ${TMPDIR:-/tmp}/hh-agent-oracle.log" -say "$A_SESS" "/grant oracle" +wait_for "$A_SESS" 'online|ollama|qwen' 45 && ok "agent announced" \ + || note "no 'online' line yet - agent log: ${TMPDIR:-/tmp}/hh-agent-qwen2.5:3b.log" +say "$A_SESS" "/grant qwen2.5:3b" sleep 1 snap_evid "$A_SESS" 03-agent # ---- 5. fast model builds code in the sandbox ------------------------------ step "fast qwen builds /root/fib.py in the sandbox" -say "$A_SESS" "/ai oracle !create /root/fib.py that prints the first 10 fibonacci numbers space-separated on one line, then run it with python3" +say "$A_SESS" "/ai qwen2.5:3b !create /root/fib.py that prints the first 10 fibonacci numbers space-separated on one line, then run it with python3" # Give the CPU coder model room to think, then poll for the file. WT=150 wait_cmd docker exec "$CTR" test -s /root/fib.py NEED='0 1 1 2 3 5 8 13 21 34' diff --git a/hh/film-save-load.sh b/hh/film-save-load.sh index ead7920..394a3e5 100755 --- a/hh/film-save-load.sh +++ b/hh/film-save-load.sh @@ -147,9 +147,9 @@ wait_for 'summoned|sandbox|ready|online' 60 >/dev/null sleep 1.5 # ---- 5. spawn fast qwen agent (auto-grant drive) --------------------------- -step "spawn oracle (auto-grant sandbox drive)" +step "spawn agent (auto-grant sandbox drive)" say "/ai start $CODER allow" -wait_for 'oracle|online|ollama|qwen' 45 && ok "oracle online" || note "no online line yet" +wait_for 'online|ollama|qwen' 45 && ok "agent online" || note "no online line yet" sleep 1.5 # ---- 6. the fast model builds code in the sandbox -------------------------- @@ -157,7 +157,7 @@ sleep 1.5 # through the PTY. Validate-by-running; retry once; abort before save if it # still fails (no silent fallback in a film). step "fast qwen writes /root/fib.py and runs it" -TASK="/ai oracle !create /root/fib.py with exactly two lines and nothing else: line 1 is nums = [0, 1, 1, 2, 3, 5, 8, 13, 21, 34] and line 2 is print(*nums) then run it with: python3 /root/fib.py" +TASK="/ai $CODER !create /root/fib.py with exactly two lines and nothing else: line 1 is nums = [0, 1, 1, 2, 3, 5, 8, 13, 21, 34] and line 2 is print(*nums) then run it with: python3 /root/fib.py" BUILT=0 for attempt in 1 2; do note "build attempt $attempt" diff --git a/hh/src/app.rs b/hh/src/app.rs index eda576e..315bcb1 100644 --- a/hh/src/app.rs +++ b/hh/src/app.rs @@ -161,9 +161,6 @@ pub struct PendingVm { pub needs_install: bool, } -/// Display handle the summoned Python agent joins under (see `spawn_agent`). -const AGENT_NAME: &str = "oracle"; - pub struct App { pub me: String, pub lines: Vec, @@ -212,6 +209,11 @@ pub struct App { /// (`/ai start allow`). Re-applied in the broker Ready handler, since /// launching a sandbox resets the ACL back to just the owner. pub agent_sbx_allow: bool, + /// Display name the summoned agent joined under — derived from its model or + /// profile at `/ai start` (e.g. "qwen2.5:3b", model name + parameter size). + /// Tracked so the broker Ready handler can re-grant its drive and `/ai stop` + /// can revoke the right ACL entry. + pub agent_name: Option, } impl App { @@ -244,6 +246,7 @@ impl App { ai_stream: std::collections::HashMap::new(), spin: 0, agent_sbx_allow: false, + agent_name: None, } } @@ -1085,7 +1088,9 @@ pub async fn run(params: net::ConnParams, mut session: Session, mut theme: Theme app.sudoers.insert(app.me.clone()); // owner = superuser if app.agent_sbx_allow { // Re-apply a `/ai start … allow` grant the launch reset. - app.drivers.insert(AGENT_NAME.to_string()); + if let Some(n) = &app.agent_name { + app.drivers.insert(n.clone()); + } } send_frame(&out_tx, &session.room, json!({ "_sbx":"status","state":"ready","backend": backend.label(), "rows": rows, "cols": cols @@ -1722,7 +1727,10 @@ fn handle_command( app.sys("⛧ dismissed the AI agent"); // Drop any sandbox drive the agent held so a dead handle can't act. app.agent_sbx_allow = false; - let revoked = app.drivers.remove(AGENT_NAME) | app.sudoers.remove(AGENT_NAME); + let revoked = app + .agent_name + .take() + .is_some_and(|n| app.drivers.remove(&n) | app.sudoers.remove(&n)); if revoked && app.sandbox.is_some() { broadcast_acl(out_tx, room, app); } @@ -1749,12 +1757,6 @@ fn handle_command( Some(head) if head.is_empty() || head.ends_with(' ') => (head.trim(), true), _ => (raw, false), }; - // Tolerate a leading agent-name token (`/ai start oracle allow`): - // there's only one agent, so treat it as addressing, not a profile. - let raw = match raw.strip_prefix(AGENT_NAME) { - Some(rest) if rest.is_empty() || rest.starts_with(' ') => rest.trim(), - _ => raw, - }; // A bare name (no ':' tag, no '/' path) is a models.toml profile; // anything else is treated as a literal Ollama model tag. let (profile, model): (Option<&str>, &str) = if raw.is_empty() { @@ -1764,10 +1766,14 @@ fn handle_command( } else { (Some(raw), raw) }; - let name = AGENT_NAME; + // Name the agent after what it runs, for clarity in the roster: a + // profile keeps its label; a direct Ollama model uses its tag + // (e.g. "qwen2.5:3b" — model name + parameter size). + let name = profile.unwrap_or(model); match spawn_agent(params, &app.password, name, profile, model) { Ok(child) => { *agent = Some(child); + app.agent_name = Some(name.to_string()); app.agent_sbx_allow = grant_sbx; let desc = match profile { Some(p) => format!("profile {p}"), diff --git a/hh/src/ui.rs b/hh/src/ui.rs index 0a0fbe4..1143f0a 100644 --- a/hh/src/ui.rs +++ b/hh/src/ui.rs @@ -567,7 +567,8 @@ fn draw_roster(f: &mut Frame, area: ratatui::layout::Rect, app: &App, theme: &Th f.render_widget(roster, area); } -/// Animated "⠋ oracle is thinking…" title shown while AI agents generate a reply. +/// Animated "⠋ is thinking…" title shown while AI agents generate a +/// reply. The name(s) come from `app.ai_typing`, i.e. each agent's own handle. fn ai_thinking_title(app: &App) -> String { const FRAMES: [&str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; let glyph = FRAMES[(app.spin / 2) % FRAMES.len()];