fix: one tool-set recipe per session, so a tool cannot vanish between rounds
Nightly Build / build (push) Successful in 7m36s
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:
@@ -44,6 +44,21 @@ const NOTIFY_BATCH_WINDOW_MS: u64 = 200;
|
||||
// first message of a burst.
|
||||
const SOURCE_COALESCE_DEBOUNCE_MS: u64 = 0;
|
||||
|
||||
/// Builds the surface-specific interface tools of one session.
|
||||
///
|
||||
/// The core owns the tools themselves but must never learn **which** surface
|
||||
/// gets them (`show_file_to_user` is for SPA clients, never for the Telegram
|
||||
/// plugin): that policy is installed by the shell through
|
||||
/// [`ChatHub::set_interface_tools_builder`] and consulted by every path that
|
||||
/// starts or resumes a turn, so the tool set of a conversation cannot depend on
|
||||
/// which entry point drove it.
|
||||
///
|
||||
/// The hub hands **itself** in as an argument rather than being captured, so a
|
||||
/// builder stored on the hub is not a reference cycle.
|
||||
pub type InterfaceToolsBuilder = Arc<
|
||||
dyn Fn(Arc<ChatHub>, &str, &Arc<ChatSessionHandler>) -> Vec<InterfaceTool> + Send + Sync,
|
||||
>;
|
||||
|
||||
// ── ChatHub ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Manages **interactive, user-facing sessions only** (web, mobile, project chats):
|
||||
@@ -68,6 +83,9 @@ pub struct ChatHub {
|
||||
/// TaskManager reference for injecting execute_task into interactive sessions.
|
||||
/// Set via set_task_mgr() after construction (breaks circular dep with cron).
|
||||
task_mgr: std::sync::OnceLock<Arc<TaskManager>>,
|
||||
/// The surface's own interface tools, installed post-construction by the
|
||||
/// shell. See [`InterfaceToolsBuilder`].
|
||||
iface_tools: OnceLock<InterfaceToolsBuilder>,
|
||||
/// Per-source input inboxes (coalescing + FIFO ordering). Created lazily on the
|
||||
/// first message for a source; each spawns one consumer task.
|
||||
inboxes: Mutex<HashMap<String, Arc<SourceInbox>>>,
|
||||
@@ -109,6 +127,7 @@ impl ChatHub {
|
||||
global_tx,
|
||||
notify_tx,
|
||||
task_mgr: std::sync::OnceLock::new(),
|
||||
iface_tools: OnceLock::new(),
|
||||
inboxes: Mutex::new(HashMap::new()),
|
||||
me: OnceLock::new(),
|
||||
shutdown: shutdown.clone(),
|
||||
@@ -131,6 +150,12 @@ impl ChatHub {
|
||||
let _ = self.task_mgr.set(task_mgr);
|
||||
}
|
||||
|
||||
/// Installs the surface's interface-tool policy. Called once per hub by the
|
||||
/// shell (the core must not know what an SPA is). Absent ⇒ no extra tools.
|
||||
pub fn set_interface_tools_builder(&self, build: InterfaceToolsBuilder) {
|
||||
let _ = self.iface_tools.set(build);
|
||||
}
|
||||
|
||||
// ── Public API ────────────────────────────────────────────────────────────
|
||||
|
||||
/// Register a source. No-op for duplicate registrations.
|
||||
@@ -199,23 +224,19 @@ impl ChatHub {
|
||||
// Bridge mpsc from handle_message → global broadcast, tagging with source/session.
|
||||
let tx = Self::bridge_to_global(self.global_tx.clone(), source_tag, session_id);
|
||||
|
||||
// get_or_create_handler is idempotent; we call it early to read the
|
||||
// session's RunContext so it can be inherited by any task spawned here.
|
||||
// get_or_create_handler is idempotent; we call it early because the
|
||||
// session's RunContext (read inside the recipe below) is inherited by
|
||||
// any task spawned here.
|
||||
let handler = self.session_mgr.get_or_create_handler(session_id).await?;
|
||||
let run_context_json = handler.run_context_json().await;
|
||||
|
||||
// Inject execute_task as an InterfaceTool for all interactive sessions.
|
||||
// session_id and run_context_json are captured so tasks inherit the parent context.
|
||||
// The session's own interface tools — the same recipe the resume and
|
||||
// approval-resolution paths use, so nothing appears or vanishes
|
||||
// depending on how the turn started. A caller may still add its own on
|
||||
// top through `opts`.
|
||||
let mut interface_tools = opts.interface_tools;
|
||||
if let Some(task_mgr) = self.task_mgr.get() {
|
||||
interface_tools.push(
|
||||
crate::tools::cron_jobs::build_execute_task_interface_tool(
|
||||
Arc::clone(task_mgr),
|
||||
session_id,
|
||||
run_context_json,
|
||||
)
|
||||
);
|
||||
}
|
||||
interface_tools.extend(
|
||||
self.session_interface_tools(session_id, source_id, &handler).await,
|
||||
);
|
||||
handler.handle_message(
|
||||
prompt,
|
||||
opts.client_name,
|
||||
@@ -407,9 +428,9 @@ impl ChatHub {
|
||||
let source = chat_sessions::find_by_id(&self.db, session_id).await?
|
||||
.map(|s| s.source)
|
||||
.unwrap_or_else(|| "web".to_string());
|
||||
let tx = Self::bridge_to_global(self.global_tx.clone(), source, session_id);
|
||||
let tx = Self::bridge_to_global(self.global_tx.clone(), source.clone(), session_id);
|
||||
let handler = self.session_mgr.get_or_create_handler(session_id).await?;
|
||||
let interface_tools = self.execute_task_tools(session_id, &handler).await;
|
||||
let interface_tools = self.session_interface_tools(session_id, &source, &handler).await;
|
||||
handler.recover_turn(interface_tools, tx).await
|
||||
}
|
||||
|
||||
@@ -432,18 +453,28 @@ impl ChatHub {
|
||||
let source = chat_sessions::find_by_id(&self.db, session_id).await?
|
||||
.map(|s| s.source)
|
||||
.unwrap_or_else(|| "web".to_string());
|
||||
let tx = Self::bridge_to_global(self.global_tx.clone(), source, session_id);
|
||||
let tx = Self::bridge_to_global(self.global_tx.clone(), source.clone(), session_id);
|
||||
let handler = self.session_mgr.get_or_create_handler(session_id).await?;
|
||||
let interface_tools = self.execute_task_tools(session_id, &handler).await;
|
||||
let interface_tools = self.session_interface_tools(session_id, &source, &handler).await;
|
||||
handler.resolve_pending_call(call, decision, interface_tools, tx).await
|
||||
}
|
||||
|
||||
/// Builds the `execute_task` interface tool for a session, mirroring the injection
|
||||
/// done for live turns. Empty when no TaskManager is configured
|
||||
/// so `execute_task mode=async` can be rebuilt by `build_execution` during resume.
|
||||
async fn execute_task_tools(
|
||||
/// **The** interface-tool recipe of a session: `execute_task` (so a pending
|
||||
/// sub-agent task can be re-dispatched) plus whatever the surface declared
|
||||
/// through [`Self::set_interface_tools_builder`].
|
||||
///
|
||||
/// Every path that starts or resumes a turn goes through here — the live
|
||||
/// message, `resume_session`, `resolve_pending_call`. That is the whole
|
||||
/// point: before this, only the live path was given `show_file_to_user`
|
||||
/// (injected per-message by the WS handler), so approving a card or
|
||||
/// reconnecting mid-turn continued the *same conversation* with the tool
|
||||
/// silently gone, and the model's next call to it failed with "unknown
|
||||
/// tool". A tool set must be a property of the session, not of the entry
|
||||
/// point that happened to drive it.
|
||||
async fn session_interface_tools(
|
||||
&self,
|
||||
session_id: i64,
|
||||
source: &str,
|
||||
handler: &Arc<ChatSessionHandler>,
|
||||
) -> Vec<InterfaceTool> {
|
||||
let mut tools = Vec::new();
|
||||
@@ -455,6 +486,11 @@ impl ChatHub {
|
||||
run_context_json,
|
||||
));
|
||||
}
|
||||
if let Some(build) = self.iface_tools.get() {
|
||||
if let Some(me) = self.me.get().and_then(Weak::upgrade) {
|
||||
tools.extend(build(me, source, handler));
|
||||
}
|
||||
}
|
||||
tools
|
||||
}
|
||||
|
||||
|
||||
@@ -26,7 +26,9 @@ use crate::clarification::ClarificationManager;
|
||||
use crate::llm::LlmManager;
|
||||
use crate::llm::logging::RequestLogTarget;
|
||||
use crate::loop_adapters::activation::SkaldToolActivator;
|
||||
use crate::loop_adapters::builtins::{SkaldAskUserTool, SkaldHumanChannel};
|
||||
use crate::loop_adapters::builtins::{
|
||||
SkaldAskUserTool, SkaldHumanChannel, UpdateScratchpadTool, WriteTodosTool,
|
||||
};
|
||||
use crate::loop_adapters::history::SqliteHistory;
|
||||
use crate::loop_adapters::prefix_cache::PrefixCache;
|
||||
use crate::loop_adapters::runtime::LoopConfig;
|
||||
@@ -217,6 +219,18 @@ impl AgentCatalog for SkaldAgentCatalog {
|
||||
scope.session_id,
|
||||
Some(child_frame.get()),
|
||||
)))));
|
||||
// The blackboard and the checklist. Both are *told* to sub-agents by the
|
||||
// prompts (`agents/common/tools.md`, and every reporting agent ends with
|
||||
// "register your report with `update_scratchpad`"), and the scratchpad is
|
||||
// injected into a child's context — so leaving the writers out of the
|
||||
// child's tool set made a documented instruction unexecutable: the model
|
||||
// called them and got "unknown tool". `scratchpad_sid` is the parent's,
|
||||
// deliberately (see the context above): one blackboard per session.
|
||||
native.push(Arc::new(UpdateScratchpadTool::new(
|
||||
self.pool.clone(),
|
||||
scope.scratchpad_sid,
|
||||
)));
|
||||
native.push(Arc::new(WriteTodosTool));
|
||||
|
||||
let toolset: Arc<dyn agent_loop::tool::ToolSet> = Arc::new(
|
||||
SkaldToolSet::new(
|
||||
|
||||
@@ -366,6 +366,22 @@ impl ChatSessionHandler {
|
||||
self.fs.load()
|
||||
}
|
||||
|
||||
/// The swappable fs **cell**, not a snapshot: a tool built from it follows a
|
||||
/// §6 remount instead of pinning the membership it saw at build time.
|
||||
pub fn shared_fs(&self) -> SharedFs {
|
||||
self.fs.clone()
|
||||
}
|
||||
|
||||
/// The owner's encrypted pool (`{userid}.db`).
|
||||
pub fn owner_pool(&self) -> &Arc<SqlitePool> {
|
||||
&self.db
|
||||
}
|
||||
|
||||
/// The registry pool (`system.db`) — shared memory, registry tables.
|
||||
pub fn shared_pool(&self) -> &Arc<SqlitePool> {
|
||||
&self.shared_pool
|
||||
}
|
||||
|
||||
/// Override the session used for scratchpad reads/writes.
|
||||
/// Called by the cron runner for async tasks so they share the parent's scratchpad.
|
||||
pub fn set_scratchpad_session_id(&self, id: i64) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>> {
|
||||
|
||||
Reference in New Issue
Block a user