fix: one tool-set recipe per session, so a tool cannot vanish between rounds
Nightly Build / build (push) Successful in 7m36s

Two "unknown tool (not in this turn's tool set)" failures, one disease: the
turn's tool set was rebuilt from a different recipe depending on which entry
point happened to drive it.

A sub-agent got `ask_user_clarification`, `execute_subtask` and `activate_tools`
and nothing else — while `agents/common/tools.md` and every reporting agent's
prompt tell it to register its output with `update_scratchpad`. The child could
see the scratchpad injected into its context but had no way to write to it. It
now gets the scratchpad and todos tools, on the parent's `scratchpad_sid`: one
blackboard per session, as the surrounding code already declared.

`show_file_to_user` was injected per message by the WS handler, while
`resume_session` and `resolve_pending_call` rebuilt the list with `execute_task`
alone. So approving a card, or reconnecting mid-turn, continued the *same*
conversation with the tool silently gone. There is now a single recipe,
`ChatHub::session_interface_tools`, used by all three paths and fed by a builder
the shell installs once through `Skald::set_interface_tools_builder`: the core
keeps owning the tool, the shell keeps owning the policy of who gets it —
Telegram still does not, since it cannot act on OpenFile.
This commit is contained in:
2026-08-04 15:03:46 +01:00
parent ff298f1aef
commit f900d803f2
8 changed files with 163 additions and 36 deletions
+10
View File
@@ -63,6 +63,16 @@ impl Skald {
fn rt_user_contexts(&self) -> &super::user_context::UserContextRegistry { &self.user_contexts }
/// Declares the tools the running surface contributes to a chat session
/// (the SPA's `show_file_to_user`, …). Called once by the shell after
/// construction: the core owns the tools, the shell owns the policy of who
/// gets them. Every per-user hub built from here on receives it, and every
/// path that starts or resumes a turn consults it — see
/// [`crate::chat_hub::InterfaceToolsBuilder`].
pub fn set_interface_tools_builder(&self, build: crate::chat_hub::InterfaceToolsBuilder) {
self.rt_user_contexts().set_interface_tools_builder(build);
}
/// The user's runtime context IF it is already live (built), **without**
/// building one — used to refresh a logged-in user in place. A user who never
/// logged in has no snapshot to refresh; their next login builds a fresh one.
+25 -1
View File
@@ -21,7 +21,7 @@
//! pending map.
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::{Arc, OnceLock};
use anyhow::Result;
use chrono_tz::Tz;
@@ -146,6 +146,10 @@ pub(super) struct UserContextFactory {
datetime_config: DatetimeConfig,
compaction: CompactionConfig,
cron_tz: Option<Tz>,
/// The surface's interface-tool policy, installed by the shell after
/// construction and handed to every per-user hub built from here — the one
/// place that can reach a hub created lazily at login.
iface_tools: OnceLock<crate::chat_hub::InterfaceToolsBuilder>,
}
impl UserContextFactory {
@@ -181,9 +185,19 @@ impl UserContextFactory {
datetime_config: DatetimeConfig { timezone: config.timezone.clone(), ..config.llm.datetime },
compaction: config.llm.compaction.clone(),
cron_tz,
iface_tools: OnceLock::new(),
}
}
/// Installs the shell's interface-tool policy. Applies to every hub built
/// from now on; call it before serving requests.
pub(super) fn set_interface_tools_builder(
&self,
build: crate::chat_hub::InterfaceToolsBuilder,
) {
let _ = self.iface_tools.set(build);
}
async fn build(&self, user_id: &str, pool: SqlitePool) -> Result<Arc<UserContext>> {
let pool = Arc::new(pool);
// This user's own stop signal: a **child** of the instance token, so a global
@@ -352,6 +366,9 @@ impl UserContextFactory {
user_shutdown.clone(),
default_agent,
);
if let Some(build) = self.iface_tools.get() {
chat_hub.set_interface_tools_builder(Arc::clone(build));
}
chat_hub.register("web").await;
chat_hub.register("talk").await;
@@ -405,6 +422,13 @@ impl UserContextRegistry {
Self { factory, contexts: Mutex::new(HashMap::new()) }
}
pub(super) fn set_interface_tools_builder(
&self,
build: crate::chat_hub::InterfaceToolsBuilder,
) {
self.factory.set_interface_tools_builder(build);
}
/// Returns the user's context, building it from `pool` on first use. Idempotent:
/// once built, the same `Arc<UserContext>` is returned until restart.
pub(super) async fn resolve(&self, user_id: &str, pool: SqlitePool) -> Result<Arc<UserContext>> {