Compare commits

...
2 Commits
Author SHA1 Message Date
dguiducci cf5415ae88 docs: record the event-bus rule in the codebase guide
Nightly Build / build (push) Successful in 6m52s
Names the three global buses and their caps, and states the coupling rule
they exist for: a producer emits an event rather than calling the
consumer, and a new channel is a code-review flag until proven necessary.
2026-07-26 21:41:57 +01:00
dguiducci 6f35c53d93 activate_tools: diagnose a group instead of pretending it activated
A group name that resolved to no running MCP server was granted anyway,
persisted in `activated_tools`, and reported as a success with "registered
but not yet running — tools will appear after reconnect". Every part of
that was false: nothing registers connectors anymore (the agent-facing
`register_mcp` went away with §14), no reconnect will ever produce the
tools, and the model — believing it had succeeded — called `mcp__x__…` a
round later and failed there instead of here. The junk grant row stayed in
the session forever, resolving to zero tool defs on every projection.

`SkaldToolActivator` now resolves first and acts only on what resolved,
walking the connector states in order: the built-in `config` group, then
the servers running in this user's view, then `mcp_user_servers` (owner
pool), then `mcp_global_servers` + `mcp_global_access`, then `mcp_catalog`
+ `mcp_catalog_access` (registry pool), then unknown. Only `activated`
touches the in-memory grant set and the DB; everything else leaves no
trace at all.

The result is a JSON object keyed by group name — `status` (activated /
needs_login / not_activated / not_authorized / unavailable / unknown),
`tool_prefix`, `tool_count`, `description`, `message` — with the same
shape whether the call succeeded or not, so the model parses one thing
rather than prose. `message` is written to be relayed to a non-technical
user and says who can fix it: the user in Connectors, or the admin. When
no group at all activates, the tool fails with that same JSON, so the
model reports the diagnosis instead of proceeding. A diagnosis query that
errors is logged and falls through to the next candidate — a broken lookup
must never become a false claim about a connector.

The activator needs the registry pool, the user id and the config defs;
both construction sites are updated, so a sub-agent gets the same
diagnosis as the root agent.

