rename agents/main→assistant, role-based default entry agent
Nightly Build / build (push) Successful in 6m31s
Nightly Build / build (push) Successful in 6m31s
This commit is contained in:
@@ -12,8 +12,10 @@ use crate::message_meta::MessageMetadata;
|
||||
/// Optional parameters for a [`ChatHubApi::send_message`] call.
|
||||
#[derive(Default)]
|
||||
pub struct SendMessageOptions {
|
||||
/// Agent to use for this source's session. Defaults to `"main"` if not set.
|
||||
/// Only takes effect when a new session is created — ignored for existing sessions.
|
||||
/// Agent to use for this source's session. When unset, falls back to the
|
||||
/// hub's owner-resolved default entry agent (the caller's role `attrs.chat_agent`,
|
||||
/// else `DEFAULT_CHAT_AGENT`). Only takes effect when a new session is created —
|
||||
/// ignored for existing sessions.
|
||||
pub agent_id: Option<String>,
|
||||
/// Named substitutions applied to the agent's system prompt.
|
||||
/// Each entry replaces the sentinel `__KEY__` in the loaded prompt text.
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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(¬es).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();
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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?
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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"}"#),
|
||||
},
|
||||
],
|
||||
}]
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user