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.
This commit is contained in:
@@ -8,10 +8,13 @@ use std::sync::{Arc, RwLock};
|
|||||||
use agent_loop::activation::{Activation, ActivationSource, ToolActivator};
|
use agent_loop::activation::{Activation, ActivationSource, ToolActivator};
|
||||||
use agent_loop::ids::{FrameId, MessageId};
|
use agent_loop::ids::{FrameId, MessageId};
|
||||||
use agent_loop::tool::{ToolCtx, ToolFailure};
|
use agent_loop::tool::{ToolCtx, ToolFailure};
|
||||||
use serde_json::Value;
|
use serde_json::{Value, json};
|
||||||
use sqlx::SqlitePool;
|
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::mcp::McpProvider;
|
||||||
use crate::tools::tool_names::CONFIG_GROUP;
|
use crate::tools::tool_names::CONFIG_GROUP;
|
||||||
|
|
||||||
@@ -77,29 +80,323 @@ impl ActivationSource for SkaldActivationSource {
|
|||||||
|
|
||||||
// ── ToolActivator ────────────────────────────────────────────────────────────
|
// ── ToolActivator ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Backend of the crate's shipped `activate_tools` tool: validates the groups
|
/// What a requested group resolved to. Only [`Status::Activated`] grants
|
||||||
/// against the catalog, updates the in-memory grant set **immediately** (next
|
/// anything: every other state means the group's tools cannot appear in this
|
||||||
/// round sees the tools), and persists the activation anchored at the
|
/// session, and saying otherwise would have the model call `mcp__x__…` a round
|
||||||
/// triggering assistant message (derived from the call's `chat_llm_tools`
|
/// later and fail there instead of here.
|
||||||
/// row). Unifies what today lives split between `tools/activate_tools.rs`
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||||
/// (grants) and `llm_loop.rs` (persistence).
|
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 {
|
pub struct SkaldToolActivator {
|
||||||
|
/// Owner pool — `mcp_user_servers` (this user's activations).
|
||||||
pool: Arc<SqlitePool>,
|
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>,
|
mcp: Arc<dyn McpProvider>,
|
||||||
|
/// The reserved `config` group's defs, for its tool count.
|
||||||
|
config_defs: Arc<Vec<Value>>,
|
||||||
grants: Arc<RwLock<HashSet<String>>>,
|
grants: Arc<RwLock<HashSet<String>>>,
|
||||||
session_id: i64,
|
session_id: i64,
|
||||||
stack: Option<i64>,
|
stack: Option<i64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SkaldToolActivator {
|
impl SkaldToolActivator {
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub fn new(
|
pub fn new(
|
||||||
pool: Arc<SqlitePool>,
|
pool: Arc<SqlitePool>,
|
||||||
|
shared_pool: Arc<SqlitePool>,
|
||||||
|
user_id: String,
|
||||||
mcp: Arc<dyn McpProvider>,
|
mcp: Arc<dyn McpProvider>,
|
||||||
|
config_defs: Arc<Vec<Value>>,
|
||||||
grants: Arc<RwLock<HashSet<String>>>,
|
grants: Arc<RwLock<HashSet<String>>>,
|
||||||
session_id: i64,
|
session_id: i64,
|
||||||
stack: Option<i64>,
|
stack: Option<i64>,
|
||||||
) -> Self {
|
) -> 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()));
|
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();
|
// Resolve first, act only on what resolved: an unknown or unconfigured
|
||||||
|
// group must leave no trace, in RAM or in the DB.
|
||||||
// Immediate in-memory effect (the defs re-read at the next round picks
|
let mut reports: Vec<(String, GroupReport)> = Vec::new();
|
||||||
// the new grants up for free).
|
|
||||||
{
|
|
||||||
let mut set = self.grants.write().map_err(|_| ToolFailure::Failed("activate_tools: lock poisoned".into()))?;
|
|
||||||
for g in &groups {
|
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());
|
set.insert(g.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -128,32 +444,30 @@ impl ToolActivator for SkaldToolActivator {
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| ToolFailure::Failed(format!("activate_tools: anchor lookup failed: {e}")))?
|
.map_err(|e| ToolFailure::Failed(format!("activate_tools: anchor lookup failed: {e}")))?
|
||||||
.ok_or_else(|| ToolFailure::Failed("activate_tools: call row not found".into()))?;
|
.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" };
|
let kind = if g == CONFIG_GROUP { "builtin" } else { "mcp" };
|
||||||
activated_tools::grant(&self.pool, self.session_id, self.stack, call.message_id, kind, g)
|
activated_tools::grant(&self.pool, self.session_id, self.stack, call.message_id, kind, g)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ToolFailure::Failed(format!("activate_tools: grant failed: {e}")))?;
|
.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();
|
// One JSON object keyed by group name, same shape whether the call
|
||||||
let scope = match self.stack {
|
// succeeded or not — the model parses one thing, never prose.
|
||||||
None => "session".to_string(),
|
let body = Value::Object(
|
||||||
Some(s) => format!("stack {s}"),
|
reports
|
||||||
};
|
.iter()
|
||||||
Ok(format!(
|
.map(|(name, r)| (name.clone(), r.to_json()))
|
||||||
"Tool groups activated for this {scope}: {}. \
|
.collect(),
|
||||||
Their tools are available from the next tool-call round.",
|
);
|
||||||
activated.join(", ")
|
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]
|
/// The fixture's pool is both the owner and the registry pool: `init_system_pool`
|
||||||
async fn activate_grants_in_memory_and_persists_anchored() {
|
/// creates both buckets in one file, which is exactly what a diagnosis needs.
|
||||||
let f = fixture("act-grant").await;
|
fn activator(f: &Fixture, mcp: Arc<dyn McpProvider>, grants: Arc<RwLock<HashSet<String>>>) -> SkaldToolActivator {
|
||||||
let mcp: Arc<dyn McpProvider> = Arc::new(FakeMcp::with_server("gmail", &["send", "read"]));
|
SkaldToolActivator::new(
|
||||||
let grants = Arc::new(RwLock::new(HashSet::new()));
|
f.pool.clone(),
|
||||||
let activator = SkaldToolActivator::new(f.pool.clone(), mcp, grants.clone(), 1, None);
|
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"),
|
conversation: agent_loop::ids::ConversationId::new("session:1"),
|
||||||
frame: f.frame,
|
frame: f.frame,
|
||||||
agent: "assistant".into(),
|
agent: "assistant".into(),
|
||||||
call_id: f.call,
|
call_id: f.call,
|
||||||
cancel: tokio_util::sync::CancellationToken::new(),
|
cancel: tokio_util::sync::CancellationToken::new(),
|
||||||
extensions: Default::default(),
|
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.
|
// In-memory effect.
|
||||||
assert!(grants.read().unwrap().contains("gmail"));
|
assert!(grants.read().unwrap().contains("gmail"));
|
||||||
@@ -281,6 +626,114 @@ mod tests {
|
|||||||
cleanup(&f.path);
|
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]
|
#[tokio::test]
|
||||||
async fn activation_source_resolves_defs_per_anchor() {
|
async fn activation_source_resolves_defs_per_anchor() {
|
||||||
let f = fixture("act-src").await;
|
let f = fixture("act-src").await;
|
||||||
|
|||||||
@@ -202,7 +202,10 @@ impl AgentCatalog for SkaldAgentCatalog {
|
|||||||
}
|
}
|
||||||
native.push(Arc::new(ActivateToolsTool::new(Arc::new(SkaldToolActivator::new(
|
native.push(Arc::new(ActivateToolsTool::new(Arc::new(SkaldToolActivator::new(
|
||||||
self.pool.clone(),
|
self.pool.clone(),
|
||||||
|
self.shared_pool.clone(),
|
||||||
|
self.user_id.clone(),
|
||||||
self.mcp.clone(),
|
self.mcp.clone(),
|
||||||
|
scope.config_defs.clone(),
|
||||||
child_grants.clone(),
|
child_grants.clone(),
|
||||||
scope.session_id,
|
scope.session_id,
|
||||||
Some(child_frame.get()),
|
Some(child_frame.get()),
|
||||||
|
|||||||
@@ -315,7 +315,10 @@ impl UserLoopRuntime {
|
|||||||
native.push(Arc::new(
|
native.push(Arc::new(
|
||||||
ActivateToolsTool::new(Arc::new(SkaldToolActivator::new(
|
ActivateToolsTool::new(Arc::new(SkaldToolActivator::new(
|
||||||
self.pool.clone(),
|
self.pool.clone(),
|
||||||
|
self.shared_pool.clone(),
|
||||||
|
self.user_id.clone(),
|
||||||
self.mcp.clone(),
|
self.mcp.clone(),
|
||||||
|
scope.config_defs.clone(),
|
||||||
scope.grants.clone(),
|
scope.grants.clone(),
|
||||||
scope.session_id,
|
scope.session_id,
|
||||||
None,
|
None,
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ use serde_json::Value;
|
|||||||
|
|
||||||
use crate::tools::tool_names as tn;
|
use crate::tools::tool_names as tn;
|
||||||
use super::{ChatSessionHandler, update_scratchpad_tool_def, write_todos_tool_def};
|
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.
|
/// Returns an `activate_tools` OpenAI tool definition.
|
||||||
pub(crate) fn activate_tools_tool_def() -> Value {
|
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 \
|
keyword `config`, which loads all system-configuration tools (managing \
|
||||||
MCP servers, plugins, scheduled cron jobs, and secrets). \
|
MCP servers, plugins, scheduled cron jobs, and secrets). \
|
||||||
Pass an array of group names (e.g. [\"gmail\", \"config\"]). \
|
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": {
|
"parameters": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -44,7 +51,7 @@ impl ChatSessionHandler {
|
|||||||
client_name: Option<String>,
|
client_name: Option<String>,
|
||||||
extra_system: Option<String>,
|
extra_system: Option<String>,
|
||||||
extra_system_dynamic: Option<String>,
|
extra_system_dynamic: Option<String>,
|
||||||
mut interface_tools: Vec<InterfaceTool>,
|
interface_tools: Vec<InterfaceTool>,
|
||||||
system_substitutions: HashMap<String, String>,
|
system_substitutions: HashMap<String, String>,
|
||||||
) -> anyhow::Result<AgentRunConfig> {
|
) -> anyhow::Result<AgentRunConfig> {
|
||||||
let meta = crate::agents::load_meta(&self.agent_id).ok();
|
let meta = crate::agents::load_meta(&self.agent_id).ok();
|
||||||
@@ -137,39 +144,15 @@ impl ChatSessionHandler {
|
|||||||
// ── Tool-group grant initialisation ─────────────────────────────────────
|
// ── Tool-group grant initialisation ─────────────────────────────────────
|
||||||
//
|
//
|
||||||
// Load persisted session grants from DB (MCP server names and/or the reserved
|
// Load persisted session grants from DB (MCP server names and/or the reserved
|
||||||
// `config` keyword), then inject `activate_tools` so the LLM can activate
|
// `config` keyword). The tool itself is native to the loop
|
||||||
// additional groups on demand.
|
// (`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(
|
let persisted = crate::db::activated_tools::list_refs_session(
|
||||||
&self.db, self.session_id,
|
&self.db, self.session_id,
|
||||||
).await.unwrap_or_default();
|
).await.unwrap_or_default();
|
||||||
|
|
||||||
let active_mcp_grants: Arc<RwLock<HashSet<String>>> =
|
let active_mcp_grants: Arc<RwLock<HashSet<String>>> =
|
||||||
Arc::new(RwLock::new(persisted.into_iter().collect()));
|
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 ─────────────────────────────────
|
// ── End tool-group grant initialisation ─────────────────────────────────
|
||||||
|
|
||||||
// Append RunContext system prompt fragments to the dynamic tail (not cached).
|
// 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(", ")
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -32,7 +32,6 @@ pub fn is_file_read_tool(name: &str) -> bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub mod tool_names;
|
pub mod tool_names;
|
||||||
pub mod activate_tools;
|
|
||||||
pub mod ast_outline;
|
pub mod ast_outline;
|
||||||
pub mod configure_plugin;
|
pub mod configure_plugin;
|
||||||
pub mod cron_jobs;
|
pub mod cron_jobs;
|
||||||
|
|||||||
Reference in New Issue
Block a user