diff --git a/CLAUDE.md b/CLAUDE.md index 5160957..9d131ab 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -114,13 +114,13 @@ Schema is greenfield (no migrations, §0), but a purely **additive** column land **Memory namespace (blueprint §5).** `memory_docs` (accessor `db/memory_docs.rs` — `get`/`upsert`/`list`/`search`(FTS)/`delete`) backs a virtual note store surfaced through the fs-tools, **not** the disk. Two sibling roots (not the blueprint's nested `memory/{userid}` + `memory/shared`): `user-memory/…` routes to the caller's own pool (`ToolContext::pool`), `shared-memory/…` to the system pool (a singleton captured in `fs::register_all`). `tools/fs/classify_memory()` decides on the raw first path component (a `..` in the tail clamps inside the store, never escapes to disk); `read_file`/`write_file`/`list_files`/`edit_file`/`insert_at_line`/`replace_lines`/`search_file` override `run_with` to route memory paths (each extracting a pure transform shared with its on-disk `execute`) and leave every other path on disk. Approval (seeded in `seed_fs_path_rules`): `user-memory/*` is `@fs_any allow` (private, frictionless); `shared-memory/*` is `@fs_read allow` + `@fs_write require` — reads free, **writes need approval** so the agent can't silently push one person's data into shared memory. `grep_files` stays disk-only (regex-across-tree ≠ FTS); ranked full-text recall over notes is a separate tool, `memory_search` (`tools/fs/memory_search.rs`), over the `memory_docs` FTS index — allowed by a path-less rule (it takes `query`, not `path`). -**Memory injection into the prompt**: `MessageBuilder::load_inject_memory` routes each `meta.inject_memory` entry — `user-memory/…` → owner pool, `shared-memory/…` → the shared (`system.db`) pool, both via `memory_docs::get`; anything else (`data/…`, `$WD/…`) is a disk read. The shared pool is threaded `ChatSessionManager` → handler → `MessageBuilder`. `main` and `project-coordinator` inject `user-memory/index.md` + `shared-memory/index.md`. +**Memory injection into the prompt**: `MessageBuilder::load_inject_memory` routes each `meta.inject_memory` entry — `user-memory/…` → owner pool, `shared-memory/…` → the shared (`system.db`) pool, both via `memory_docs::get`; anything else (`data/…`, `$WD/…`) is a disk read. The shared pool is threaded `ChatSessionManager` → handler → `MessageBuilder`. `assistant` and `project-coordinator` inject `user-memory/index.md` + `shared-memory/index.md`. **Prompt substitutions**: an `AGENT.md` may carry `` placeholders; `agents::resolve_includes` turns each into a `__KEY__` sentinel, replaced at request time. Two are **builder-side** — `MessageBuilder` resolves them itself from the session owner (`user_id`) + registry (`shared_pool`), so every source (WS, mobile, cron, sub-agents) gets them with no caller plumbing: `__SHARED_FOLDERS__` (the user's shared-folders table) and `__USER_PROFILE__` (the owner's directory profile: `Name`, `Date of birth` with age computed at build time, `Sex`, `Preferred language`, admin `Notes` — unset values render as explicit `unknown` / `not specified`, the `Notes` line is omitted when empty). Any other key comes from the per-call `SendMessageOptions::system_substitutions` map. `system.db` still gets **both** bucket functions — but no longer because the migration is unstarted. It gets the owner schema because it *is* the owner of **shared** memory (`memory_docs`) plus, for now, the globally-scoped `secrets` and the `mcp_events` lifecycle log (`SecretsStore` and the global `McpManager` are built on the system pool and shared by reference into every `UserContext`; the global runtime's *config* now lives in the registry table `mcp_global_servers`, and per-user connector config in each user's owner `mcp_user_servers`). Every *other* owner table is created there but never written to anymore — the global owner-bound managers that would write them (chat/jobs/etc.) are inert (see "Current state"). Fully dropping `create_owner_tables` from `system.db` is blocked on the §4 scope decision for secrets (plus the residual global `mcp_events` log), not on call-site migration. -`users` (`crates/skald-core/src/db/users.rs`) holds the directory plus auth material. It lives in the system DB, which the box owner can read, so it must never store anything that derives a user's key. `Credentials` is an enum mirroring the table's `CHECK`: an encrypted user carries a **wrapped DEK** (whose AEAD tag *is* the password verifier — hence no `password_hash`); a cleartext user carries an ordinary verifier, or none. `User` is deliberately not `Serialize` and its `Debug` redacts key material — use `User::summary()` for anything leaving the process. `role_id` references `roles(id)` (the `roles` table is now seeded before `users` in `create_registry_tables`). A nullable `locale` column (additive via `ensure_column`) holds the per-user UI language override; role-driven conventions live in the free-form `roles.attrs` JSON — never new columns per attribute — parsed at a **single point** by the typed `db::roles::RoleAttrs` (`ui_mode`, `permission_groups`): `ui_mode` (see the frontend section) plus the role's **security-group set** (`roles.permission_group` = the default group, `attrs.permission_groups` = additional allowed groups; `Role::effective_groups()` = the union, `roles::role_allows_group()` gates it with `admin` short-circuiting to all). See the security-group picker in the frontend section. The admin-managed **directory profile** lives in three more additive columns — `birthdate` (ISO `YYYY-MM-DD`), `sex` (free text), `notes` (admin-authored) — edited only from the Users admin page (`set_directory_fields`; validation — real non-future date, length caps — lives in the `users_mgmt` API, not the db layer) and rendered into agent prompts by the `__USER_PROFILE__` substitution (see above). They are directory metadata written *by* the admin *about* the user, so the registry is their honest home under the §2 threat model. +`users` (`crates/skald-core/src/db/users.rs`) holds the directory plus auth material. It lives in the system DB, which the box owner can read, so it must never store anything that derives a user's key. `Credentials` is an enum mirroring the table's `CHECK`: an encrypted user carries a **wrapped DEK** (whose AEAD tag *is* the password verifier — hence no `password_hash`); a cleartext user carries an ordinary verifier, or none. `User` is deliberately not `Serialize` and its `Debug` redacts key material — use `User::summary()` for anything leaving the process. `role_id` references `roles(id)` (the `roles` table is now seeded before `users` in `create_registry_tables`). A nullable `locale` column (additive via `ensure_column`) holds the per-user UI language override; role-driven conventions live in the free-form `roles.attrs` JSON — never new columns per attribute — parsed at a **single point** by the typed `db::roles::RoleAttrs` (`ui_mode`, `permission_groups`, `chat_agent`): `ui_mode` (see the frontend section) plus the role's **security-group set** (`roles.permission_group` = the default group, `attrs.permission_groups` = additional allowed groups; `Role::effective_groups()` = the union, `roles::role_allows_group()` gates it with `admin` short-circuiting to all). See the security-group picker in the frontend section. The role's **default entry (chat) agent** is `attrs.chat_agent` — the neutral `chat`-type agent members of the role land on (§0.1: data, not an enum). Resolved by `roles::default_chat_agent_for_user(registry_pool, user_id)` — the single seam behind both the per-user `ChatHub`'s `default_agent` (snapshotted at login in `UserContextFactory::build`, like fs/MCP access, so **every** session-creation path — explicit `provision_session`, lazy WS `get_or_create_session`, notify — honors it) and `provisioning_for_source`'s non-project branch. Falls back to `agents::DEFAULT_CHAT_AGENT` (`"assistant"`, the renamed former `main`) when unset. Seeded: `admin`/`member` → `assistant`, `children` → `kid` (Companion). A per-user override is future work, layering on top in the same resolver. The stack **root frame** is created with the session's own `agent_id` (not a literal) — `config.agent_id` (from the frame) drives which prompt runs, so a wrong id there silently runs the wrong agent. The admin-managed **directory profile** lives in three more additive columns — `birthdate` (ISO `YYYY-MM-DD`), `sex` (free text), `notes` (admin-authored) — edited only from the Users admin page (`set_directory_fields`; validation — real non-future date, length caps — lives in the `users_mgmt` API, not the db layer) and rendered into agent prompts by the `__USER_PROFILE__` substitution (see above). They are directory metadata written *by* the admin *about* the user, so the registry is their honest home under the §2 threat model. ## Filesystem & containers (blueprint §6) @@ -196,7 +196,7 @@ At context-build time (`MessageBuilder`), attachments of the **current turn** (t - **Restart recovery of a parallel batch** is intentionally lossy (single-user app): `resume_turn` first calls `reap_interrupted_parallel_batches`, which detects a batch by ≥2 active `chat_sessions_stack` frames at the same depth (impossible for a linear stack), fails their spawning tool calls and terminates the frames, then lets the normal linear cascade resume the parent. A lone interrupted sub-agent is untouched and still recovers via the cascade. - Client resolution order: `args.client` → `meta.json client` → AUTO selection by scope/strength. - **The parent's resolved client is NOT inherited.** Passing a concrete model name to `resolve()` bypasses strength/scope checks; sub-agents always auto-select unless overridden explicitly. -- `list_agents` is a plain tool; returns JSON excluding `main`. +- `list_agents` is a plain tool; returns JSON of **task** agents only (excludes `chat`/`system` agents like the `assistant` entry agent). - `resume_turn` (+ its cascade) is kept only for: app-restart recovery of an active child stack, async task result injection (`inject_async_result`), and the WS resume message — not for the normal sync dispatch. ## Cancellation (stop) @@ -274,7 +274,7 @@ All extend `LightElement` from `web/lib/base.js` (Lit). `ChatSession` (`web/lib/ **Role-driven interface** (§0.1 — data, not enums): `roles.attrs` JSON may carry `"ui_mode": "simple"`. `/api/auth/me` resolves it via `RoleAttrs` (`admin` is always `full`) and the sidebar renders chat + inbox only for simple-mode members; the role editor exposes it as an "Interface" select. Hiding links is never access control — routes stay capability-gated server-side. `MeResponse` also carries `locale`, `default_locale` and `encrypted`. -**Security-group picker (per-session, runtime, role-gated).** A **security-group** is a *permission bundle only* — a `tool_permission_groups` id, driving tool visibility/approval — **not** a "mode" (no system-context injection; the `RunContext.system_prompt` substrate exists but is unused by the picker). The role carries the user's **allowed set** (default `permission_group` + `attrs.permission_groups`, §0.1); a new non-project session inherits the role's default group (`sessions.rs::create` → `role_default_run_context`). The chat surface switches it **at runtime like the model pill**: `copilot.js` renders a shield pill (hidden when ≤1 group) fed by `GET /api/my/security-groups` (the caller's role set, joined with group names; `admin` → all); selecting one sends the WS control message `{type:"select_security_group", group}` (`chat-session.js::_selectGroup`, twin of `select_client`). The server (`ws.rs::handle_select_security_group_msg`) validates against the role, persists it on `chat_sessions.run_context`, updates the live handler, and **broadcasts `ServerEvent::SecurityGroupSelected`** so every open tab re-syncs (the initial state is sent on WS connect). **Enforcement is server-side** via the shared `run_context::validate_run_context_for_role` (used by both the WS path and the REST `set_session_run_context`): a non-admin may only pick a group in its role's effective set (else 403), and **every other `RunContext` field** (`system_prompt`, `allow_fs_writes`/`allow_fs_reads`, `working_directory`) is **discarded** — closing an fs-escalation hole; `admin` passes through unchanged. The role editor (`roles-page.js`) sets the default group + an allowed-groups checklist (→ `attrs.permission_groups`). +**Security-group picker (per-session, runtime, role-gated).** A **security-group** is a *permission bundle only* — a `tool_permission_groups` id, driving tool visibility/approval — **not** a "mode" (no system-context injection; the `RunContext.system_prompt` substrate exists but is unused by the picker). The role carries the user's **allowed set** (default `permission_group` + `attrs.permission_groups`, §0.1); a new non-project session inherits the role's default group (`sessions.rs::create` → `role_default_run_context`). The chat surface switches it **at runtime like the model pill**: `copilot.js` renders a shield pill (hidden when ≤1 group) fed by `GET /api/my/security-groups` (the caller's role set, joined with group names; `admin` → all); selecting one sends the WS control message `{type:"select_security_group", group}` (`chat-session.js::_selectGroup`, twin of `select_client`). The server (`ws.rs::handle_select_security_group_msg`) validates against the role, persists it on `chat_sessions.run_context`, updates the live handler, and **broadcasts `ServerEvent::SecurityGroupSelected`** so every open tab re-syncs (the initial state is sent on WS connect). **Enforcement is server-side** via the shared `run_context::validate_run_context_for_role` (used by both the WS path and the REST `set_session_run_context`): a non-admin may only pick a group in its role's effective set (else 403), and **every other `RunContext` field** (`system_prompt`, `allow_fs_writes`/`allow_fs_reads`, `working_directory`) is **discarded** — closing an fs-escalation hole; `admin` passes through unchanged. The role editor (`roles-page.js`) sets the default group + an allowed-groups checklist (→ `attrs.permission_groups`) + a **default-assistant** select (→ `attrs.chat_agent`) fed by `GET /api/agents` filtered to `type:chat` minus `project-coordinator` (source-driven); the same exclusion is enforced server-side in the roles API (`validate_chat_agent`). | File | Element | Notes | | ---- | ------- | ----- | diff --git a/agents/main/AGENT.md b/agents/assistant/AGENT.md similarity index 100% rename from agents/main/AGENT.md rename to agents/assistant/AGENT.md diff --git a/agents/main/icon.png b/agents/assistant/icon.png similarity index 100% rename from agents/main/icon.png rename to agents/assistant/icon.png diff --git a/agents/main/meta.json b/agents/assistant/meta.json similarity index 87% rename from agents/main/meta.json rename to agents/assistant/meta.json index ca714b8..4ecdf13 100644 --- a/agents/main/meta.json +++ b/agents/assistant/meta.json @@ -1,14 +1,14 @@ { - "name": "Main Assistant", + "name": "Assistant", "description": "General-purpose assistant: helps the user with any task using tools, and persists all relevant information in memory", "friendly_description": "Your general-purpose assistant — helps with any task and remembers what matters in memory.", "i18n": { "it": { - "name": "Assistente Principale", + "name": "Assistente", "friendly_description": "Il tuo assistente tuttofare — ti aiuta in qualsiasi attività e ricorda ciò che conta nella memoria." }, "fr": { - "name": "Assistant Principal", + "name": "Assistant", "friendly_description": "Votre assistant polyvalent — vous aide dans toutes vos tâches et retient l'essentiel en mémoire." } }, diff --git a/crates/core-api/src/chat_hub.rs b/crates/core-api/src/chat_hub.rs index 88ee1f2..a9f1849 100644 --- a/crates/core-api/src/chat_hub.rs +++ b/crates/core-api/src/chat_hub.rs @@ -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, /// Named substitutions applied to the agent's system prompt. /// Each entry replaces the sentinel `__KEY__` in the loaded prompt text. diff --git a/crates/skald-core/src/agents.rs b/crates/skald-core/src/agents.rs index 9e6edd2..ae58adf 100644 --- a/crates/skald-core/src/agents.rs +++ b/crates/skald-core/src/agents.rs @@ -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`, diff --git a/crates/skald-core/src/approval/mod.rs b/crates/skald-core/src/approval/mod.rs index 5d9c623..af5243e 100644 --- a/crates/skald-core/src/approval/mod.rs +++ b/crates/skald-core/src/approval/mod.rs @@ -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)); diff --git a/crates/skald-core/src/chat_hub/mod.rs b/crates/skald-core/src/chat_hub/mod.rs index 0d061ee..ce9e54b 100644 --- a/crates/skald-core/src/chat_hub/mod.rs +++ b/crates/skald-core/src/chat_hub/mod.rs @@ -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>, + /// 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, global_tx: broadcast::Sender, shutdown: CancellationToken, + default_agent: String, ) -> Arc { let (notify_tx, notify_rx) = mpsc::channel::(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>, ) -> 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> { - 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 { - 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, Option)> { - 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> { - 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; } }; diff --git a/crates/skald-core/src/db/chat_sessions_stack.rs b/crates/skald-core/src/db/chat_sessions_stack.rs index e493b44..4ad654b 100644 --- a/crates/skald-core/src/db/chat_sessions_stack.rs +++ b/crates/skald-core/src/db/chat_sessions_stack.rs @@ -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(); diff --git a/crates/skald-core/src/db/roles.rs b/crates/skald-core/src/db/roles.rs index ad858da..29c9be5 100644 --- a/crates/skald-core/src/db/roles.rs +++ b/crates/skald-core/src/db/roles.rs @@ -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, + /// 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, } 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 { @@ -189,9 +212,11 @@ pub async fn user_count(pool: &SqlitePool, role_id: &str) -> Result { /// 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); + } } diff --git a/crates/skald-core/src/session/handler/mod.rs b/crates/skald-core/src/session/handler/mod.rs index 2c4d51a..1603800 100644 --- a/crates/skald-core/src/session/handler/mod.rs +++ b/crates/skald-core/src/session/handler/mod.rs @@ -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? } }; diff --git a/crates/skald-core/src/session/manager.rs b/crates/skald-core/src/session/manager.rs index 44304be..0bcf1ce 100644 --- a/crates/skald-core/src/session/manager.rs +++ b/crates/skald-core/src/session/manager.rs @@ -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)) } diff --git a/crates/skald-core/src/setup/mod.rs b/crates/skald-core/src/setup/mod.rs index fcf5c65..d67eef0 100644 --- a/crates/skald-core/src/setup/mod.rs +++ b/crates/skald-core/src/setup/mod.rs @@ -45,13 +45,14 @@ pub fn seed_profiles() -> Vec { 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"}"#), }, ], }] diff --git a/crates/skald-core/src/skald/bundles.rs b/crates/skald-core/src/skald/bundles.rs index f63c8e5..55fb3e6 100644 --- a/crates/skald-core/src/skald/bundles.rs +++ b/crates/skald-core/src/skald/bundles.rs @@ -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; diff --git a/crates/skald-core/src/skald/user_context.rs b/crates/skald-core/src/skald/user_context.rs index 063795c..2083e73 100644 --- a/crates/skald-core/src/skald/user_context.rs +++ b/crates/skald-core/src/skald/user_context.rs @@ -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; diff --git a/src/frontend/api/projects.rs b/src/frontend/api/projects.rs index 62d81e3..66f8667 100644 --- a/src/frontend/api/projects.rs +++ b/src/frontend/api/projects.rs @@ -30,7 +30,7 @@ use super::{ApiError, guard::AuthUser, require_context}; pub const PROJECT_SOURCE_PREFIX: &str = "project-"; /// Agent that drives interactive project-chat sessions. -const PROJECT_COORDINATOR_AGENT: &str = "project-coordinator"; +pub(crate) const PROJECT_COORDINATOR_AGENT: &str = "project-coordinator"; // ── Request/Response types ──────────────────────────────────────────────────── @@ -339,7 +339,10 @@ pub async fn provisioning_for_source( .strip_prefix(PROJECT_SOURCE_PREFIX) .and_then(|s| s.parse::().ok()) else { - return Ok(("main".to_string(), None)); + // Non-project source → the caller's role-assigned entry agent (same resolver + // the per-user hub uses, so explicit-create and lazy-create never diverge). + let agent = skald_core::db::roles::default_chat_agent_for_user(skald.db(), user_id).await; + return Ok((agent, None)); }; let (project, _can_write) = require_member(skald, id, user_id).await?; diff --git a/src/frontend/api/roles.rs b/src/frontend/api/roles.rs index a9f7561..1221c10 100644 --- a/src/frontend/api/roles.rs +++ b/src/frontend/api/roles.rs @@ -3,16 +3,38 @@ use std::sync::Arc; use axum::{Json, extract::{Path, State}}; use serde::Deserialize; -use skald_core::db::roles::{self, ADMIN_ROLE_ID, Role}; +use skald_core::agents::{self, AgentType}; +use skald_core::db::roles::{self, ADMIN_ROLE_ID, RoleAttrs, Role}; use skald_core::skald::Skald; use super::ApiError; +use super::projects::PROJECT_COORDINATOR_AGENT; pub async fn list(State(skald): State>) -> Result>, ApiError> { let roles = roles::list(skald.db()).await?; Ok(Json(roles)) } +/// If the role's `attrs.chat_agent` is set, it must name an existing **chat** agent +/// other than the source-bound `project-coordinator` (which needs a project run-context +/// and is never a sensible personal default). Empty/absent is fine — the resolver falls +/// back to `DEFAULT_CHAT_AGENT`. Shared by create + update so the two can't drift. +fn validate_chat_agent(attrs: &Option) -> Result<(), ApiError> { + let Some(agent) = RoleAttrs::from_opt(attrs).chat_agent.filter(|s| !s.trim().is_empty()) else { + return Ok(()); + }; + if agent == PROJECT_COORDINATOR_AGENT { + return Err(ApiError::bad_request( + "project-coordinator is source-driven and cannot be a role's default assistant", + )); + } + match agents::load_meta(&agent) { + Ok(meta) if meta.agent_type == AgentType::Chat => Ok(()), + Ok(_) => Err(ApiError::bad_request(format!("agent '{agent}' is not a chat agent"))), + Err(_) => Err(ApiError::bad_request(format!("unknown agent '{agent}'"))), + } +} + #[derive(Deserialize)] pub struct CreateBody { pub id: String, @@ -35,6 +57,7 @@ pub async fn create( if body.permission_group.trim().is_empty() { return Err(ApiError::bad_request("permission group must not be empty")); } + validate_chat_agent(&body.attrs)?; roles::insert(skald.db(), id, body.label.trim(), &body.permission_group, body.attrs.as_deref()) .await?; // Seed the standard self-service Connector capabilities (§14): a new role can @@ -66,6 +89,7 @@ pub async fn update( if body.permission_group.trim().is_empty() { return Err(ApiError::bad_request("permission group must not be empty")); } + validate_chat_agent(&body.attrs)?; let ok = roles::update(skald.db(), &id, body.label.trim(), &body.permission_group, body.attrs.as_deref()) .await?; if !ok { diff --git a/web/components/copilot-render.js b/web/components/copilot-render.js index 8f3ea1c..b4c17f7 100644 --- a/web/components/copilot-render.js +++ b/web/components/copilot-render.js @@ -313,7 +313,7 @@ export function renderAgent(msg) {
- ${msg.parent_agent_id ?? 'main'} + ${msg.parent_agent_id ?? 'assistant'} ${msg.agent_id} @@ -334,7 +334,7 @@ export function renderAgentEnd(msg) { ${msg.agent_id} - ${msg.parent_agent_id ?? 'main'} + ${msg.parent_agent_id ?? 'assistant'} ${t('copilot.agent_finished')}
diff --git a/web/components/roles-page.js b/web/components/roles-page.js index 90b8a5c..d87ae3c 100644 --- a/web/components/roles-page.js +++ b/web/components/roles-page.js @@ -4,6 +4,9 @@ import { LightElement } from '../lib/base.js'; import { t } from '../lib/i18n.js'; const ADMIN_ID = 'admin'; +// Source-driven chat agent (needs a project run-context): never a personal default, +// so it is excluded from the role assistant picker (mirrors the server-side guard). +const PROJECT_COORDINATOR_ID = 'project-coordinator'; export class RolesPage extends LightElement { @@ -12,6 +15,7 @@ export class RolesPage extends LightElement { _open: { state: true }, _roles: { state: true }, _groups: { state: true }, + _agents: { state: true }, _error: { state: true }, _modal: { state: true }, // null | { mode: 'create'|'edit', role?, form } }; @@ -22,6 +26,7 @@ export class RolesPage extends LightElement { this._open = false; this._roles = null; this._groups = null; + this._agents = null; this._error = null; this._modal = null; } @@ -45,14 +50,19 @@ export class RolesPage extends LightElement { async _load() { this._error = null; try { - const [rRes, gRes] = await Promise.all([ + const [rRes, gRes, aRes] = await Promise.all([ fetch('/api/roles'), fetch('/api/tool-permission-groups'), + fetch('/api/agents'), ]); if (!rRes.ok) throw new Error(`HTTP ${rRes.status}`); if (!gRes.ok) throw new Error(`HTTP ${gRes.status}`); + if (!aRes.ok) throw new Error(`HTTP ${aRes.status}`); this._roles = await rRes.json(); this._groups = await gRes.json(); + // Only `type:chat` agents are entry agents; project-coordinator is source-driven. + this._agents = (await aRes.json()) + .filter(a => a.type === 'chat' && a.id !== PROJECT_COORDINATOR_ID); } catch (e) { this._error = e.message; } @@ -76,12 +86,26 @@ export class RolesPage extends LightElement { } catch { return []; } } - _mergeAttrs(attrs, uiMode, allowedGroups) { + // The role's default entry agent (data-driven, §0.1). Empty means "fall back to the + // instance default assistant" — the server resolver handles it, so we store nothing. + _attrsChatAgent(attrs) { + try { const a = JSON.parse(attrs || '{}').chat_agent; return typeof a === 'string' ? a : ''; } + catch { return ''; } + } + + // Display name for a chat-agent id (falls back to the id, or the default label when unset). + _agentName(id) { + if (!id) return t('roles.form.assistant_default'); + return this._agents?.find(a => a.id === id)?.name ?? id; + } + + _mergeAttrs(attrs, uiMode, allowedGroups, chatAgent) { let o = {}; try { o = JSON.parse(attrs || '{}') ?? {}; } catch { o = {}; } if (uiMode === 'simple') o.ui_mode = 'simple'; else delete o.ui_mode; const extras = Array.isArray(allowedGroups) ? allowedGroups.filter(Boolean) : []; if (extras.length) o.permission_groups = extras; else delete o.permission_groups; + if (chatAgent) o.chat_agent = chatAgent; else delete o.chat_agent; const keys = Object.keys(o); return keys.length ? JSON.stringify(o) : null; } @@ -89,7 +113,7 @@ export class RolesPage extends LightElement { _openCreate() { this._modal = { mode: 'create', - form: { id: '', label: '', permission_group: this._groups?.[0]?.id ?? 'default', attrs: '', ui_mode: 'full', allowed_groups: [] }, + form: { id: '', label: '', permission_group: this._groups?.[0]?.id ?? 'default', attrs: '', ui_mode: 'full', allowed_groups: [], chat_agent: '' }, }; } @@ -97,7 +121,7 @@ export class RolesPage extends LightElement { this._modal = { mode: 'edit', role, - form: { label: role.label, permission_group: role.permission_group, attrs: role.attrs ?? '', ui_mode: this._attrsUiMode(role.attrs), allowed_groups: this._attrsAllowedGroups(role.attrs) }, + form: { label: role.label, permission_group: role.permission_group, attrs: role.attrs ?? '', ui_mode: this._attrsUiMode(role.attrs), allowed_groups: this._attrsAllowedGroups(role.attrs), chat_agent: this._attrsChatAgent(role.attrs) }, }; } @@ -129,7 +153,7 @@ export class RolesPage extends LightElement { id: form.id.trim(), label: form.label.trim(), permission_group: form.permission_group, - attrs: this._mergeAttrs(form.attrs, form.ui_mode, form.allowed_groups), + attrs: this._mergeAttrs(form.attrs, form.ui_mode, form.allowed_groups, form.chat_agent), }), }); if (!res.ok) throw new Error(await res.text()); @@ -146,7 +170,7 @@ export class RolesPage extends LightElement { body: JSON.stringify({ label: form.label.trim(), permission_group: form.permission_group, - attrs: this._mergeAttrs(form.attrs, form.ui_mode, form.allowed_groups), + attrs: this._mergeAttrs(form.attrs, form.ui_mode, form.allowed_groups, form.chat_agent), }), }); if (!res.ok) throw new Error(await res.text()); @@ -226,6 +250,14 @@ export class RolesPage extends LightElement {
${unsafeHTML(t('roles.form.interface_hint'))}
+
+ + +
${t('roles.form.assistant_hint')}
+
${t('roles.col.label')} ${t('roles.col.group')} ${t('roles.col.interface')} + ${t('roles.col.assistant')} @@ -289,6 +322,7 @@ export class RolesPage extends LightElement { ${this._attrsUiMode(r.attrs) === 'simple' ? html`${t('roles.badge.simple')}` : html`${t('roles.badge.full')}`} + ${this._agentName(this._attrsChatAgent(r.attrs))}