Removes `tools/activate_tools.rs` and its interface-tool registration: it
was unreachable (`SkaldToolSet::find` prefers natives, and `NATIVE_NAMES`
explicitly drops the legacy interface tool of that name) but carried the
same wrong string, waiting to be fixed twice.
2026-07-26 21:41:53 +01:00
7 changed files with 551 additions and 225 deletions
+16
View File
@@ -21,6 +21,22 @@ Load-bearing decisions from that document:
- **Threat model** (§2): the adversary is the **tempted admin**, who owns the box but does not recompile the binary or dump RAM. Do not design against a forensic attacker.
- **Roles are data, not enums** (§0.1): a `roles` table binds permission-group, run-context and data-handling attributes. "Children" is a seeded preset row, never a hardcoded type.
### Event-driven coupling — think in events, not calls
Three global broadcast buses — **never add a fourth without checking these first**:
| Bus | Cap | Events | File |
|-----|-----|--------|------|
| `ChatEventBus` | 256 | user message, assistant response, compaction done | `core-api/src/bus.rs` |
| `SystemEventBus` | 64 | provider (un)registered, config key updated, job completed, session cancelled | `core-api/src/system_bus.rs` |
| `GlobalEvent` (per-user) | 512 | all `ServerEvent` variants → WS clients + inbox lifecycle | `core-api/src/events.rs` |
Plus internal `mpsc` queues: per-source `SourceInbox` (message serialization) and a central `notify` queue (background agents → user).
**Before you add a direct function call or a new import between two components, stop and ask:** is one component producing data another needs? If yes, add a variant to an existing bus and spawn a subscriber. Don't call `some_manager.log_thing(...)` from the producer — emit a `ThingHappened` event on `SystemEventBus` and let the manager subscribe.
**A new `mpsc::channel` or `broadcast::channel` is a code-review flag.** Nine times out of ten you want one of the three buses above. If you truly need a new one, be ready to explain why none of the existing three fits.
### The core is domain-neutral — this is a hard rule
"Family" is **positioning, not architecture**. Schema, engine, API, identifiers **and comments** must never contain `family`, `household`, `parent`, `child` or `minor`. A pivot to teams, small orgs or care settings must not require renaming anything.
+498 -45
View File
@@ -8,10 +8,13 @@ use std::sync::{Arc, RwLock};
use agent_loop::activation::{Activation, ActivationSource, ToolActivator};
use agent_loop::ids::{FrameId, MessageId};
use agent_loop::tool::{ToolCtx, ToolFailure};
use serde_json::Value;
use serde_json::{Value, json};
use sqlx::SqlitePool;
use crate::db::{activated_tools, chat_llm_tools};
use crate::db::{
activated_tools, chat_llm_tools, mcp_catalog, mcp_catalog_access, mcp_global_access,
mcp_global_servers, mcp_user_servers,
};
use crate::mcp::McpProvider;
use crate::tools::tool_names::CONFIG_GROUP;
@@ -77,29 +80,323 @@ impl ActivationSource for SkaldActivationSource {
// ── ToolActivator ────────────────────────────────────────────────────────────
/// Backend of the crate's shipped `activate_tools` tool: validates the groups
/// against the catalog, updates the in-memory grant set **immediately** (next
/// round sees the tools), and persists the activation anchored at the
/// triggering assistant message (derived from the call's `chat_llm_tools`
/// row). Unifies what today lives split between `tools/activate_tools.rs`
/// (grants) and `llm_loop.rs` (persistence).
/// What a requested group resolved to. Only [`Status::Activated`] grants
/// anything: every other state means the group's tools cannot appear in this
/// session, and saying otherwise would have the model call `mcp__x__…` a round
/// later and fail there instead of here.
#[derive(Clone, Copy, PartialEq, Eq)]
enum Status {
/// Running (or the built-in `config` group) — granted and persisted.
Activated,
/// The user activated the connector but never finished signing in
/// (`auth_state != 'ready'`), or they disabled it.
NeedsLogin,
/// Installed and disabled, or in the catalog and never activated: the USER
/// can fix it from Connectors.
NotActivated,
/// Exists but the ADMIN must act (a global connector not enabled/granted, a
/// catalog entry this user is not authorized for).
NotAuthorized,
/// Meant to be running and isn't — a start/connect failure, not a
/// configuration one. Transient.
Unavailable,
/// No such connector anywhere.
Unknown,
}
impl Status {
fn as_str(self) -> &'static str {
match self {
Status::Activated => "activated",
Status::NeedsLogin => "needs_login",
Status::NotActivated => "not_activated",
Status::NotAuthorized => "not_authorized",
Status::Unavailable => "unavailable",
Status::Unknown => "unknown",
}
}
}
/// One group's outcome, rendered as one JSON object. The shape is identical in
/// success and failure: `status` is what the model branches on, `message` is
/// what it relays to the user (the audience is non-technical — see `docs/`).
struct GroupReport {
status: Status,
tool_prefix: Option<String>,
tool_count: usize,
description: Option<String>,
message: String,
}
impl GroupReport {
fn to_json(&self) -> Value {
json!({
"status": self.status.as_str(),
"tool_prefix": self.tool_prefix,
"tool_count": self.tool_count,
"description": self.description,
"message": self.message,
})
}
}
/// Backend of the crate's shipped `activate_tools` tool: resolves each group
/// against the runtime and the connector tables, updates the in-memory grant
/// set **immediately** for the ones that resolved (next round sees the tools),
/// and persists those activations anchored at the triggering assistant message
/// (derived from the call's `chat_llm_tools` row).
///
/// A group that cannot be activated is diagnosed rather than accepted: it
/// touches neither the grant set nor `activated_tools`, and the report says
/// which of the connector states (§7/§15) it is in and who can fix it.
pub struct SkaldToolActivator {
/// Owner pool — `mcp_user_servers` (this user's activations).
pool: Arc<SqlitePool>,
/// Registry pool — `mcp_catalog`, `mcp_global_servers` and the access grants.
shared_pool: Arc<SqlitePool>,
user_id: String,
mcp: Arc<dyn McpProvider>,
/// The reserved `config` group's defs, for its tool count.
config_defs: Arc<Vec<Value>>,
grants: Arc<RwLock<HashSet<String>>>,
session_id: i64,
stack: Option<i64>,
}
impl SkaldToolActivator {
#[allow(clippy::too_many_arguments)]
pub fn new(
pool: Arc<SqlitePool>,
shared_pool: Arc<SqlitePool>,
user_id: String,
mcp: Arc<dyn McpProvider>,
config_defs: Arc<Vec<Value>>,
grants: Arc<RwLock<HashSet<String>>>,
session_id: i64,
stack: Option<i64>,
) -> Self {
Self { pool, mcp, grants, session_id, stack }
Self { pool, shared_pool, user_id, mcp, config_defs, grants, session_id, stack }
}
/// Where the activated tools land, for the confirmation message.
fn scope_label(&self) -> &'static str {
match self.stack {
None => "this session",
Some(_) => "this sub-agent frame",
}
}
/// Resolves one group name to its state. A diagnosis query that fails is
/// logged and treated as "no row" — a broken lookup must not turn into a
/// false claim about the connector.
async fn resolve(&self, name: &str) -> GroupReport {
if name == CONFIG_GROUP {
return GroupReport {
status: Status::Activated,
tool_prefix: None,
tool_count: self.config_defs.len(),
description: Some(
"Built-in system-configuration tools: connectors, plugins, scheduled jobs, secrets."
.into(),
),
message: format!("Tools are in context for {} from the next round.", self.scope_label()),
};
}
// Running in this user's view (global per-user, already access-filtered).
let key = [name.to_string()];
let running = self.mcp.tools_for(&key);
if !running.is_empty() {
return GroupReport {
status: Status::Activated,
tool_prefix: Some(format!("mcp__{name}__")),
tool_count: running.len(),
description: self.mcp.server_descriptions().get(name).cloned().flatten(),
message: format!("Tools are in context for {} from the next round.", self.scope_label()),
};
}
// Not running — diagnose, from the user's own activations outward.
if let Some(row) = self
.lookup(mcp_user_servers::get_by_name(&self.pool, name).await, "mcp_user_servers", name)
.flatten()
{
let description = self.catalog_description(row.catalog_name.as_deref()).await;
if !row.enabled {
return GroupReport {
status: Status::NotActivated,
tool_prefix: None,
tool_count: 0,
description,
message: format!(
"The `{name}` connector is set up for this user but switched off. \
Tell the user to re-enable it in Connectors."
),
};
}
if row.auth_state != "ready" {
let how = self.login_hint(row.catalog_name.as_deref()).await;
return GroupReport {
status: Status::NeedsLogin,
tool_prefix: None,
tool_count: 0,
description,
message: format!(
"The `{name}` connector is installed but the sign-in was never completed. \
Tell the user to open Connectors → {name} and {how}."
),
};
}
return GroupReport {
status: Status::Unavailable,
tool_prefix: None,
tool_count: 0,
description,
message: format!(
"The `{name}` connector is set up and enabled but its server is not running \
right now — it failed to start. This is temporary and not something the user \
can fix from the interface."
),
};
}
if let Some(row) = self
.lookup(mcp_global_servers::get_by_name(&self.shared_pool, name).await, "mcp_global_servers", name)
.flatten()
{
let description = row.description.clone();
if !row.enabled {
return GroupReport {
status: Status::NotAuthorized,
tool_prefix: None,
tool_count: 0,
description,
message: format!(
"`{name}` is a shared connector that the administrator has disabled. \
Only an administrator can turn it back on."
),
};
}
let granted = self
.lookup(mcp_global_access::has_access(&self.shared_pool, row.id, &self.user_id).await, "mcp_global_access", name)
.unwrap_or(false);
if !granted {
return GroupReport {
status: Status::NotAuthorized,
tool_prefix: None,
tool_count: 0,
description,
message: format!(
"`{name}` is a shared connector this user has not been given access to. \
Only an administrator can grant it."
),
};
}
return GroupReport {
status: Status::Unavailable,
tool_prefix: None,
tool_count: 0,
description,
message: format!(
"`{name}` is enabled and granted but its server is not running right now — \
it failed to start. This is temporary and not something the user can fix."
),
};
}
if let Some(row) = self
.lookup(mcp_catalog::get_by_name(&self.shared_pool, name).await, "mcp_catalog", name)
.flatten()
{
let description = row.description.clone();
if row.scope == "global" {
return GroupReport {
status: Status::NotAuthorized,
tool_prefix: None,
tool_count: 0,
description,
message: format!(
"`{name}` is installed but not switched on as a shared connector. \
Only an administrator can enable it."
),
};
}
let authorized = self
.lookup(mcp_catalog_access::has_access(&self.shared_pool, name, &self.user_id).await, "mcp_catalog_access", name)
.unwrap_or(false);
return if authorized {
GroupReport {
status: Status::NotActivated,
tool_prefix: None,
tool_count: 0,
description,
message: format!(
"`{name}` is available to this user but not activated yet. \
Tell the user they can activate it in Connectors, then ask again."
),
}
} else {
GroupReport {
status: Status::NotAuthorized,
tool_prefix: None,
tool_count: 0,
description,
message: format!(
"`{name}` is installed on this instance but this user is not authorized \
to activate it. Only an administrator can authorize them."
),
}
};
}
GroupReport {
status: Status::Unknown,
tool_prefix: None,
tool_count: 0,
description: None,
message: format!(
"There is no connector named `{name}`. Valid group names are the ones listed in \
the MCP servers table of your context, plus the reserved `config`. Do not guess \
a name; if the user needs this capability, they can look for it in the Connectors \
marketplace."
),
}
}
/// A diagnosis lookup: `Err` is a broken query, not an answer — log it and
/// fall through to the next candidate rather than mislabelling the group.
fn lookup<T>(&self, r: anyhow::Result<T>, table: &str, name: &str) -> Option<T> {
match r {
Ok(v) => Some(v),
Err(e) => {
tracing::warn!(table, group = name, error = %e, "activate_tools: diagnosis lookup failed");
None
}
}
}
/// The catalog blurb of the entry a user activation came from — the only
/// description a non-running connector has.
async fn catalog_description(&self, catalog_name: Option<&str>) -> Option<String> {
let name = catalog_name?;
mcp_catalog::get_by_name(&self.shared_pool, name).await.ok().flatten()?.description
}
/// How this connector's sign-in is completed, per its catalog `auth_kind`.
async fn login_hint(&self, catalog_name: Option<&str>) -> &'static str {
let kind = match catalog_name {
Some(n) => mcp_catalog::get_by_name(&self.shared_pool, n)
.await
.ok()
.flatten()
.map(|c| c.auth_kind),
None => None,
};
match kind.as_deref() {
Some("oauth") => "complete the sign-in (approve access, then paste the code back)",
Some("qr") => "scan the QR code with the device they want to link",
_ => "finish setting it up",
}
}
}
@@ -110,13 +407,32 @@ impl ToolActivator for SkaldToolActivator {
return Err(ToolFailure::Failed("activate_tools: `groups` is empty".into()));
}
let available: HashSet<String> = self.mcp.tools().iter().map(|t| t.server_name.clone()).collect();
// Immediate in-memory effect (the defs re-read at the next round picks
// the new grants up for free).
{
let mut set = self.grants.write().map_err(|_| ToolFailure::Failed("activate_tools: lock poisoned".into()))?;
// Resolve first, act only on what resolved: an unknown or unconfigured
// group must leave no trace, in RAM or in the DB.
let mut reports: Vec<(String, GroupReport)> = Vec::new();
for g in &groups {
if reports.iter().any(|(n, _)| n == g) {
continue; // the same group twice in one call
}
let report = self.resolve(g).await;
reports.push((g.clone(), report));
}
let activated: Vec<String> = reports
.iter()
.filter(|(_, r)| r.status == Status::Activated)
.map(|(n, _)| n.clone())
.collect();
if !activated.is_empty() {
// Immediate in-memory effect (the defs re-read at the next round
// picks the new grants up for free).
{
let mut set = self
.grants
.write()
.map_err(|_| ToolFailure::Failed("activate_tools: lock poisoned".into()))?;
for g in &activated {
set.insert(g.clone());
}
}
@@ -128,32 +444,30 @@ impl ToolActivator for SkaldToolActivator {
.await
.map_err(|e| ToolFailure::Failed(format!("activate_tools: anchor lookup failed: {e}")))?
.ok_or_else(|| ToolFailure::Failed("activate_tools: call row not found".into()))?;
for g in &groups {
for g in &activated {
let kind = if g == CONFIG_GROUP { "builtin" } else { "mcp" };
activated_tools::grant(&self.pool, self.session_id, self.stack, call.message_id, kind, g)
.await
.map_err(|e| ToolFailure::Failed(format!("activate_tools: grant failed: {e}")))?;
}
let activated: Vec<String> = groups
.iter()
.map(|n| {
if n == CONFIG_GROUP || available.contains(n) {
format!("{n}")
} else {
format!("{n} (registered but not yet running — tools will appear after reconnect)")
}
})
.collect();
let scope = match self.stack {
None => "session".to_string(),
Some(s) => format!("stack {s}"),
};
Ok(format!(
"Tool groups activated for this {scope}: {}. \
Their tools are available from the next tool-call round.",
activated.join(", ")
))
// One JSON object keyed by group name, same shape whether the call
// succeeded or not — the model parses one thing, never prose.
let body = Value::Object(
reports
.iter()
.map(|(name, r)| (name.clone(), r.to_json()))
.collect(),
);
let text = serde_json::to_string(&body).unwrap_or_else(|_| "{}".to_string());
if activated.is_empty() {
// Nothing was activated: fail, so the model treats it as an error
// and relays the diagnosis instead of proceeding as if it worked.
return Err(ToolFailure::Failed(text));
}
Ok(text)
}
}
@@ -249,23 +563,54 @@ mod tests {
}
}
#[tokio::test]
async fn activate_grants_in_memory_and_persists_anchored() {
let f = fixture("act-grant").await;
let mcp: Arc<dyn McpProvider> = Arc::new(FakeMcp::with_server("gmail", &["send", "read"]));
let grants = Arc::new(RwLock::new(HashSet::new()));
let activator = SkaldToolActivator::new(f.pool.clone(), mcp, grants.clone(), 1, None);
/// The fixture's pool is both the owner and the registry pool: `init_system_pool`
/// creates both buckets in one file, which is exactly what a diagnosis needs.
fn activator(f: &Fixture, mcp: Arc<dyn McpProvider>, grants: Arc<RwLock<HashSet<String>>>) -> SkaldToolActivator {
SkaldToolActivator::new(
f.pool.clone(),
f.pool.clone(),
"u1".into(),
mcp,
Arc::new(vec![serde_json::json!({"type":"function","function":{"name":"cron_list"}})]),
grants,
1,
None,
)
}
let ctx = ToolCtx {
fn ctx_of(f: &Fixture) -> ToolCtx {
ToolCtx {
conversation: agent_loop::ids::ConversationId::new("session:1"),
frame: f.frame,
agent: "assistant".into(),
call_id: f.call,
cancel: tokio_util::sync::CancellationToken::new(),
extensions: Default::default(),
};
let text = activator.activate(vec!["gmail".into(), CONFIG_GROUP.into()], &ctx).await.unwrap();
assert!(text.contains("gmail ✓"));
}
}
fn report(text: &str, group: &str) -> Value {
serde_json::from_str::<Value>(text).expect("tool result is JSON")[group].clone()
}
#[tokio::test]
async fn activate_grants_in_memory_and_persists_anchored() {
let f = fixture("act-grant").await;
let mcp: Arc<dyn McpProvider> = Arc::new(FakeMcp::with_server("gmail", &["send", "read"]));
let grants = Arc::new(RwLock::new(HashSet::new()));
let activator = activator(&f, mcp, grants.clone());
let text = activator
.activate(vec!["gmail".into(), CONFIG_GROUP.into()], &ctx_of(&f))
.await
.unwrap();
let gmail = report(&text, "gmail");
assert_eq!(gmail["status"], "activated");
assert_eq!(gmail["tool_prefix"], "mcp__gmail__");
assert_eq!(gmail["tool_count"], 2);
assert_eq!(report(&text, CONFIG_GROUP)["status"], "activated");
assert_eq!(report(&text, CONFIG_GROUP)["tool_count"], 1);
// In-memory effect.
assert!(grants.read().unwrap().contains("gmail"));
@@ -281,6 +626,114 @@ mod tests {
cleanup(&f.path);
}
/// The bug this taxonomy exists for: a name nobody knows used to be granted,
/// persisted and reported as a success.
#[tokio::test]
async fn unknown_group_fails_and_leaves_no_trace() {
let f = fixture("act-unknown").await;
let mcp: Arc<dyn McpProvider> = Arc::new(FakeMcp::with_server("tavily", &["search"]));
let grants = Arc::new(RwLock::new(HashSet::new()));
let activator = activator(&f, mcp, grants.clone());
let err = activator.activate(vec!["gmail".into()], &ctx_of(&f)).await.unwrap_err();
let ToolFailure::Failed(text) = err else { panic!("expected Failed") };
assert_eq!(report(&text, "gmail")["status"], "unknown");
assert!(grants.read().unwrap().is_empty(), "no in-memory grant");
assert!(
activated_tools::list_refs_session(&f.pool, 1).await.unwrap().is_empty(),
"no durable row"
);
f.pool.close().await;
cleanup(&f.path);
}
/// Activated by the user, sign-in never completed (§15): diagnosed, not granted.
#[tokio::test]
async fn pending_activation_reports_needs_login() {
let f = fixture("act-pending").await;
crate::db::mcp_catalog::upsert(&f.pool, catalog_entry("gmail", "per_user", "oauth")).await.unwrap();
crate::db::mcp_user_servers::insert(&f.pool, mcp_user_servers::InsertUserServer {
name: "gmail", catalog_name: Some("gmail"), source: "local_script", transport: "stdio",
command: Some("node"), args_json: None, env_json: None, url: None, api_key: None,
oauth_provider: Some("google"), deliver_json: None, script_rel_path: None,
verify_command: None, verify_script_rel_path: None, auth_state: "pending",
}).await.unwrap();
let mcp: Arc<dyn McpProvider> = Arc::new(FakeMcp::with_server("tavily", &["search"]));
let grants = Arc::new(RwLock::new(HashSet::new()));
let activator = activator(&f, mcp, grants.clone());
let err = activator.activate(vec!["gmail".into()], &ctx_of(&f)).await.unwrap_err();
let ToolFailure::Failed(text) = err else { panic!("expected Failed") };
let r = report(&text, "gmail");
assert_eq!(r["status"], "needs_login");
assert_eq!(r["description"], "Mail for the user");
assert!(r["message"].as_str().unwrap().contains("paste the code"), "{r}");
assert!(grants.read().unwrap().is_empty());
assert!(activated_tools::list_refs_session(&f.pool, 1).await.unwrap().is_empty());
f.pool.close().await;
cleanup(&f.path);
}
/// In the catalog, never granted to this user: the admin is the one who can fix it.
#[tokio::test]
async fn catalog_entry_without_grant_reports_not_authorized() {
let f = fixture("act-cat").await;
crate::db::mcp_catalog::upsert(&f.pool, catalog_entry("gmail", "per_user", "oauth")).await.unwrap();
let mcp: Arc<dyn McpProvider> = Arc::new(FakeMcp::with_server("tavily", &["search"]));
let grants = Arc::new(RwLock::new(HashSet::new()));
let activator = activator(&f, mcp, grants.clone());
let err = activator.activate(vec!["gmail".into()], &ctx_of(&f)).await.unwrap_err();
let ToolFailure::Failed(text) = err else { panic!("expected Failed") };
assert_eq!(report(&text, "gmail")["status"], "not_authorized");
f.pool.close().await;
cleanup(&f.path);
}
/// A mixed batch activates what it can and diagnoses the rest — the whole
/// call is not lost because one name was wrong.
#[tokio::test]
async fn mixed_batch_activates_only_the_resolvable_ones() {
let f = fixture("act-mixed").await;
let mcp: Arc<dyn McpProvider> = Arc::new(FakeMcp::with_server("tavily", &["search"]));
let grants = Arc::new(RwLock::new(HashSet::new()));
let activator = activator(&f, mcp, grants.clone());
let text = activator
.activate(vec!["tavily".into(), "gmail".into()], &ctx_of(&f))
.await
.unwrap();
assert_eq!(report(&text, "tavily")["status"], "activated");
assert_eq!(report(&text, "gmail")["status"], "unknown");
let set = grants.read().unwrap().clone();
assert_eq!(set, HashSet::from(["tavily".to_string()]));
assert_eq!(activated_tools::list_refs_session(&f.pool, 1).await.unwrap(), vec!["tavily"]);
f.pool.close().await;
cleanup(&f.path);
}
fn catalog_entry<'a>(name: &'a str, scope: &'a str, auth_kind: &'a str) -> mcp_catalog::UpsertCatalog<'a> {
mcp_catalog::UpsertCatalog {
name, scope, source: "local_script", transport: "stdio",
command: Some("node"), args_json: None, env_json: None, url: None,
script_path: Some("gmail/server.js"), config_schema_json: None, auth_kind,
oauth_provider: Some("google"), oauth_scopes_json: None, deliver_json: None,
role_filter: None, verify_command: None, verify_script_path: None,
icon_small_path: None, icon_large_path: None, friendly_name: Some("Gmail"),
description: Some("Mail for the user"), tool_meta_json: None,
version: None, version_string: None, version_release_date: None,
}
}
#[tokio::test]
async fn activation_source_resolves_defs_per_anchor() {
let f = fixture("act-src").await;
@@ -202,7 +202,10 @@ impl AgentCatalog for SkaldAgentCatalog {
}
native.push(Arc::new(ActivateToolsTool::new(Arc::new(SkaldToolActivator::new(
self.pool.clone(),
self.shared_pool.clone(),
self.user_id.clone(),
self.mcp.clone(),
scope.config_defs.clone(),
child_grants.clone(),
scope.session_id,
Some(child_frame.get()),
@@ -315,7 +315,10 @@ impl UserLoopRuntime {
native.push(Arc::new(
ActivateToolsTool::new(Arc::new(SkaldToolActivator::new(
self.pool.clone(),
self.shared_pool.clone(),
self.user_id.clone(),
self.mcp.clone(),
scope.config_defs.clone(),
scope.grants.clone(),
scope.session_id,
None,
+13 -30
View File
@@ -5,7 +5,7 @@ use serde_json::Value;
use crate::tools::tool_names as tn;
use super::{ChatSessionHandler, update_scratchpad_tool_def, write_todos_tool_def};
use super::interface_tools::{AgentRunConfig, InterfaceTool, ToolFuture};
use super::interface_tools::{AgentRunConfig, InterfaceTool};
/// Returns an `activate_tools` OpenAI tool definition.
pub(crate) fn activate_tools_tool_def() -> Value {
@@ -18,7 +18,14 @@ pub(crate) fn activate_tools_tool_def() -> Value {
keyword `config`, which loads all system-configuration tools (managing \
MCP servers, plugins, scheduled cron jobs, and secrets). \
Pass an array of group names (e.g. [\"gmail\", \"config\"]). \
Once activated, the tools are available from the next tool-call round onward.",
Only names listed in your context can be activated — never guess one. \
Returns a JSON object keyed by group name, each with a `status` \
(`activated`, `needs_login`, `not_activated`, `not_authorized`, \
`unavailable`, `unknown`), the `tool_prefix` its tools are called \
under, a `tool_count`, a `description` and a `message` to relay to \
the user. Only `activated` groups become callable, from the next \
tool-call round onward; for any other status, tell the user what the \
`message` says instead of retrying.",
"parameters": {
"type": "object",
"properties": {
@@ -44,7 +51,7 @@ impl ChatSessionHandler {
client_name: Option<String>,
extra_system: Option<String>,
extra_system_dynamic: Option<String>,
mut interface_tools: Vec<InterfaceTool>,
interface_tools: Vec<InterfaceTool>,
system_substitutions: HashMap<String, String>,
) -> anyhow::Result<AgentRunConfig> {
let meta = crate::agents::load_meta(&self.agent_id).ok();
@@ -137,39 +144,15 @@ impl ChatSessionHandler {
// ── Tool-group grant initialisation ─────────────────────────────────────
//
// Load persisted session grants from DB (MCP server names and/or the reserved
// `config` keyword), then inject `activate_tools` so the LLM can activate
// additional groups on demand.
// `config` keyword). The tool itself is native to the loop
// (`SkaldToolActivator`, which shares this very set through the turn scope):
// an interface tool of the same name would be dropped by `NATIVE_NAMES`.
let persisted = crate::db::activated_tools::list_refs_session(
&self.db, self.session_id,
).await.unwrap_or_default();
let active_mcp_grants: Arc<RwLock<HashSet<String>>> =
Arc::new(RwLock::new(persisted.into_iter().collect()));
{
let mcp_clone = Arc::clone(&self.mcp);
let grants_clone = Arc::clone(&active_mcp_grants);
let activate_tool = crate::tools::activate_tools::ActivateTools {
stack_id: None,
mcp: mcp_clone,
active_mcp_grants: grants_clone,
};
let activate_tool = Arc::new(activate_tool);
interface_tools.push(InterfaceTool {
definition: activate_tools_tool_def(),
handler: Arc::new(move |args| -> ToolFuture {
use crate::tools::Tool as _;
let tool = Arc::clone(&activate_tool);
Box::pin(async move {
tokio::task::spawn_blocking(move || tool.execute(args))
.await
.map_err(|e| anyhow::anyhow!("activate_tools task panicked: {e}"))?
})
}),
});
}
// ── End tool-group grant initialisation ─────────────────────────────────
// Append RunContext system prompt fragments to the dynamic tail (not cached).
@@ -1,131 +0,0 @@
use std::collections::HashSet;
use std::sync::{Arc, RwLock};
use anyhow::Result;
use serde_json::{Value, json};
use crate::mcp::McpProvider;
use crate::tools::tool_names::CONFIG_GROUP;
use crate::tools::{Tool, ToolDescriptionLength, truncate_label, MAX_LABEL_SHORT};
/// Per-session (or per-stack) tool that activates **tool groups** on demand.
///
/// A group is either:
/// - an **MCP server name** — loads that server's tools, or
/// - the reserved keyword `"config"` — loads all built-in `Config`-category
/// tools (system configuration: MCP/plugin/cron management, secrets).
///
/// When the LLM calls `activate_tools(["gmail", "config"])`, this tool updates
/// the in-memory grant set **immediately**, so the group's tools appear in the
/// *next LLM round* of the current turn (via `all_tool_defs()`).
///
/// The **durable** record of the activation is written by the round loop
/// (`handle_tool_call`), not here: it anchors the activation to the assistant
/// `message_id` that triggered it, in the owner table `activated_tools`
/// (`stack_id NULL` = session-scoped root grant; `Some(id)` = sub-agent frame
/// grant, deleted on frame exit). Splitting the write this way lets the loop
/// supply the `message_id` — the anchor the DTL serializer positions injected
/// tool blocks against — which this tool does not have.
///
/// Not in the global `ToolRegistry` — injected as an `InterfaceTool` in
/// `build_agent_config` (root) and `build_sub_agent_config` (sub-agents).
pub struct ActivateTools {
/// `None` for root agents, `Some(stack_id)` for sub-agents. Used only to
/// label the confirmation message; the durable scope is decided by the loop.
pub stack_id: Option<i64>,
pub mcp: Arc<dyn McpProvider>,
/// Shared in-memory grant set. Updated in-place on every call so subsequent
/// rounds within the same turn see the new tools via `all_tool_defs()`.
pub active_mcp_grants: Arc<RwLock<HashSet<String>>>,
}
impl Tool for ActivateTools {
fn name(&self) -> &str { crate::tools::tool_names::ACTIVATE_TOOLS }
fn category(&self) -> crate::tools::ToolCategory { crate::tools::ToolCategory::Config }
fn description(&self) -> &str {
"Activate one or more tool groups so their tools become available. \
A group is either an MCP server name (see the MCP list) or the reserved \
keyword `config`, which loads all system-configuration tools (managing \
MCP servers, plugins, scheduled cron jobs, and secrets). \
Pass an array of group names. \
Once activated, the tools are available from the next tool-call round onward."
}
fn parameters_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"groups": {
"type": "array",
"items": { "type": "string" },
"description": "Tool groups to activate: MCP server names and/or the reserved \
keyword \"config\" (e.g. [\"gmail\", \"config\"])."
}
},
"required": ["groups"]
})
}
fn describe(&self, args: &Value, _length: ToolDescriptionLength) -> String {
let names = args["groups"]
.as_array()
.map(|a| a.iter().filter_map(|v| v.as_str()).collect::<Vec<_>>().join(", "))
.unwrap_or_else(|| "?".to_string());
truncate_label(&format!("activate tools [{names}]"), MAX_LABEL_SHORT)
}
fn execute(&self, args: Value) -> Result<String> {
let names: Vec<String> = args["groups"]
.as_array()
.ok_or_else(|| anyhow::anyhow!("activate_tools: `groups` must be an array"))?
.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect();
if names.is_empty() {
anyhow::bail!("activate_tools: `groups` is empty");
}
let available: HashSet<String> = self.mcp.tools()
.iter()
.map(|t| t.server_name.clone())
.collect();
// Update the in-memory set so the next LLM round sees the new grants.
// The durable record (with the triggering `message_id`) is written by the
// round loop after this tool returns.
{
let mut set = self.active_mcp_grants.write()
.map_err(|_| anyhow::anyhow!("activate_tools: lock poisoned"))?;
for name in &names {
set.insert(name.clone());
}
}
let activated: Vec<String> = names.iter()
.map(|n| {
if n == CONFIG_GROUP {
// Built-in group — always available, no MCP server to reconnect.
format!("{n}")
} else if available.contains(n) {
format!("{n}")
} else {
format!("{n} (registered but not yet running — tools will appear after reconnect)")
}
})
.collect();
let scope = match self.stack_id {
None => "session".to_string(),
Some(s) => format!("stack {s}"),
};
Ok(format!(
"Tool groups activated for this {scope}: {}. \
Their tools are available from the next tool-call round.",
activated.join(", ")
))
}
}
-1
View File
@@ -32,7 +32,6 @@ pub fn is_file_read_tool(name: &str) -> bool {
}
pub mod tool_names;
pub mod activate_tools;
pub mod ast_outline;
pub mod configure_plugin;
pub mod cron_jobs;