Files
Skald-Circle/crates/skald-core/src/loop_adapters/runtime.rs
T
dguiducci efb5b1dc33
Nightly Build / build (push) Successful in 7m44s
feat: let an agent ask what its connectors are, instead of guessing
An agent that wanted to know which MCP servers it had called
`list_mcp_servers` — a tool that has never existed anywhere in this
repo — and got "unknown tool". It was not a random hallucination: the
prompt block says "the system prompt shows available servers", and
`render_mcp_list` returned an empty string when nothing was connected.
The model read a promise, found no table, and invented the discovery
tool the text implied. The `mcp` kinds of `list_items`/`toggle_item`
had been removed to close the §14 RCE vector, which was right for the
write half and left no read half at all.

So `list_items` gains `type: "mcp"` and returns the whole picture in
one call, split into four buckets that each answer a different
question: what is already loaded (call its tools directly), what is
ready for `activate_tools`, what is installed but unusable and why,
and what the user could still activate. Conflating the first two is
what produced the original failure, so they stay apart. Every entry
carries a derived note and a next step; when the step is a human one,
it says so and names the UI page, because there is no tool for it.

Read-only, and structurally so: `toggle_item` deliberately gains
nothing, and the new `McpDirectory` trait exposes exactly one method.
Enabling a connector from a tool is the thing §14 removed, and a wider
seam here is how it would come back. Deny-by-default survives the
report — an ungranted connector is not named at all, since a listing
of what to ask for is itself a leak — except for a catalogue manager,
who cannot administer what they cannot see.

Three sources answer three questions and none is redundant: the
registry says what exists and who may have it, the owner database says
what was activated, and the live runtimes say what is connected right
now — a row can read `ready` while its process is dead. The live half
reaches the tool through the turn's extension map, alongside the pool
and the fs view; with no live view the durable picture still renders,
so freshness is an improvement and never a precondition.

The static `__MCP_LIST__` table stays as it was, because it is frozen
per conversation for prompt-cache stability. Its empty case now says
so out loud and points at the tool.
2026-08-04 19:41:18 +01:00

453 lines
18 KiB
Rust

