feat(ai): name agents after their model, not hardcoded "oracle"

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 <noreply@anthropic.com>
This commit is contained in:
leetcrypt
2026-06-06 17:26:17 -07:00
parent 3a54578f0a
commit 9222fa8ad7
8 changed files with 44 additions and 32 deletions
+2 -1
View File
@@ -275,7 +275,8 @@ directly:
```bash ```bash
.venv/bin/python -m cmd_chat.agent <server_ip> <port> \ .venv/bin/python -m cmd_chat.agent <server_ip> <port> \
--name oracle --password <room-pw> --provider ollama --model qwen2.5:3b --password <room-pw> --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) # cloud (opt-in): --provider anthropic --model claude-opus-4-6 (needs ANTHROPIC_API_KEY)
``` ```
+1 -1
View File
@@ -129,6 +129,6 @@ fi
echo echo
echo "AI ready. start a local agent against a running room with:" echo "AI ready. start a local agent against a running room with:"
echo " .venv/bin/python -m cmd_chat.agent <host> <port> \\" echo " .venv/bin/python -m cmd_chat.agent <host> <port> \\"
echo " --name oracle --password <room-pw> --provider ollama --model $MODEL --no-tls" echo " --password <room-pw> --provider ollama --model $MODEL --no-tls"
echo echo
echo "tip: pick a different model with HH_AI_MODEL=llama3 ./bootstrap-ai.sh" echo "tip: pick a different model with HH_AI_MODEL=llama3 ./bootstrap-ai.sh"
+8 -4
View File
@@ -2,8 +2,8 @@
Examples Examples
-------- --------
# local Ollama (default, recommended) # local Ollama (default, recommended) — joins as "qwen2.5:3b"
python -m cmd_chat.agent 127.0.0.1 3000 --name oracle \ python -m cmd_chat.agent 127.0.0.1 3000 \
--password hunter2 --model qwen2.5:3b --no-tls --password hunter2 --model qwen2.5:3b --no-tls
# cloud, opt-in # 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("server", nargs="?", help="room host (omit with --list-models/--check)")
ap.add_argument("port", type=int, nargs="?", help="room port") 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("--password", default=None, help="room password")
ap.add_argument("--provider", default="ollama", ap.add_argument("--provider", default="ollama",
help="ollama | anthropic | openai | module:Class") help="ollama | anthropic | openai | module:Class")
@@ -184,8 +185,11 @@ def main() -> None:
if code_provider is not None: if code_provider is not None:
print(f"sandbox/code path → {code_provider.name}/{code_provider.model}", file=sys.stderr) 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( 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, password=args.password, insecure=args.insecure, no_tls=args.no_tls,
system_prompt=args.system, context_window=args.context_window, system_prompt=args.system, context_window=args.context_window,
token_budget=args.token_budget, embedder=embedder, rag_top_k=args.rag_top_k, token_budget=args.token_budget, embedder=embedder, rag_top_k=args.rag_top_k,
+3 -3
View File
@@ -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." 1. **Title card** — "Ephemeral by default. Persistent on demand."
2. **Summon** — alice: `/sbx launch docker` → "summoned" sandbox bubble. 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`). (the coder model rides along for `!task`).
4. **Build, by the fast model** — alice: 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 → agent drives the shared shell; `fib.py` is written and executed; the
sandbox pane shows the Fibonacci output. sandbox pane shows the Fibonacci output.
5. **Freeze it** — alice: `/sbx save buildbox` 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`. - TUI doesn't bind Ctrl-U (it inserts a literal `u`); clear input with `BSpace`.
Send text with `send-keys -l "<text>"` then a separate `Enter`; don't race renders. Send text with `send-keys -l "<text>"` 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 - Keep `!task` phrasing single-line; the agent's drive output lands in the sandbox
pane, not chat. pane, not chat.
- `/sbx load` refuses if a sandbox is already running — stop first. - `/sbx load` refuses if a sandbox is already running — stop first.
+7 -7
View File
@@ -2,8 +2,8 @@
# demo-save-load.sh — PoC harness for the "persistent sandbox" video beat. # demo-save-load.sh — PoC harness for the "persistent sandbox" video beat.
# #
# Flow (see docs/demo-save-load-poc.md): # Flow (see docs/demo-save-load-poc.md):
# session A: /sbx launch docker → /ai start → /grant oracle # session A: /sbx launch docker → /ai start → /grant qwen2.5:3b
# /ai oracle !build fib.py & run it → /sbx save buildbox → quit # /ai qwen2.5:3b !build fib.py & run it → /sbx save buildbox → quit
# prove: container purged on quit, but hh-snap:buildbox image survives # prove: container purged on quit, but hh-snap:buildbox image survives
# session B: fresh client → /sbx load buildbox → the model's code is intact # 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 snap_evid "$A_SESS" 02-sandbox
# ---- 4. spawn the coder agent + grant drive -------------------------------- # ---- 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" say "$A_SESS" "/ai start"
wait_for "$A_SESS" 'oracle|online|ollama' 45 && ok "oracle announced" \ wait_for "$A_SESS" 'online|ollama|qwen' 45 && ok "agent announced" \
|| note "no 'online' line yet - agent log: ${TMPDIR:-/tmp}/hh-agent-oracle.log" || note "no 'online' line yet - agent log: ${TMPDIR:-/tmp}/hh-agent-qwen2.5:3b.log"
say "$A_SESS" "/grant oracle" say "$A_SESS" "/grant qwen2.5:3b"
sleep 1 sleep 1
snap_evid "$A_SESS" 03-agent snap_evid "$A_SESS" 03-agent
# ---- 5. fast model builds code in the sandbox ------------------------------ # ---- 5. fast model builds code in the sandbox ------------------------------
step "fast qwen builds /root/fib.py 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. # 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 WT=150 wait_cmd docker exec "$CTR" test -s /root/fib.py
NEED='0 1 1 2 3 5 8 13 21 34' NEED='0 1 1 2 3 5 8 13 21 34'
+3 -3
View File
@@ -147,9 +147,9 @@ wait_for 'summoned|sandbox|ready|online' 60 >/dev/null
sleep 1.5 sleep 1.5
# ---- 5. spawn fast qwen agent (auto-grant drive) --------------------------- # ---- 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" 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 sleep 1.5
# ---- 6. the fast model builds code in the sandbox -------------------------- # ---- 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 # through the PTY. Validate-by-running; retry once; abort before save if it
# still fails (no silent fallback in a film). # still fails (no silent fallback in a film).
step "fast qwen writes /root/fib.py and runs it" 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 BUILT=0
for attempt in 1 2; do for attempt in 1 2; do
note "build attempt $attempt" note "build attempt $attempt"
+18 -12
View File
@@ -161,9 +161,6 @@ pub struct PendingVm {
pub needs_install: bool, pub needs_install: bool,
} }
/// Display handle the summoned Python agent joins under (see `spawn_agent`).
const AGENT_NAME: &str = "oracle";
pub struct App { pub struct App {
pub me: String, pub me: String,
pub lines: Vec<ChatLine>, pub lines: Vec<ChatLine>,
@@ -212,6 +209,11 @@ pub struct App {
/// (`/ai start <name> allow`). Re-applied in the broker Ready handler, since /// (`/ai start <name> allow`). Re-applied in the broker Ready handler, since
/// launching a sandbox resets the ACL back to just the owner. /// launching a sandbox resets the ACL back to just the owner.
pub agent_sbx_allow: bool, 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<String>,
} }
impl App { impl App {
@@ -244,6 +246,7 @@ impl App {
ai_stream: std::collections::HashMap::new(), ai_stream: std::collections::HashMap::new(),
spin: 0, spin: 0,
agent_sbx_allow: false, 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 app.sudoers.insert(app.me.clone()); // owner = superuser
if app.agent_sbx_allow { if app.agent_sbx_allow {
// Re-apply a `/ai start … allow` grant the launch reset. // 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!({ send_frame(&out_tx, &session.room, json!({
"_sbx":"status","state":"ready","backend": backend.label(), "rows": rows, "cols": cols "_sbx":"status","state":"ready","backend": backend.label(), "rows": rows, "cols": cols
@@ -1722,7 +1727,10 @@ fn handle_command(
app.sys("⛧ dismissed the AI agent"); app.sys("⛧ dismissed the AI agent");
// Drop any sandbox drive the agent held so a dead handle can't act. // Drop any sandbox drive the agent held so a dead handle can't act.
app.agent_sbx_allow = false; 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() { if revoked && app.sandbox.is_some() {
broadcast_acl(out_tx, room, app); 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), Some(head) if head.is_empty() || head.ends_with(' ') => (head.trim(), true),
_ => (raw, false), _ => (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; // A bare name (no ':' tag, no '/' path) is a models.toml profile;
// anything else is treated as a literal Ollama model tag. // anything else is treated as a literal Ollama model tag.
let (profile, model): (Option<&str>, &str) = if raw.is_empty() { let (profile, model): (Option<&str>, &str) = if raw.is_empty() {
@@ -1764,10 +1766,14 @@ fn handle_command(
} else { } else {
(Some(raw), raw) (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) { match spawn_agent(params, &app.password, name, profile, model) {
Ok(child) => { Ok(child) => {
*agent = Some(child); *agent = Some(child);
app.agent_name = Some(name.to_string());
app.agent_sbx_allow = grant_sbx; app.agent_sbx_allow = grant_sbx;
let desc = match profile { let desc = match profile {
Some(p) => format!("profile {p}"), Some(p) => format!("profile {p}"),
+2 -1
View File
@@ -567,7 +567,8 @@ fn draw_roster(f: &mut Frame, area: ratatui::layout::Rect, app: &App, theme: &Th
f.render_widget(roster, area); f.render_widget(roster, area);
} }
/// Animated "⠋ oracle is thinking…" title shown while AI agents generate a reply. /// Animated "⠋ <agent> 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 { fn ai_thinking_title(app: &App) -> String {
const FRAMES: [&str; 10] = ["", "", "", "", "", "", "", "", "", ""]; const FRAMES: [&str; 10] = ["", "", "", "", "", "", "", "", "", ""];
let glyph = FRAMES[(app.spin / 2) % FRAMES.len()]; let glyph = FRAMES[(app.spin / 2) % FRAMES.len()];