Version 0.0.1 #2

Merged
dguiducci merged 42 commits from main into release 2026-07-22 14:44:42 +01:00
22 changed files with 204 additions and 44 deletions
Showing only changes of commit 8ff64cbddc - Show all commits
+4 -4
View File
@@ -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 `<!-- KEY -->` 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 |
| ---- | ------- | ----- |

Before

Width:  |  Height:  |  Size: 1.4 MiB

After

Width:  |  Height:  |  Size: 1.4 MiB

@@ -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."
}
},
+4 -2
View File
@@ -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.
+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;
+5 -2
View File
@@ -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::<i64>().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?;
+25 -1
View File
@@ -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<Arc<Skald>>) -> Result<Json<Vec<Role>>, 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<String>) -> 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 {
+2 -2
View File
@@ -313,7 +313,7 @@ export function renderAgent(msg) {
<div class="copilot-agent-header">
<i class="bi bi-${icon}"></i>
<span>
<strong>${msg.parent_agent_id ?? 'main'}</strong>
<strong>${msg.parent_agent_id ?? 'assistant'}</strong>
<i class="bi bi-arrow-right mx-1" style="font-size:0.7rem"></i>
<strong>${msg.agent_id}</strong>
</span>
@@ -334,7 +334,7 @@ export function renderAgentEnd(msg) {
<span>
<strong>${msg.agent_id}</strong>
<i class="bi bi-arrow-right mx-1" style="font-size:0.7rem"></i>
<strong>${msg.parent_agent_id ?? 'main'}</strong>
<strong>${msg.parent_agent_id ?? 'assistant'}</strong>
</span>
<span class="copilot-agent-badge done">${t('copilot.agent_finished')}</span>
</div>
+40 -6
View File
@@ -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 {
</select>
<div class="form-text" style="font-size:.75rem">${unsafeHTML(t('roles.form.interface_hint'))}</div>
</div>
<div class="mb-3">
<label class="form-label">${t('roles.form.assistant')}</label>
<select class="form-select" @change=${e => this._patch('chat_agent', e.target.value)}>
<option value="" ?selected=${!form.chat_agent}>${t('roles.form.assistant_default')}</option>
${(this._agents ?? []).map(a => html`<option value=${a.id} ?selected=${form.chat_agent === a.id}>${a.name}</option>`)}
</select>
<div class="form-text" style="font-size:.75rem">${t('roles.form.assistant_hint')}</div>
</div>
<div class="mb-3">
<label class="form-label">${t('roles.form.attrs')} <span class="text-muted">${t('roles.form.attrs_hint')}</span></label>
<input class="form-control font-monospace" placeholder=${t('roles.form.attrs_ph')} .value=${form.attrs}
@@ -275,6 +307,7 @@ export class RolesPage extends LightElement {
<th>${t('roles.col.label')}</th>
<th>${t('roles.col.group')}</th>
<th>${t('roles.col.interface')}</th>
<th>${t('roles.col.assistant')}</th>
<th></th>
</tr>
</thead>
@@ -289,6 +322,7 @@ export class RolesPage extends LightElement {
<td>${this._attrsUiMode(r.attrs) === 'simple'
? html`<span class="badge" style="background:var(--accent-soft);color:var(--accent)">${t('roles.badge.simple')}</span>`
: html`<span class="badge bg-secondary">${t('roles.badge.full')}</span>`}</td>
<td>${this._agentName(this._attrsChatAgent(r.attrs))}</td>
<td>
<div class="um-actions">
<button class="um-btn-icon" title=${isAdmin ? t('roles.tooltip.locked') : t('roles.tooltip.edit')}
+6 -2
View File
@@ -118,7 +118,7 @@ export default {
'llmr.empty': 'No requests found.',
'llmr.total': '{n} rows',
'llmr.filter.agent_id': 'Agent ID',
'llmr.filter.agent_ph': 'e.g. main',
'llmr.filter.agent_ph': 'e.g. assistant',
'llmr.filter.source': 'Source',
'llmr.filter.source_ph': 'e.g. web, tic, cron',
'llmr.filter.from': 'From',
@@ -556,7 +556,7 @@ export default {
'approval.form.source': 'Source',
'approval.form.source_any': 'Any',
'approval.form.agent_id': 'Agent ID',
'approval.form.agent_id_ph': 'main (empty = any)',
'approval.form.agent_id_ph': 'assistant (empty = any)',
'approval.form.note': 'Note',
'approval.form.note_ph': 'Short description…',
'approval.form.cancel': 'Cancel',
@@ -656,6 +656,7 @@ export default {
'roles.col.label': 'Label',
'roles.col.group': 'Permission group',
'roles.col.interface': 'Interface',
'roles.col.assistant': 'Assistant',
'roles.badge.simple': 'Simple',
'roles.badge.full': 'Full',
@@ -674,6 +675,9 @@ export default {
'roles.form.interface_full': 'Full — all pages',
'roles.form.interface_simple': 'Simple — chat only',
'roles.form.interface_hint': 'Members with the simple interface see only chat and inbox. Stored as <code>ui_mode</code> in the attrs JSON below.',
'roles.form.assistant': 'Default assistant',
'roles.form.assistant_default': 'Default (Assistant)',
'roles.form.assistant_hint': 'The agent members of this role chat with by default. Overridable per person later.',
'roles.form.attrs': 'Attrs',
'roles.form.attrs_hint': '(JSON, optional)',
'roles.form.attrs_ph': '{}',
+6 -2
View File
@@ -118,7 +118,7 @@ export default {
'llmr.empty': 'Aucune requête trouvée.',
'llmr.total': '{n} lignes',
'llmr.filter.agent_id': 'ID de l\'agent',
'llmr.filter.agent_ph': 'ex. main',
'llmr.filter.agent_ph': 'ex. assistant',
'llmr.filter.source': 'Source',
'llmr.filter.source_ph': 'ex. web, tic, cron',
'llmr.filter.from': 'De',
@@ -556,7 +556,7 @@ export default {
'approval.form.source': 'Source',
'approval.form.source_any': 'Toute',
'approval.form.agent_id': 'ID de l\'agent',
'approval.form.agent_id_ph': 'main (vide = toute)',
'approval.form.agent_id_ph': 'assistant (vide = toute)',
'approval.form.note': 'Note',
'approval.form.note_ph': 'Brève description…',
'approval.form.cancel': 'Annuler',
@@ -656,6 +656,7 @@ export default {
'roles.col.label': 'Libellé',
'roles.col.group': 'Groupe de permissions',
'roles.col.interface': 'Interface',
'roles.col.assistant': 'Assistant',
'roles.badge.simple': 'Simple',
'roles.badge.full': 'Complet',
@@ -674,6 +675,9 @@ export default {
'roles.form.interface_full': 'Complet — toutes les pages',
'roles.form.interface_simple': 'Simple — discussion uniquement',
'roles.form.interface_hint': 'Les membres avec l\'interface simple voient seulement la discussion et la boîte de réception. Stocké comme <code>ui_mode</code> dans le JSON d\'attributs ci-dessous.',
'roles.form.assistant': 'Assistant par défaut',
'roles.form.assistant_default': 'Par défaut (Assistant)',
'roles.form.assistant_hint': 'L\'agent avec lequel les membres de ce rôle discutent par défaut. Remplaçable par personne plus tard.',
'roles.form.attrs': 'Attributs',
'roles.form.attrs_hint': '(JSON, facultatif)',
'roles.form.attrs_ph': '{}',
+6 -2
View File
@@ -118,7 +118,7 @@ export default {
'llmr.empty': 'Nessuna richiesta trovata.',
'llmr.total': '{n} righe',
'llmr.filter.agent_id': 'ID Agente',
'llmr.filter.agent_ph': 'es. main',
'llmr.filter.agent_ph': 'es. assistant',
'llmr.filter.source': 'Sorgente',
'llmr.filter.source_ph': 'es. web, tic, cron',
'llmr.filter.from': 'Da',
@@ -556,7 +556,7 @@ export default {
'approval.form.source': 'Origine',
'approval.form.source_any': 'Qualsiasi',
'approval.form.agent_id': 'ID agente',
'approval.form.agent_id_ph': 'main (vuoto = qualsiasi)',
'approval.form.agent_id_ph': 'assistant (vuoto = qualsiasi)',
'approval.form.note': 'Nota',
'approval.form.note_ph': 'Breve descrizione…',
'approval.form.cancel': 'Annulla',
@@ -656,6 +656,7 @@ export default {
'roles.col.label': 'Etichetta',
'roles.col.group': 'Gruppo di permessi',
'roles.col.interface': 'Interfaccia',
'roles.col.assistant': 'Assistente',
'roles.badge.simple': 'Semplice',
'roles.badge.full': 'Completa',
@@ -674,6 +675,9 @@ export default {
'roles.form.interface_full': 'Completa — tutte le pagine',
'roles.form.interface_simple': 'Semplice — solo chat',
'roles.form.interface_hint': 'I membri con interfaccia semplice vedono solo chat e richieste. Memorizzato come <code>ui_mode</code> nell\'attrs JSON qui sotto.',
'roles.form.assistant': 'Assistente predefinito',
'roles.form.assistant_default': 'Predefinito (Assistente)',
'roles.form.assistant_hint': 'L\'agente con cui i membri di questo ruolo parlano di default. In futuro sovrascrivibile per persona.',
'roles.form.attrs': 'Attrs',
'roles.form.attrs_hint': '(JSON, opzionale)',
'roles.form.attrs_ph': '{}',