//! `UserLoopRuntime` — the loop stack of one user, built once.
//!
//! Everything that lives as long as the owner's pool lives here: the
//! `LoopManager` (event bus + live-loop registry), the history store, the
//! approval gate, the hooks, the agent catalog and the delegate tool. A turn
//! then contributes only what is genuinely its own — the agent's prompt, its
//! tool set, its model pin — through [`UserLoopRuntime::turn_params`].
//!
//! Why one per user and not one per turn (blueprint D12): the manager's job is
//! the *global* view — which conversations are running, `/stop`, recovery,
//! shutdown. A manager rebuilt for every message can answer none of those, and
//! rebuilding the graph per message also leaks it (the catalog ↔ delegate cycle
//! is broken by a `Weak`, but a per-turn graph would still pile up).
use std::sync::Arc;
use agent_loop::activation::ActivateToolsTool;
use agent_loop::delegate::DelegateTool;
use agent_loop::ids::ConversationId;
use agent_loop::manager::{LiveInput, LoopManager, TurnMeta, TurnParams};
use agent_loop::model::{ModelHint, ModelSelector};
use agent_loop::store::HistoryStore;
use agent_loop::tool::{Extensions, Tool as LoopTool, ToolSet};
use core_api::interface_tool::InterfaceTool;
use core_api::user_fs::SharedFs;
use serde_json::Value;
use sqlx::SqlitePool;
use crate::approval::ApprovalManager;
use crate::clarification::ClarificationManager;
use crate::config::DatetimeConfig;
use crate::llm::LlmManager;
use crate::llm::logging::RequestLogTarget;
use crate::loop_adapters::activation::{SkaldActivationSource, SkaldToolActivator};
use crate::loop_adapters::async_task::CronExecutor;
use crate::loop_adapters::builtins::{
ExecuteTaskAliasTool, LegacyInterfaceTool, SkaldAskUserTool, SkaldHumanChannel,
UpdateScratchpadTool, WriteTodosTool,
};
use crate::loop_adapters::catalog::SkaldAgentCatalog;
use crate::loop_adapters::gate::ApprovalGate;
use crate::loop_adapters::history::SqliteHistory;
use crate::loop_adapters::hooks::{DtlReanchorHook, SkaldWritePreviewHook};
use crate::loop_adapters::live_input::PendingLiveInput;
use crate::loop_adapters::prefix_cache::PrefixCache;
use crate::loop_adapters::preview::PreviewContext;
use crate::loop_adapters::projection_cfg::skald_assembler;
use crate::loop_adapters::scope::TurnScope;
use crate::loop_adapters::selector::SkaldSelector;
use crate::loop_adapters::system::AgentSystemContext;
use crate::loop_adapters::toolset::{CallerMcp, CallerUserId, SkaldToolSet};
use crate::mcp::McpProvider;
use crate::session::handler::PendingUserInput;
use crate::session::handler::interface_tools::AgentRunConfig;
use crate::tool_discovery::ToolDiscovery;
use crate::tools::ToolRegistry;
use crate::tools::tool_names as tn;
/// Instance-wide loop limits (from `config.yml`).
#[derive(Clone)]
pub struct LoopConfig {
pub max_rounds: usize,
pub max_parallel_calls: usize,
/// Sliding-window cap on projected history. `None` (the default) leaves
/// history append-only — see `LlmConfig::max_history_messages`.
pub max_history_messages: Option<usize>,
pub max_tool_result_chars: Option<usize>,
/// Automatic compaction bounds the context instead of a message window.
pub auto_compaction_enabled: bool,
pub datetime: DatetimeConfig,
pub max_agent_depth: u32,
}
/// Names handled natively; a legacy interface tool of the same name is dropped.
const NATIVE_NAMES: &[&str] = &[tn::ACTIVATE_TOOLS, tn::EXECUTE_TASK];
/// One user's loop stack.
pub struct UserLoopRuntime {
manager: Arc<LoopManager>,
store: Arc<dyn HistoryStore>,
catalog: Arc<SkaldAgentCatalog>,
delegate: Arc<DelegateTool>,
/// Backs `execute_task mode=async`; its `TaskManager` lands at wiring time.
async_exec: Arc<CronExecutor>,
// per-turn assembly material
pool: Arc<SqlitePool>,
shared_pool: Arc<SqlitePool>,
user_id: String,
fs: SharedFs,
tools: Arc<ToolRegistry>,
mcp: Arc<dyn McpProvider>,
llm_manager: Arc<LlmManager>,
clarification: Arc<ClarificationManager>,
tool_discovery: Arc<ToolDiscovery>,
config: LoopConfig,
/// The user's frozen system prefixes, shared with the agent catalog so a
/// sub-agent's own prefix is cached alongside its parent's.
prefix_cache: Arc<PrefixCache>,
}
/// What a turn contributes on top of the runtime.
pub struct TurnInputs<'a> {
pub scope: Arc<TurnScope>,
pub config: &'a AgentRunConfig,
/// Messages queued while the turn runs, drained at round boundaries.
pub live_input: Option<Arc<dyn PendingUserInput>>,
}
impl UserLoopRuntime {
#[allow(clippy::too_many_arguments)]
pub fn build(
pool: Arc<SqlitePool>,
shared_pool: Arc<SqlitePool>,
user_id: String,
fs: SharedFs,
tools: Arc<ToolRegistry>,
mcp: Arc<dyn McpProvider>,
llm_manager: Arc<LlmManager>,
approval: Arc<ApprovalManager>,
clarification: Arc<ClarificationManager>,
tool_discovery: Arc<ToolDiscovery>,
config: LoopConfig,
) -> anyhow::Result<Arc<Self>> {
let store: Arc<dyn HistoryStore> = Arc::new(SqliteHistory::new(pool.clone()));
let gate = ApprovalGate::new(
approval.clone(),
store.clone(),
tools.clone(),
pool.clone(),
shared_pool.clone(),
Some(fs.clone()),
);
let preview_hook = Arc::new(SkaldWritePreviewHook::new(PreviewContext {
pool: pool.clone(),
shared_pool: shared_pool.clone(),
fs: Some(fs.clone()),
}));
// The default selector has no strength requirement; every turn overrides
// it with the agent's own (D14). It carries the owner's log target, so a
// call served by it (recovery, compaction) is still attributed.
let default_selector: Arc<dyn ModelSelector> = Arc::new(
SkaldSelector::new(llm_manager.clone(), None)
.with_log(RequestLogTarget::user(user_id.clone(), pool.clone())),
);
let manager = Arc::new(
LoopManager::builder()
.models(default_selector)
.store(store.clone())
.gate_arc(Arc::new(gate))
.hook(preview_hook)
.hook(Arc::new(DtlReanchorHook::new(pool.clone())))
.max_rounds(config.max_rounds)
.max_parallel_calls(config.max_parallel_calls)
.build()?,
);
// One per user, living as long as this runtime: a conversation's system
// prefix must outlast its turns for the provider's cache to hold.
let prefix_cache = Arc::new(PrefixCache::new());
let catalog = Arc::new(SkaldAgentCatalog::new(
pool.clone(),
shared_pool.clone(),
user_id.clone(),
llm_manager.clone(),
approval,
clarification.clone(),
mcp.clone(),
tools.clone(),
fs.clone(),
config.clone(),
prefix_cache.clone(),
));
// `mode: "async"` runs as a durable cron job; the manager behind it is
// set at wiring time (see `CronExecutor`).
let async_exec = Arc::new(CronExecutor::new());
let delegate = Arc::new(
DelegateTool::new(
manager.clone(),
catalog.clone(),
store.clone(),
config.max_agent_depth,
)
.with_async(async_exec.clone()),
);
// The catalog hands `execute_subtask` to children; it holds this Weak.
catalog.set_delegate(&delegate);
Ok(Arc::new(Self {
manager,
store,
catalog,
delegate,
async_exec,
pool,
shared_pool,
user_id,
fs,
tools,
mcp,
llm_manager,
clarification,
tool_discovery,
config,
prefix_cache,
}))
}
pub fn manager(&self) -> &Arc<LoopManager> {
&self.manager
}
/// Hands the user's `TaskManager` to the async executor. Called once the
/// cron side exists (it needs the session manager that owns this runtime).
pub fn set_task_manager(&self, tasks: Arc<crate::cron::TaskManager>) {
self.async_exec.set_task_manager(tasks);
}
pub fn store(&self) -> &Arc<dyn HistoryStore> {
&self.store
}
/// Where this user's LLM traffic is logged: metadata in the registry
/// (attributed to them), payloads in their own encrypted pool.
pub fn log_target(&self) -> RequestLogTarget {
RequestLogTarget::user(self.user_id.clone(), self.pool.clone())
}
/// The conversation id of a session — the store's encoding.
pub fn conversation(session_id: i64) -> ConversationId {
SqliteHistory::conversation(session_id)
}
/// Everything a turn needs, assembled from the run config and the scope.
pub async fn turn_params(&self, inputs: TurnInputs<'_>) -> anyhow::Result<TurnParams> {
let TurnInputs { scope, config, live_input } = inputs;
let frame_agent = config.agent_id.clone();
// ── System context ──
let system = Arc::new(AgentSystemContext {
agent_id: frame_agent.clone(),
extra_static: config.extra_system.clone(),
extra_dynamic: config.extra_system_dynamic.clone(),
tail_reminder: config.tail_reminder.clone(),
substitutions: config.system_substitutions.clone(),
pool: self.pool.clone(),
shared_pool: self.shared_pool.clone(),
user_id: self.user_id.clone(),
mcp: self.mcp.clone(),
project_root: scope.project_root.clone(),
scratchpad_sid: scope.scratchpad_sid,
datetime: self.config.datetime.clone(),
prefix_cache: self.prefix_cache.clone(),
});
// The agent's own declarations. Loaded once here and used twice below —
// for the tool set and for the selector's strength floor.
let meta = crate::agents::load_meta(&frame_agent).ok();
// ── Tool set: the native tools, then the surface's legacy ones ──
//
// Unless the agent declares it gets none. An empty set is not the same as
// a restrictive permission group: a group decides whether a call is
// allowed, this decides whether the model is shown anything to call. For
// an agent that reads material and answers in prose — a review, a
// summariser — that is the difference between gating an action and there
// being no action available.
let tools = match meta.as_ref() {
Some(m) if !m.allow_tools => Arc::new(agent_loop::tool::ToolRegistry::new()) as Arc<dyn ToolSet>,
_ => self.build_toolset(&scope, config),
};
// ── Assembler: the shared projection, scoped to this session's DTL ──
let assembler = Arc::new(skald_assembler(
Arc::new(SkaldActivationSource::new(
self.pool.clone(),
self.mcp.clone(),
scope.config_defs.clone(),
scope.session_id,
None,
)),
Some(self.fs.load()),
self.config.max_history_messages,
self.config.auto_compaction_enabled,
self.config.max_tool_result_chars,
));
// ── Extensions: the tool bridge's context + the turn's own scope ──
let mut extensions = Extensions::new();
extensions.insert(self.pool.clone());
extensions.insert(self.fs.load());
extensions.insert(Arc::new(CallerUserId(self.user_id.clone())));
extensions.insert(Arc::new(CallerMcp(Arc::new(
crate::mcp::McpDirectoryHandle(self.mcp.clone()),
))));
extensions.insert(scope.clone());
// ── Selector: this agent's strength (D14) + the owner's request log ──
let strength = meta.and_then(|m| m.strength);
let selector: Arc<dyn ModelSelector> = Arc::new(
SkaldSelector::new(self.llm_manager.clone(), strength).with_log(self.log_target()),
);
// The session's root frame; the store reuses the provisioned row.
let frame = self
.store
.open_frame(
&Self::conversation(scope.session_id),
None,
agent_loop::store::FrameSpec::root(&frame_agent),
)
.await?;
Ok(TurnParams {
frame,
agent: frame_agent,
system,
tools,
model_hint: ModelHint::name(config.client_name.clone()),
selector: Some(selector),
live_input: live_input
.map(|p| Arc::new(PendingLiveInput::new(p)) as Arc<dyn LiveInput>),
extensions,
meta: TurnMeta {
synthetic: false,
interactive: scope.is_interactive,
context_label: scope.context_label.read().ok().and_then(|g| g.clone()),
user_message: None,
},
assembler: Some(assembler),
})
}
/// The root agent's tool set: natives (activation, delegation, clarification,
/// scratchpad, todos) plus the surface's own interface tools.
fn build_toolset(&self, scope: &Arc<TurnScope>, config: &AgentRunConfig) -> Arc<dyn ToolSet> {
let mut native: Vec<Arc<dyn LoopTool>> = Vec::new();
// activate_tools, sharing the turn's grant set so the next round sees
// whatever this round activated.
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,
)))
.with_definition(crate::session::handler::config::activate_tools_tool_def()),
));
// execute_task: sync/async → the delegate; cron → the scheduling handler.
{
let injected = config
.interface_tools
.iter()
.find(|it| it.definition["function"]["name"].as_str() == Some(tn::EXECUTE_TASK))
.cloned();
let (def, handler) = match injected {
Some(it) => (it.definition.clone(), Some(it.handler.clone())),
None => (legacy_execute_task_def(), None),
};
native.push(Arc::new(ExecuteTaskAliasTool::new(
self.delegate.as_ref().clone().with_name(tn::EXECUTE_TASK),
def,
handler,
)));
}
native.push(Arc::new(SkaldAskUserTool::new(
Arc::new(SkaldHumanChannel::new(
self.clarification.clone(),
scope.session_id,
&scope.agent_id,
&scope.source,
scope.is_interactive,
scope.context_label.clone(),
)),
self.store.clone(),
)));
native.push(Arc::new(UpdateScratchpadTool::new(
self.pool.clone(),
scope.scratchpad_sid,
)));
native.push(Arc::new(WriteTodosTool));
let legacy: Vec<InterfaceTool> = config
.interface_tools
.iter()
.filter(|it| {
let name = it.definition["function"]["name"].as_str().unwrap_or("");
!NATIVE_NAMES.contains(&name)
})
.cloned()
.collect();
for it in &legacy {
native.push(Arc::new(LegacyInterfaceTool::new(it.clone())));
}
Arc::new(
SkaldToolSet::new(
scope.base_defs.as_ref().clone(),
scope.config_defs.clone(),
self.mcp.clone(),
scope.grants.clone(),
scope.memory_tools.as_ref().clone(),
scope.image_tools.as_ref().clone(),
legacy,
self.tools.all_tools(),
)
.with_discovery(self.tool_discovery.clone())
.with_native_all(native),
)
}
/// The catalog, for callers that list dispatchable agents.
pub fn catalog(&self) -> &Arc<SkaldAgentCatalog> {
&self.catalog
}
}
/// Fallback definition for `execute_task` when no interface handler was injected
/// (non-interactive sessions): mirrors the injected one.
fn legacy_execute_task_def() -> Value {
serde_json::json!({
"type": "function",
"function": {
"name": tn::EXECUTE_TASK,
"description": "Execute a task with a sub-agent. mode=sync waits for the result; \
mode=async schedules it in the background.",
"parameters": {
"type": "object",
"properties": {
"agent_id": { "type": "string" },
"prompt": { "type": "string" },
"title": { "type": "string" },
"description": { "type": "string" },
"mode": { "type": "string", "enum": ["sync", "async"] },
"client": { "type": "string" }
},
"required": ["agent_id", "prompt"]
}
}
})
}