Files
Skald-Circle/crates/skald-core/src/skald/bundles.rs
T
Daniele 5765941758
Nightly Build / build (push) Successful in 8m6s
feat(prompt): tell the agent what its sandbox can run
The agent had no way to know its container ships ffmpeg, ripgrep or
tesseract, so it either declined work it could do or spent a round finding
out. This adds a command list to the system prompt as a **discovery hint** —
explicitly not an inventory.

Every decision follows from it being a hint:

- The allowlist (~35 entries, `container/commands.rs`) is the curation; a
  full PATH dump is 800 entries of coreutils noise. The probe exists so the
  list cannot *lie*, not so it can discover: `command -v` at login means we
  never announce something a container recreate threw away.
- The rendered prose says the list is partial and names `command -v`, so a
  tool outside the allowlist costs one check rather than a wrong conclusion.
  An empty probe renders as an explicit "could not be read", never as
  silence under a heading promising a list.
- Order is the allowlist's own, grouped by kind of work — the grouping is
  the curation, and the reader is a model, not a grep.
- Staleness is cheap both ways, so there is no invalidation machinery: a
  login-time snapshot on `UserContext`, non-fatal, refreshed at next login.

The gate is the tool, not the sentinel. Every AGENT.md carries
`common/sandbox.md` — the four system agents included — and the section is
emitted iff the turn's model is shown `execute_cmd`, derived from
`allow_tools` plus the security group's visibility filter for a root turn
and from `child_defs` for a sub-agent: always the same definitions the model
will see. `has_execute_cmd` therefore joins the PrefixCache key, since the
group is switchable mid-conversation and that switch already rewrites the
tool payload in the same provider cache.

The fragment holds only the heading and one stable sentence; every
conditional claim lives in the renderer, because prose promising
`sudo apt-get install` is not the renderer's to retract when the tool is
absent. `execute_cmd`'s own description loses `(python + node available)`:
its job is steering away from the shell, and a capability advertisement
diluted it.
2026-08-09 09:49:50 +01:00

456 lines
20 KiB
Rust

