rename agents/main→assistant, role-based default entry agent
Nightly Build / build (push) Successful in 6m31s

This commit is contained in:
2026-07-21 21:40:06 +01:00
parent 17f5769e0d
commit 8ff64cbddc
22 changed files with 204 additions and 44 deletions
+7
View File
@@ -8,6 +8,13 @@ use core_api::provider::LlmStrength;
const AGENTS_DIR: &str = "agents";
/// The neutral, instance-wide fallback chat agent (§0.1: a stable technical id,
/// never surfaced to the user — the display name lives in its `meta.json`). Used
/// as the last-resort default when a role carries no `attrs.chat_agent` (or its
/// attrs are unreadable). The per-user entry agent is normally resolved from the
/// caller's role — see `db::roles::default_chat_agent_for_user`.
pub const DEFAULT_CHAT_AGENT: &str = "assistant";
/// The role an agent plays, declared by the required `type` field in `meta.json`.
///
/// - `Chat`: a conversational entry-point the user talks to directly (e.g. `main`,
+2 -2
View File
@@ -1185,7 +1185,7 @@ mod tests {
// Gate decisions through the real check() path.
async fn decide(mgr: &ApprovalManager, tool: &str, path: &str) -> GateResult {
mgr.check(1, None, "main", "web", tool, &json!({ "path": path }), Some("default")).await
mgr.check(1, None, "assistant", "web", tool, &json!({ "path": path }), Some("default")).await
}
// user-memory auto-allows reads and writes; the old `memory/*` no longer matches.
assert!(matches!(decide(&mgr, "write_file", "user-memory/notes.md").await, GateResult::Allow));
@@ -1209,7 +1209,7 @@ mod tests {
assert!(matches!(decide(&mgr, "write_file", "src/main.rs").await, GateResult::Require));
// Non-filesystem tool: unaffected by @fs_* rules, gated by catch-all.
let cmd = mgr
.check(1, None, "main", "web", "execute_cmd", &json!({ "command": "ls" }), Some("default"))
.check(1, None, "assistant", "web", "execute_cmd", &json!({ "command": "ls" }), Some("default"))
.await;
assert!(matches!(cmd, GateResult::Require));
+18 -8
View File
@@ -78,6 +78,14 @@ pub struct ChatHub {
/// model name). When absent the caller AUTO-resolves. In-memory only: a
/// server restart clears all pins (intentional for the MVP).
selected_clients: Mutex<HashMap<String, String>>,
/// The entry agent used when a source has no session yet and the caller did
/// not specify one. Resolved once, at login, from the owner's role
/// (`attrs.chat_agent`, else `DEFAULT_CHAT_AGENT`) — this hub is owner-bound,
/// so its default is the owner's default. Every lazy `get_or_create_session`
/// path (WS connect, notify, synthetic turns) routes through it, so a member's
/// role-assigned assistant is honored regardless of which path creates the
/// first session.
default_agent: String,
}
impl ChatHub {
@@ -87,6 +95,7 @@ impl ChatHub {
approval: Arc<ApprovalManager>,
global_tx: broadcast::Sender<GlobalEvent>,
shutdown: CancellationToken,
default_agent: String,
) -> Arc<Self> {
let (notify_tx, notify_rx) = mpsc::channel::<Notification>(NOTIFY_CAPACITY);
@@ -101,6 +110,7 @@ impl ChatHub {
me: OnceLock::new(),
shutdown: shutdown.clone(),
selected_clients: Mutex::new(HashMap::new()),
default_agent,
});
// Store a weak self-reference for lazily-spawned source consumers.
let _ = hub.me.set(Arc::downgrade(&hub));
@@ -179,7 +189,7 @@ impl ChatHub {
// was busy. `None` for synthetic turns, which never inject.
pending_input: Option<Arc<dyn PendingUserInput>>,
) -> anyhow::Result<()> {
let agent_id = opts.agent_id.as_deref().unwrap_or("main");
let agent_id = opts.agent_id.as_deref().unwrap_or(&self.default_agent);
let session_id = self.get_or_create_session(source_id, agent_id).await?;
let source_tag = source_id.to_string();
@@ -220,7 +230,7 @@ impl ChatHub {
/// Returns the session handler for the source's active session, creating one lazily if needed.
pub async fn session_handler(&self, source_id: &str) -> anyhow::Result<Arc<ChatSessionHandler>> {
let session_id = self.get_or_create_session(source_id, "main").await?;
let session_id = self.get_or_create_session(source_id, &self.default_agent).await?;
self.session_mgr.get_or_create_handler(session_id).await
}
@@ -273,10 +283,10 @@ impl ChatHub {
}
/// Create a new session for the source, discarding the previous one.
/// Thin wrapper over `provision_session` preserving the default `main` agent
/// Thin wrapper over `provision_session` using the owner's default entry agent
/// (kept for the `ChatHubApi` trait and generic callers).
pub async fn clear(&self, source_id: &str) -> anyhow::Result<i64> {
self.provision_session(source_id, "main", None, true).await
self.provision_session(source_id, &self.default_agent, None, true).await
}
/// Subscribe to the global event bus. The `source_id` parameter is accepted
@@ -308,7 +318,7 @@ impl ChatHub {
/// Returns `(input_tokens, output_tokens)` — both are `None` when no
/// messages exist or the provider did not report usage.
pub async fn context_info(&self, source_id: &str) -> anyhow::Result<(Option<i64>, Option<i64>)> {
let session_id = self.get_or_create_session(source_id, "main").await?;
let session_id = self.get_or_create_session(source_id, &self.default_agent).await?;
let stack = match chat_sessions_stack::active_for_session(&self.db, session_id).await? {
Some(s) => s,
None => return Ok((None, None)),
@@ -321,7 +331,7 @@ impl ChatHub {
/// sub-agent frames and excluding asynchronous tasks (which run in their own
/// session). `None` when no provider reported a cost.
pub async fn cost_info(&self, source_id: &str) -> anyhow::Result<Option<f64>> {
let session_id = self.get_or_create_session(source_id, "main").await?;
let session_id = self.get_or_create_session(source_id, &self.default_agent).await?;
chat_history::total_cost_for_session(&self.db, session_id).await
}
@@ -414,7 +424,7 @@ impl ChatHub {
/// Revoke all session-scoped MCP grants for a source's active session.
/// The next LLM turn will start with no MCP servers activated.
pub async fn reset_mcp(&self, source_id: &str) -> anyhow::Result<()> {
let session_id = self.get_or_create_session(source_id, "main").await?;
let session_id = self.get_or_create_session(source_id, &self.default_agent).await?;
crate::db::session_mcp_grants::revoke_all(&self.db, session_id).await?;
info!(source_id, session_id, "ChatHub: MCP grants reset");
Ok(())
@@ -695,7 +705,7 @@ impl ChatHub {
// the last assistant message and runs the LLM loop so the agent can respond.
let result_json = serde_json::to_string(&notes).unwrap_or_else(|_| "[]".to_string());
let session_id = match hub.get_or_create_session(&home, "main").await {
let session_id = match hub.get_or_create_session(&home, &hub.default_agent).await {
Ok(sid) => sid,
Err(e) => { error!(error = %e, "notification consumer: get_or_create_session failed"); continue; }
};
@@ -170,7 +170,7 @@ mod tests {
sqlx::query("INSERT INTO chat_sessions (id) VALUES (?)")
.bind(sid).execute(&pool).await.unwrap();
create(&pool, sid, "main", None, 0, None).await.unwrap();
create(&pool, sid, "assistant", None, 0, None).await.unwrap();
let a = create(&pool, sid, "task", Some("A"), 1, Some(101)).await.unwrap();
create(&pool, sid, "task", Some("B"), 1, Some(102)).await.unwrap();
+53 -3
View File
@@ -54,6 +54,12 @@ pub struct RoleAttrs {
/// to its default `permission_group`. The default is always implicitly allowed; the
/// effective set is `unique({permission_group} permission_groups)`.
pub permission_groups: Vec<String>,
/// The entry (`type:chat`) agent members of this role talk to by default — e.g.
/// `children` → `kid` (Companion), `member`/`admin` → `assistant`. Resolved into the
/// per-user runtime at login (see [`default_chat_agent_for_user`]). `None` (or absent
/// attrs) falls back to [`crate::agents::DEFAULT_CHAT_AGENT`]. Data-driven, not an
/// enum (§0.1): a future per-user override layers on top of this.
pub chat_agent: Option<String>,
}
impl RoleAttrs {
@@ -83,6 +89,23 @@ impl Role {
}
}
/// The entry chat agent for a user: their role's `attrs.chat_agent`, else the neutral
/// [`crate::agents::DEFAULT_CHAT_AGENT`]. The single resolver behind the per-user hub
/// default and `provisioning_for_source`, so every session-creation path agrees on which
/// agent a member lands on. Tolerant by construction — a missing user, missing role, or
/// unset `chat_agent` all collapse to the default rather than erroring (a chat must open).
///
/// A future per-user override would slot in here, checked before the role default.
pub async fn default_chat_agent_for_user(pool: &SqlitePool, user_id: &str) -> String {
let role_agent = async {
let user = super::users::get(pool, user_id).await.ok()??;
let role = get(pool, &user.role_id).await.ok()??;
role.attrs_parsed().chat_agent.filter(|s| !s.trim().is_empty())
}
.await;
role_agent.unwrap_or_else(|| crate::agents::DEFAULT_CHAT_AGENT.to_string())
}
/// Whether a role may use `group_id` as its session security-group. `admin` holds
/// every group by construction; a missing role allows nothing.
pub async fn role_allows_group(pool: &SqlitePool, role_id: &str, group_id: &str) -> Result<bool> {
@@ -189,9 +212,11 @@ pub async fn user_count(pool: &SqlitePool, role_id: &str) -> Result<i64> {
/// Inserts the built-in `admin` role. Idempotent.
pub async fn seed_admin(pool: &SqlitePool) -> Result<()> {
// `chat_agent` is explicit so the role editor shows admin's default rather than an
// empty pill; the fallback in `default_chat_agent_for_user` would resolve the same.
sqlx::query(
"INSERT OR IGNORE INTO roles (id, label, permission_group)
VALUES ('admin', 'Administrator', 'default')",
r#"INSERT OR IGNORE INTO roles (id, label, permission_group, attrs)
VALUES ('admin', 'Administrator', 'default', '{"chat_agent":"assistant"}')"#,
)
.execute(pool)
.await?;
@@ -224,13 +249,15 @@ mod tests {
let a = RoleAttrs::from_opt(&None);
assert_eq!(a.ui_mode, UiMode::Full);
assert!(a.permission_groups.is_empty());
assert!(a.chat_agent.is_none());
// Populated.
let a = RoleAttrs::from_opt(&Some(
r#"{"ui_mode":"simple","permission_groups":["ops","research"]}"#.into(),
r#"{"ui_mode":"simple","permission_groups":["ops","research"],"chat_agent":"kid"}"#.into(),
));
assert_eq!(a.ui_mode, UiMode::Simple);
assert_eq!(a.permission_groups, vec!["ops", "research"]);
assert_eq!(a.chat_agent.as_deref(), Some("kid"));
// Malformed JSON → defaults, never an error.
let a = RoleAttrs::from_opt(&Some("not json".into()));
@@ -265,4 +292,27 @@ mod tests {
// An unknown role allows nothing.
assert!(!role_allows_group(&pool, "ghost", "default").await.unwrap());
}
#[tokio::test]
async fn default_chat_agent_resolves_role_then_falls_back() {
let pool = crate::db::init_system_pool(&tmp_db("chatagent")).await.unwrap();
// A role with an explicit chat_agent, and one without.
insert(&pool, "children", "Children", "default", Some(r#"{"chat_agent":"kid"}"#))
.await.unwrap();
insert(&pool, "member", "Member", "default", Some(r#"{"ui_mode":"full"}"#))
.await.unwrap();
// Minimal cleartext user rows (encrypted=0, no credentials) — bypasses Argon2.
for (id, uname, role) in [("u_kid", "kid1", "children"), ("u_adult", "adult1", "member")] {
sqlx::query("INSERT INTO users (id, username, role_id, encrypted) VALUES (?, ?, ?, 0)")
.bind(id).bind(uname).bind(role).execute(&pool).await.unwrap();
}
// Role's chat_agent wins; a role without one falls back to the neutral default;
// an unknown user also falls back (never errors — a chat must be able to open).
assert_eq!(default_chat_agent_for_user(&pool, "u_kid").await, "kid");
assert_eq!(default_chat_agent_for_user(&pool, "u_adult").await, crate::agents::DEFAULT_CHAT_AGENT);
assert_eq!(default_chat_agent_for_user(&pool, "ghost").await, crate::agents::DEFAULT_CHAT_AGENT);
}
}
+3 -1
View File
@@ -585,7 +585,9 @@ impl ChatSessionHandler {
let stack = match chat_sessions_stack::active_for_session(pool, self.session_id).await? {
Some(s) => s,
None => {
chat_sessions_stack::create(pool, self.session_id, "main", None, 0, None).await?
// Lazy root frame: run this session's own entry agent (the same id
// `build_agent_config` resolves the prompt from), never a hardcoded default.
chat_sessions_stack::create(pool, self.session_id, &self.agent_id, None, 0, None).await?
}
};
+5 -1
View File
@@ -132,8 +132,12 @@ impl ChatSessionManager {
if let Some(rc) = run_context {
chat_sessions::set_run_context(&self.db, session.id, Some(&rc.to_db())).await?;
}
// The root stack frame runs the session's own entry agent — not a hardcoded
// default. Using the wrong id here would silently run that agent's prompt
// regardless of what the session was created with (llm_loop resolves the
// prompt from `config.agent_id`, which comes from the stack frame).
let stack = chat_sessions_stack::create(
&self.db, session.id, "main", None, 0, None,
&self.db, session.id, agent_id, None, 0, None,
).await?;
Ok((session.id, stack.id))
}
+3 -2
View File
@@ -45,13 +45,14 @@ pub fn seed_profiles() -> Vec<SeedProfile> {
id: "member",
label: "Member",
permission_group: "default",
attrs: Some(r#"{"ui_mode":"full"}"#),
attrs: Some(r#"{"ui_mode":"full","chat_agent":"assistant"}"#),
},
RoleSeed {
id: "children",
label: "Children",
permission_group: "default",
attrs: Some(r#"{"ui_mode":"simple"}"#),
// `kid` = the Companion agent (its display name is copy, §0.1).
attrs: Some(r#"{"ui_mode":"simple","chat_agent":"kid"}"#),
},
],
}]
+3
View File
@@ -379,6 +379,9 @@ impl Conversation {
Arc::clone(&interaction.approval),
rt.global_tx.clone(),
rt.shutdown_token.clone(),
// Inert ownerless bundle (§19): no owner to resolve a role default from, so
// the neutral fallback. Nothing consumes this hub's sessions.
crate::agents::DEFAULT_CHAT_AGENT.to_string(),
);
chat_hub.register("web").await;
chat_hub.register("talk").await;
@@ -293,12 +293,20 @@ impl UserContextFactory {
Arc::new(ToolDiscovery::new(Arc::clone(&self.registry_pool))),
));
// The owner's default entry agent, snapshotted at login from their role
// (like fs membership / MCP access above): every lazy session-creation path
// on this owner-bound hub routes through it, so a member's role-assigned
// assistant is honored no matter which path opens their first session.
let default_agent =
crate::db::roles::default_chat_agent_for_user(&self.registry_pool, user_id).await;
let chat_hub = ChatHub::new(
Arc::clone(&pool),
Arc::clone(&manager),
Arc::clone(&approval),
global_tx.clone(),
self.shutdown_token.clone(),
default_agent,
);
chat_hub.register("web").await;
chat_hub.register("talk").await;