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:
2026-07-26 21:41:53 +01:00
parent 73c720e9ef
commit 6f35c53d93
6 changed files with 535 additions and 225 deletions
+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).