//! Domain bundles: the managers, grouped by cohesion, that make up `Skald`.
//!
//! Each bundle owns a `build()` that constructs its managers (plus their startup
//! logging and non-fatal `seed_*` calls) from the shared [`Runtime`] and whatever
//! sibling bundles it depends on at construction time. Cross-bundle *cycles* are
//! not expressed here — they are resolved by the managers' `OnceLock` setters,
//! called in one place by [`super::wiring::wire`]. Bundle structs never hold
//! references to each other.
use std::sync::Arc;
use anyhow::Result;
use tracing::{debug, info, warn};
use core_api::remote::RemoteAccess;
use crate::approval::ApprovalManager;
use crate::chat_hub::ChatHub;
use crate::clarification::ClarificationManager;
use crate::command::LlmCommandManager;
use crate::compactor::ContextCompactor;
use crate::config::{CoreConfig, DatetimeConfig};
use crate::cron::TaskManager;
use crate::elicitation::ElicitationManager;
use crate::image_generate::ImageGeneratorManager;
use crate::inbox::Inbox;
use crate::latex::LatexCompiler;
use crate::llm::LlmManager;
use crate::location::LocationManager;
use crate::mcp::McpManager;
use crate::memory::MemoryManager;
use crate::plugin::PluginManager;
use crate::provider::ProviderRegistry;
use crate::run_context::RunContextManager;
use crate::secrets::SecretsStore;
use crate::session::handler::{DEFAULT_MAX_PARALLEL_SUBAGENTS, DEFAULT_MAX_TOOL_ROUNDS};
use crate::session::manager::ChatSessionManager;
use crate::tool_catalog::ToolCatalog;
use crate::tool_discovery::ToolDiscovery;
use crate::tools::ToolRegistry;
use crate::transcribe::TranscribeManager;
use crate::tts::TtsManager;
use tokio::sync::RwLock;
use core_api::plugin::Plugin;
use core_api::provider::ApiProvider;
use super::runtime::Runtime;
// ── Models: LLM/provider stack ──────────────────────────────────────────────
pub(super) struct Models {
pub(super) provider_registry: Arc<ProviderRegistry>,
pub(super) llm_manager: Arc<LlmManager>,
pub(super) secrets: Arc<SecretsStore>,
pub(super) memory_manager: Arc<MemoryManager>,
}
impl Models {
pub(super) async fn build(rt: &Runtime, config: &CoreConfig) -> Result<Self> {
let mut provider_registry = ProviderRegistry::new(Arc::clone(&rt.system_bus));
provider_registry.register_builtin(crate::llm::providers::openai::OpenAiProvider);
provider_registry.register_builtin(crate::llm::providers::anthropic::AnthropicProvider::new());
provider_registry.register_builtin(crate::llm::providers::openrouter::OpenRouterProvider::new());
provider_registry.register_builtin(crate::llm::providers::requesty::RequestyProvider::new());
provider_registry.register_builtin(crate::llm::providers::ollama::OllamaProvider::new());
// OpenAI-compatible providers are runtime data (providers.yaml), not code.
for p in crate::llm::providers::declared::load(std::path::Path::new(
crate::llm::providers::declared::PROVIDERS_FILE,
)) {
if provider_registry.contains(p.type_id()) {
warn!(type_id = p.type_id(), "declared provider id collides with a native provider — skipped");
continue;
}
provider_registry.register_builtin(p);
}
let provider_registry = Arc::new(provider_registry);
info!("provider registry ready ({} built-in providers)", provider_registry.all().len());
let log_enabled = config.llm.requests_log.as_ref().is_some_and(|r| r.enabled);
let llm_manager = LlmManager::new(Arc::clone(&rt.db), Arc::clone(&provider_registry), log_enabled).await?;
let client_count = llm_manager.client_names().await.len().saturating_sub(1);
let default_client = llm_manager.default_name().await;
info!(clients = client_count, default = %default_client, "LLM clients loaded");
let secrets = SecretsStore::new(Arc::clone(&rt.db));
info!("secrets store ready");
let memory_manager = Arc::new(MemoryManager::new());
info!("memory manager ready");
Ok(Models { provider_registry, llm_manager, secrets, memory_manager })
}
}
// ── Media: transcription / TTS / image generation ───────────────────────────
pub(super) struct Media {
pub(super) image_generator_manager: Arc<ImageGeneratorManager>,
pub(super) transcribe_manager: Arc<TranscribeManager>,
pub(super) tts_manager: Arc<TtsManager>,
}
impl Media {
pub(super) async fn build(rt: &Runtime, models: &Models) -> Result<Self> {
let image_generator_manager = ImageGeneratorManager::new(
Arc::clone(&rt.db),
Arc::clone(&models.provider_registry),
"data",
).await?;
// Evaluate the await outside the `info!` macro: leaving the temporary
// `tracing::Value` from the field expression alive across the await
// makes the surrounding future non-Send, which the multi-threaded
// runtime rejects.
let image_generator_models = image_generator_manager.list_models_info().await.len();
info!(
db_backed = image_generator_models,
"image generator manager ready",
);
let transcribe_manager = TranscribeManager::new(
Arc::clone(&rt.db),
Arc::clone(&models.provider_registry),
Arc::clone(&rt.system_bus),
rt.shutdown_token.clone(),
).await?;
let transcribe_models = transcribe_manager.list_models_info().await.len();
info!(
db_backed = transcribe_models,
"transcribe manager ready",
);
let tts_manager = TtsManager::new(
Arc::clone(&rt.db),
Arc::clone(&models.provider_registry),
Arc::clone(&rt.system_bus),
rt.shutdown_token.clone(),
).await?;
let tts_models = tts_manager.list_models_info().await.len();
info!(
db_backed = tts_models,
"tts manager ready",
);
Ok(Media { image_generator_manager, transcribe_manager, tts_manager })
}
}
// ── Integrations: MCP + plugins ─────────────────────────────────────────────
pub(super) struct Integrations {
pub(super) mcp: Arc<McpManager>,
pub(super) plugin_manager: Arc<PluginManager>,
}
impl Integrations {
/// Builds the MCP manager (its `initialize()` is deferred to `spawn_background`,
/// after the elicitation handler is wired) and the plugin manager (plugins are
/// injected by `main.rs`; `start_enabled()` runs later, from `WebFrontend`).
pub(super) fn build(rt: &Runtime, plugins: Vec<Arc<dyn Plugin>>) -> Self {
// The global runtime has no owner, so its notifications are not persisted:
// `mcp_events` is per-user and its only reader (event triage) runs per-user.
let mcp = Arc::new(McpManager::new(
Arc::clone(&rt.db),
rt.shutdown_token.clone(),
"data",
crate::mcp::EventLog::Discard,
));
// Supervise the global connectors: a crashed one is restarted rather than
// staying dead until the process does. Spawned post-construction because the
// sweep reacts through the `Arc` it is watching (see `spawn_respawn_sweep`).
mcp.spawn_respawn_sweep(rt.shutdown_token.clone());
let mut plugin_manager = PluginManager::new(Arc::clone(&rt.db));
for plugin in plugins {
plugin_manager.register_arc(plugin);
}
info!("plugins registered");
let plugin_manager = Arc::new(plugin_manager);
Integrations { mcp, plugin_manager }
}
}
// ── Tasks: cron ──────────────────────────────────────────────────────────────
pub(super) struct Tasks {
pub(super) cron: Arc<TaskManager>,
}
impl Tasks {
/// Built before `Tools` so cron tools can capture the `TaskManager`.
pub(super) fn build(rt: &Runtime, config: &CoreConfig) -> Self {
let cron_tz = config.timezone.as_deref().and_then(|s| {
match s.parse::<chrono_tz::Tz>() {
Ok(tz) => { info!("timezone: using {s}"); Some(tz) }
Err(_) => { warn!("timezone: unknown value '{s}', falling back to local time"); None }
}
});
let cron = TaskManager::new(Arc::clone(&rt.db), cron_tz, Arc::clone(&rt.system_bus));
Tasks { cron }
}
}
// ── Tools: registry + catalog + slash commands ──────────────────────────────
pub(super) struct Tools {
pub(super) tools: Arc<ToolRegistry>,
pub(super) catalog: ToolCatalog,
pub(super) command_manager: Arc<LlmCommandManager>,
}
impl Tools {
/// Captures sibling managers (mcp, plugins, cron, secrets) into the tool
/// registry. `execute_task` is deliberately NOT registered here — it is injected
/// per interactive session by `ChatHub::send_message`.
pub(super) fn build(rt: &Runtime, integrations: &Integrations, tasks: &Tasks, models: &Models) -> Self {
let mut tool_registry = ToolRegistry::new();
crate::tools::fs::register_all(&mut tool_registry, Arc::clone(&rt.db));
tool_registry.register(crate::tools::ast_outline::AstOutline::new(Arc::clone(&rt.db)));
tool_registry.register(crate::tools::exec::ExecuteCmd);
tool_registry.register(crate::tools::read_notification::ReadNotification);
// Unified listing / toggling across plugins, cron (+ agents and MCP for
// list). MCP is listed but never agent-*managed* (blueprint §14):
// connectors are curated by the admin and activated by the user via the
// Connectors UI/API — hence `list_items` gained the type and
// `toggle_item` deliberately did not.
tool_registry.register(crate::tools::list_items::ListItems::new(
Arc::clone(&integrations.plugin_manager),
Arc::clone(&tasks.cron),
Arc::clone(&rt.db)));
tool_registry.register(crate::tools::toggle_item::ToggleItem::new(
Arc::clone(&integrations.plugin_manager), Arc::clone(&tasks.cron)));
tool_registry.register(crate::tools::cron_jobs::DeleteCronJob);
tool_registry.register(crate::tools::set_secret::SetSecret(Arc::clone(&models.secrets)));
tool_registry.register(crate::tools::list_secrets::ListSecrets(Arc::clone(&models.secrets)));
tool_registry.register(crate::tools::configure_plugin::ConfigurePlugin(Arc::clone(&integrations.plugin_manager)));
// The whole write surface of the read-only skills trees (blueprint §7.3):
// `Config`-category, so neither appears in a request's schema until
// `activate_tools(["config"])` asks. They take the registry to read the
// caller's role (`skill.manage` gates the group's scope) and the cell
// `Skald::new` later fills, through which an installation reaches
// conversations that are already running.
tool_registry.register(crate::tools::skills::SkillRegister::new(
Arc::clone(&rt.db), Arc::clone(&rt.prompt_prefixes)));
tool_registry.register(crate::tools::skills::SkillDelete::new(
Arc::clone(&rt.db), Arc::clone(&rt.prompt_prefixes)));
// The download half of the skills lifecycle (blueprint §7.5): same
// `Config` category, so it too stays out of every request's schema
// until `activate_tools(["config"])`. It needs no state of its own —
// the container it runs git in comes from each caller's `ToolContext`.
tool_registry.register(crate::tools::fetch_repo::FetchRepo);
// Tools contributed by plugins (plugin.md §11), via `Plugin::tools()`.
// The core never names a plugin crate: each one hands over whatever tools
// it wants, bound to its own handle. They are built before the plugins'
// runloops start, so they must tolerate being called while stopped.
for plugin in integrations.plugin_manager.all() {
let id = plugin.id().to_string();
let tools = Arc::clone(plugin).tools();
if tools.is_empty() {
continue;
}
let n = tools.len();
for tool in tools {
tool_registry.register_arc(tool);
}
info!(plugin = %id, count = n, "plugin tools registered");
}
debug!("tool registry built");
let tools = Arc::new(tool_registry);
let catalog = ToolCatalog::new(Arc::clone(&tools), Arc::clone(&integrations.mcp));
let command_manager = Arc::new(LlmCommandManager::new());
Tools { tools, catalog, command_manager }
}
}
// ── Interaction: approval + inbox + clarification + elicitation ─────────────
pub(super) struct Interaction {
pub(super) approval: Arc<ApprovalManager>,
pub(super) inbox: Inbox,
pub(super) clarification: Arc<ClarificationManager>,
pub(super) elicitation: Arc<ElicitationManager>,
}
impl Interaction {
pub(super) async fn build(rt: &Runtime, tools: &Tools) -> Result<Self> {
let approval = Arc::new(ApprovalManager::new(Arc::clone(&rt.db), rt.global_tx.clone()));
if let Err(e) = approval.seed_defaults().await {
warn!(error = %e, "failed to seed default approval rules (non-fatal)");
}
if let Err(e) = approval.migrate_legacy_fs_rules().await {
warn!(error = %e, "failed to migrate legacy filesystem rules (non-fatal)");
}
if let Err(e) = approval.seed_fs_path_rules().await {
warn!(error = %e, "failed to seed File System path rules (non-fatal)");
}
if let Err(e) = approval.seed_default_catch_all().await {
warn!(error = %e, "failed to seed default catch-all rule (non-fatal)");
}
info!("approval manager ready");
// Shared memory is owned by the system database, so its skeleton is
// seeded here rather than in `initialize_instance` — idempotent, so an
// instance that predates the memory wiki gets it on its next boot.
if let Err(e) = crate::memory::scaffold::seed_shared(&rt.db).await {
warn!(error = %e, "failed to seed shared memory scaffold (non-fatal)");
}
let clarification = ClarificationManager::new(rt.global_tx.clone());
let elicitation = ElicitationManager::new(rt.global_tx.clone());
let inbox = Inbox::new(
Arc::clone(&approval),
Arc::clone(&clarification),
Arc::clone(&elicitation),
Arc::clone(&tools.tools),
);
Ok(Interaction { approval, inbox, clarification, elicitation })
}
}
// ── Conversation: session manager + chat hub + run context ──────────────────
pub(super) struct Conversation {
pub(super) manager: Arc<ChatSessionManager>,
pub(super) chat_hub: Arc<ChatHub>,
pub(super) run_context_manager: Arc<RunContextManager>,
}
impl Conversation {
#[allow(clippy::too_many_arguments)]
pub(super) async fn build(
rt: &Runtime,
models: &Models,
media: &Media,
tools: &Tools,
integrations: &Integrations,
interaction: &Interaction,
config: &CoreConfig,
) -> Result<Self> {
let run_context_manager =
Arc::new(RunContextManager::new(Arc::clone(&rt.db), Arc::clone(&interaction.approval)));
if let Err(e) = run_context_manager.seed_defaults().await {
warn!(error = %e, "failed to seed default permission group (non-fatal)");
}
info!("run_context manager ready");
// Always built: `/compact` is a manual command and must work with no
// configuration. Only the automatic trigger is opt-in (`threshold_tokens`).
let compactor = {
let cfg = &config.llm.compaction;
match cfg.threshold_tokens {
Some(threshold_tokens) => info!(
threshold_tokens,
keep_recent = cfg.keep_recent,
?cfg.strength,
"context compactor ready (automatic compaction enabled)"
),
None => info!(
"context compactor ready (automatic compaction off — /compact only)"
),
}
Arc::new(ContextCompactor::new(
cfg.clone(),
Arc::clone(&models.llm_manager),
Arc::clone(&rt.event_bus),
Arc::clone(&rt.config),
))
};
// The ownerless manager is inert (no loops, no consumers — see §19): it takes
// a placeholder UserFs purely to satisfy the type, never used to resolve a path.
let ownerless_fs = core_api::user_fs::SharedFs::new(core_api::user_fs::UserFs::new(
String::new(),
std::path::PathBuf::from("homes"),
"skald-ownerless",
std::path::PathBuf::from("/root"),
Vec::new(),
Vec::new(),
None,
));
let manager = Arc::new(ChatSessionManager::new(
Arc::clone(&rt.db),
Arc::clone(&rt.db), // shared pool == system.db (this is the ownerless manager)
String::new(),
ownerless_fs,
Arc::clone(&models.llm_manager),
config.llm.max_history_messages,
config.llm.max_tool_rounds.unwrap_or(DEFAULT_MAX_TOOL_ROUNDS),
config.llm.max_parallel_subagents.unwrap_or(DEFAULT_MAX_PARALLEL_SUBAGENTS),
config.llm.max_tool_result_chars,
DatetimeConfig { timezone: config.timezone.clone(), ..config.llm.datetime },
// No container, no probe: this bundle is inert (§19).
Arc::new(Vec::new()),
Arc::clone(&tools.tools),
// Inert ownerless bundle (§19): the global runtime as a provider,
// unfiltered — never actually exercised (no loops, no consumers).
Arc::clone(&integrations.mcp) as Arc<dyn crate::mcp::McpProvider>,
Arc::clone(&interaction.approval),
Arc::clone(&interaction.clarification),
Arc::clone(&rt.event_bus),
Arc::clone(&models.memory_manager),
Arc::clone(&media.image_generator_manager),
compactor,
Arc::clone(&run_context_manager),
Arc::new(ToolDiscovery::new(Arc::clone(&rt.db))),
)?);
let chat_hub = ChatHub::new(
Arc::clone(&rt.db),
Arc::clone(&manager),
Arc::clone(&interaction.approval),
rt.global_tx.clone(),
rt.shutdown_token.clone(),
// Inert ownerless bundle (§19): no owner to resolve a role default from, so
// the neutral fallback. Nothing consumes this hub's sessions.
crate::agents::DEFAULT_CHAT_AGENT.to_string(),
);
chat_hub.register("web").await;
chat_hub.register("talk").await;
// Event triage is deliberately absent: it is a system agent that runs *per user*,
// over that user's own events, sessions and hub. Building it here would
// bind it to the ownerless stack above (§19) — which is precisely the bug
// that made it inert. It is constructed by `wiring::spawn_system_agents`.
Ok(Conversation { manager, chat_hub, run_context_manager })
}
}
// ── Infra: leftover singletons ──────────────────────────────────────────────
pub(super) struct Infra {
pub(super) latex_compiler: LatexCompiler,
pub(super) location_manager: Arc<LocationManager>,
pub(super) remote: Arc<RwLock<Option<Arc<dyn RemoteAccess>>>>,
}
impl Infra {
pub(super) fn build() -> Self {
Infra {
latex_compiler: LatexCompiler::new(),
location_manager: Arc::new(LocationManager::new()),
remote: Arc::new(RwLock::new(None)),
}
}
}