diff --git a/AGENTS.md b/AGENTS.md index 00606c3c3c..caa36cb03f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -389,6 +389,13 @@ seed history by hand, or pick a transcript by recency. messages verbatim, which is what keeps the provider's prefix cache warm across a restart. The accepted consequence is that prompt edits, new skills and newly connected integrations do not reach an existing thread. +- **So is the tool list it was sent.** Every turn records its tool + declarations in the transcript (a `{"kind":"tools"}` record, written only + when they change); resume restores them, the prefix, and the committed-turn + count. The host never shrinks a thread's tools because a cache went cold: + `session_host/recorded_tools.rs` rebuilds recorded Composio actions as + deferred executors, and the prelude fetches integrations on the first turn + of every session instance, not only on a brand-new thread. - **Pre-identity conversations are adopted once**, on first resume, from the timestamped stems they were written to (`adopt_legacy_session_transcripts`). No legacy file is modified. diff --git a/Cargo.lock b/Cargo.lock index 7b85ba5fb5..ed0500c713 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6531,6 +6531,7 @@ version = "2.1.2" dependencies = [ "async-trait", "chrono", + "serde_json", "thiserror 2.0.20", "tinyagents-harness", "tinyagents-session", diff --git a/crates/openhuman-core/src/agent/learning/transcript_ingest/transcript_ingest_tests.rs b/crates/openhuman-core/src/agent/learning/transcript_ingest/transcript_ingest_tests.rs index f061e7793b..21dc967180 100644 --- a/crates/openhuman-core/src/agent/learning/transcript_ingest/transcript_ingest_tests.rs +++ b/crates/openhuman-core/src/agent/learning/transcript_ingest/transcript_ingest_tests.rs @@ -202,6 +202,7 @@ fn fake_meta(thread_id: Option<&str>) -> TranscriptMeta { async fn ingest_extracts_high_importance_preference_with_provenance() { let mem = InMemory::new(); let transcript = SessionTranscript { + tools: None, meta: fake_meta(Some("thr_alpha")), messages: durable_messages([ ChatMessage::user("hi"), @@ -236,6 +237,7 @@ async fn ingest_extracts_high_importance_preference_with_provenance() { async fn re_ingest_is_idempotent() { let mem = InMemory::new(); let transcript = SessionTranscript { + tools: None, meta: fake_meta(Some("thr_beta")), messages: durable_messages([ChatMessage::user( "I prefer Postgres for everything new — please default to it.", @@ -260,6 +262,7 @@ async fn re_ingest_is_idempotent() { async fn ingest_captures_user_reflection_and_recurring_pattern() { let mem = InMemory::new(); let transcript = SessionTranscript { + tools: None, meta: fake_meta(Some("thr_gamma")), messages: durable_messages([ ChatMessage::user("I prefer terse responses with no preamble."), @@ -296,6 +299,7 @@ async fn ingest_captures_user_reflection_and_recurring_pattern() { async fn ingest_filters_low_signal_chatter() { let mem = InMemory::new(); let transcript = SessionTranscript { + tools: None, meta: fake_meta(None), messages: durable_messages([ ChatMessage::user("ok"), @@ -323,6 +327,7 @@ async fn ingest_persists_candidates_with_bounded_concurrency() { // PERSIST_CONCURRENCY (8), so an unbounded fan-out would push more than 8 // stores in flight at once — the bound assertion below would then fail. let transcript = SessionTranscript { + tools: None, meta: fake_meta(Some("thr_bound")), messages: durable_messages([ ChatMessage::user("I prefer Postgres over MySQL for new metadata services."), diff --git a/crates/openhuman-core/src/agent/session_host/mod.rs b/crates/openhuman-core/src/agent/session_host/mod.rs index 4334c8864c..e4ef0d56fa 100644 --- a/crates/openhuman-core/src/agent/session_host/mod.rs +++ b/crates/openhuman-core/src/agent/session_host/mod.rs @@ -39,6 +39,7 @@ mod factory; mod hooks; mod policy; mod prefix_snapshot; +mod recorded_tools; mod runtime; mod runtime_session; mod session_api; diff --git a/crates/openhuman-core/src/agent/session_host/prefix_snapshot.rs b/crates/openhuman-core/src/agent/session_host/prefix_snapshot.rs index f96b957935..35ec8012db 100644 --- a/crates/openhuman-core/src/agent/session_host/prefix_snapshot.rs +++ b/crates/openhuman-core/src/agent/session_host/prefix_snapshot.rs @@ -2,9 +2,10 @@ //! //! The prompt is rendered once per session as cache tiers (see //! `agent::prompts::TieredPrompt::system_messages`) and sent as one leading -//! system message per tier. These two helpers turn a rendered prompt into -//! that prefix and recover it from a resumed transcript, so `runtime_session` -//! never has to know how many messages a prefix is. +//! system message per tier. This helper turns a rendered prompt into that +//! prefix, so `runtime_session` never has to know how many messages a prefix +//! is. A resumed thread's prefix is restored by the tinyagents session from +//! the transcript's leading system rows; nothing here re-derives it. use tinyagents_runtime::PrefixSnapshot; use tinyinference_llm::message::Message; @@ -24,14 +25,3 @@ pub(super) fn tiered_prefix_snapshot(tiered: &TieredPrompt) -> PrefixSnapshot { ); PrefixSnapshot::new(messages.into_iter().map(Message::system).collect()) } - -/// The frozen prefix of a resumed transcript: every leading system message, -/// not only the first, because the prompt is sent as one message per tier. -pub(super) fn leading_system_prefix(history: &[Message]) -> Option { - let leading: Vec = history - .iter() - .take_while(|message| matches!(message, Message::System(_))) - .cloned() - .collect(); - (!leading.is_empty()).then(|| PrefixSnapshot::new(leading)) -} diff --git a/crates/openhuman-core/src/agent/session_host/prelude_integrations.rs b/crates/openhuman-core/src/agent/session_host/prelude_integrations.rs new file mode 100644 index 0000000000..258dcbffd4 --- /dev/null +++ b/crates/openhuman-core/src/agent/session_host/prelude_integrations.rs @@ -0,0 +1,223 @@ +//! Turn-boundary refresh of the prelude's integration state: hydrating the +//! connected integrations a session starts without, tracking connects and +//! revokes between turns, and adopting the integration actions the tinyagents +//! session restored for a resumed thread. + +use std::sync::Arc; + +use tinyagents_runtime::ToolSnapshot; + +use super::OpenHumanTurnPrelude; + +impl OpenHumanTurnPrelude { + /// Takes the declarations the tinyagents session restored for this + /// thread. Called before the boundary refresh so the rebuilt surface can + /// include them. + pub(super) fn adopt_recorded_tools(&self, recorded: Option<&ToolSnapshot>) { + let Some(recorded) = recorded else { + return; + }; + let actions = super::super::recorded_tools::recorded_integration_actions(recorded.specs()); + log::debug!( + "[session] adopting {} recorded integration action declaration(s) agent={}", + actions.len(), + self.agent_definition_id + ); + self.mutable + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .recorded_integration_actions = actions; + } + + pub(super) async fn refresh_turn_boundary(&self, cold: bool) { + // Hydrate on the first turn of *this session instance*, not only on a + // brand-new thread. A resumed thread is never `cold`, and a session + // rebuilt after a restart (or any rebuild past the 60 s integrations + // cache TTL) is seeded from an empty cache — gating the fetch on + // `cold` left it with zero integrations, no deferred Composio + // actions, and no `tool_search` bridge for the whole thread. + // `refresh_cold_integrations` is a no-op once hydrated. + self.refresh_cold_integrations().await; + if !cold { + self.refresh_dynamic_announcements().await; + } + // Integration changes are authority changes, not only display + // announcements. Refresh the delegation executable set and rebuild + // its schema/policy in the same hook pass before the driver sees it. + self.refresh_delegation_tool_surface(); + } + + pub(super) async fn refresh_cold_integrations(&self) { + let should_fetch = !self + .mutable + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .connected_integrations_initialized; + if !should_fetch { + return; + } + let config = match self.runtime_config.clone() { + Some(config) => Some(config), + None => crate::config::Config::load_or_init() + .await + .ok() + .map(Arc::new), + }; + let Some(config) = config else { + return; + }; + let Some((connected, authoritative)) = load_connected_integrations(&config).await else { + // Backend unreachable and nothing cached: stay un-hydrated so the + // next turn retries rather than pinning an empty surface. + log::warn!( + "[session] integrations unavailable and no cached snapshot; will retry next turn agent={}", + self.agent_definition_id + ); + return; + }; + log::info!( + "[session] hydrated connected integrations count={} agent={}", + connected.len(), + self.agent_definition_id + ); + let mcp_servers = crate::mcp::registry::connections::connected_overview() + .await + .into_iter() + .map(|server| server.qualified_name) + .collect::>(); + let mut mutable = self + .mutable + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + mutable.connected_integrations = connected; + // A stale fallback is useful for announcements but cannot authorize + // restored executors. Leave hydration pending so a later turn retries + // the live lookup rather than pinning this session to the snapshot. + mutable.connected_integrations_initialized = authoritative; + mutable.connected_integrations_authoritative = authoritative; + mutable.announced_integrations = mutable + .connected_integrations + .iter() + .map(|item| item.toolkit.clone()) + .collect(); + mutable.announced_mcp_servers = mcp_servers; + } + + pub(super) async fn refresh_dynamic_announcements(&self) { + let skills_changed = self.drain_host_events(); + let config = match self.runtime_config.clone() { + Some(config) => Some(config), + None => crate::config::Config::load_or_init() + .await + .ok() + .map(Arc::new), + }; + if let Some(config) = config.as_deref() { + // An expired cache is refetched rather than skipped, so a + // long-lived session keeps tracking connects/revokes. + let current = match crate::integrations::composio::cached_active_integrations(config) { + Some(current) => Some((current, true)), + None => load_connected_integrations(config).await, + }; + if let Some((current, authoritative)) = current { + let mut mutable = self + .mutable + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let current_slugs: std::collections::HashSet<_> = + current.iter().map(|item| item.toolkit.clone()).collect(); + mutable + .announced_integrations + .retain(|slug| current_slugs.contains(slug)); + mutable + .pending_integration_announcement + .retain(|slug| current_slugs.contains(slug)); + for slug in ¤t_slugs { + if mutable.announced_integrations.insert(slug.clone()) + && !mutable.pending_integration_announcement.contains(slug) + { + mutable.pending_integration_announcement.push(slug.clone()); + } + } + mutable.connected_integrations = current; + mutable.connected_integrations_authoritative = authoritative; + } + } + let connected_mcp = crate::mcp::registry::connections::connected_overview() + .await + .into_iter() + .map(|server| server.qualified_name) + .collect::>(); + let mut mutable = self + .mutable + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let connected_mcp: std::collections::HashSet<_> = connected_mcp.into_iter().collect(); + mutable + .announced_mcp_servers + .retain(|server| connected_mcp.contains(server)); + mutable + .pending_mcp_announcement + .retain(|server| connected_mcp.contains(server)); + for server in connected_mcp { + if mutable.announced_mcp_servers.insert(server.clone()) + && !mutable.pending_mcp_announcement.contains(&server) + { + mutable.pending_mcp_announcement.push(server); + } + } + if !skills_changed { + return; + } + // Event-driven metadata refresh keeps the steady-state hot path free + // of the old per-turn filesystem scan. + let latest = crate::skills::load_workflow_metadata(&self.workspace_dir); + let id = |workflow: &crate::skills::Workflow| { + if workflow.dir_name.is_empty() { + workflow.name.clone() + } else { + workflow.dir_name.clone() + } + }; + let previous: std::collections::HashSet<_> = mutable.workflows.iter().map(&id).collect(); + let current: std::collections::HashSet<_> = latest.iter().map(&id).collect(); + for id in current.difference(&previous) { + if mutable.announced_skills.insert((*id).clone()) + && !mutable.pending_skill_announcement.contains(id) + { + mutable.pending_skill_announcement.push((*id).clone()); + } + } + for id in previous.difference(¤t) { + mutable.announced_skills.remove(id); + mutable + .pending_skill_announcement + .retain(|pending| pending != id); + if !mutable.pending_skill_retraction.contains(id) { + mutable.pending_skill_retraction.push((*id).clone()); + } + } + mutable.workflows = latest; + } +} + +/// Live connected integrations, falling back to the last cached snapshot +/// (even past its TTL) when the backend is unreachable. `None` only when +/// there is neither a live answer nor any snapshot to fall back to. +async fn load_connected_integrations( + config: &crate::config::Config, +) -> Option<(Vec, bool)> { + use crate::integrations::composio::FetchConnectedIntegrationsStatus; + match crate::integrations::composio::fetch_connected_integrations_status(config).await { + FetchConnectedIntegrationsStatus::Authoritative(connected) => Some((connected, true)), + FetchConnectedIntegrationsStatus::Unavailable => { + let stale = + crate::integrations::composio::cached_active_integrations_including_expired(config); + log::warn!( + "[session] integrations fetch unavailable; using stale snapshot={}", + stale.as_ref().map_or(0, Vec::len) + ); + stale.map(|connected| (connected, false)) + } + } +} diff --git a/crates/openhuman-core/src/agent/session_host/recorded_tools.rs b/crates/openhuman-core/src/agent/session_host/recorded_tools.rs new file mode 100644 index 0000000000..20d7c0de4f --- /dev/null +++ b/crates/openhuman-core/src/agent/session_host/recorded_tools.rs @@ -0,0 +1,106 @@ +//! Rebuilds the executors a resumed thread already declared to the model. +//! +//! The tinyagents session records the exact tool declarations each turn was +//! sent with and hands them back on resume (`SessionStateView::recorded_tools`). +//! A new process, though, rebuilds its live tool surface from state that may +//! not be there yet: Composio actions are synthesised from the connected +//! integrations list, which comes from a 60 s process cache that is empty +//! after a restart and may be unreachable. Without a fallback, a resumed +//! thread whose prompt says "search for the Gmail action" loses every +//! integration action — and with it the `tool_search` bridge — for the turn. +//! +//! This module turns the recorded Composio action declarations back into +//! executable deferred tools, so the tool list a thread was sent never +//! shrinks just because a cache went cold. The live surface stays +//! authoritative for any action it still supplies. + +use std::collections::HashSet; + +use tinytools::{Tool, ToolSpec}; + +use crate::integrations::composio::action_tool::ComposioActionTool; + +/// Whether `name` is a Composio action slug (`GMAIL_SEND_EMAIL`). +/// +/// Composio slugs are upper-case `TOOLKIT_ACTION`; every OpenHuman-owned tool +/// name is lower-case snake case, so the shapes never overlap. +pub(super) fn is_integration_action_name(name: &str) -> bool { + let mut parts = name.splitn(2, '_'); + let (Some(toolkit), Some(action)) = (parts.next(), parts.next()) else { + return false; + }; + !toolkit.is_empty() + && !action.is_empty() + && name + .chars() + .all(|ch| ch.is_ascii_uppercase() || ch.is_ascii_digit() || ch == '_') +} + +/// The toolkit slug an action slug belongs to (`GMAIL_SEND_EMAIL` → `gmail`). +/// Only used as the tool's search family. +fn toolkit_of(action: &str) -> String { + action + .split('_') + .next() + .unwrap_or(action) + .to_ascii_lowercase() +} + +/// The recorded integration action declarations, in recorded order. +pub(super) fn recorded_integration_actions(recorded: &[ToolSpec]) -> Vec { + recorded + .iter() + .filter(|spec| is_integration_action_name(&spec.name)) + .cloned() + .collect() +} + +/// Deferred executors for every recorded action the live set does not +/// already provide. `live` names win: a still-connected integration keeps +/// its freshly fetched declaration. +pub(super) fn rehydrate_integration_actions( + recorded: &[ToolSpec], + live: &[Box], + integrations: &[crate::agent::prompts::ConnectedIntegration], + integrations_are_authoritative: bool, +) -> Vec> { + let live_names: HashSet<&str> = live.iter().map(|tool| tool.name()).collect(); + let mut seen = HashSet::new(); + recorded + .iter() + // Only Composio's upper-case `TOOLKIT_ACTION` declarations may be + // reconstructed. A recorded OpenHuman tool such as `web_fetch` is + // historical prompt state, not an integration action. + .filter(|spec| is_integration_action_name(&spec.name)) + // Transcript declarations are historical state, never authorization. + // Do not make a deferred executor available until a current + // authoritative integration snapshot permits its toolkit and action. + .filter(|spec| { + integrations_are_authoritative + && integrations.iter().any(|integration| { + integration.connected + && integration + .toolkit + .eq_ignore_ascii_case(&toolkit_of(&spec.name)) + && !integration + .gated_tools + .iter() + .any(|gated| gated.name == spec.name) + }) + }) + .filter(|spec| !live_names.contains(spec.name.as_str())) + .filter(|spec| seen.insert(spec.name.clone())) + .map(|spec| { + Box::new(ComposioActionTool::deferred( + &toolkit_of(&spec.name), + spec.name.clone(), + spec.description.clone(), + Some(spec.parameters.clone()), + )) as Box + }) + .collect() +} + +#[cfg(test)] +#[path = "recorded_tools_tests.rs"] +mod tests; diff --git a/crates/openhuman-core/src/agent/session_host/recorded_tools_tests.rs b/crates/openhuman-core/src/agent/session_host/recorded_tools_tests.rs new file mode 100644 index 0000000000..ae37759e20 --- /dev/null +++ b/crates/openhuman-core/src/agent/session_host/recorded_tools_tests.rs @@ -0,0 +1,128 @@ +use super::*; + +fn spec(name: &str) -> ToolSpec { + ToolSpec { + name: name.into(), + description: format!("{name} description"), + parameters: serde_json::json!({ + "type": "object", + "properties": { "to": { "type": "string" } } + }), + } +} + +fn integration( + toolkit: &str, + connected: bool, + gated_tools: Vec, +) -> crate::agent::prompts::ConnectedIntegration { + crate::agent::prompts::ConnectedIntegration { + toolkit: toolkit.into(), + description: String::new(), + tools: Vec::new(), + gated_tools, + connected, + connections: Vec::new(), + non_active_status: None, + } +} + +#[test] +fn only_composio_slugs_count_as_integration_actions() { + assert!(is_integration_action_name("GMAIL_SEND_EMAIL")); + assert!(is_integration_action_name("GOOGLECALENDAR_CREATE_EVENT")); + assert!(!is_integration_action_name("memory_recall")); + assert!(!is_integration_action_name("tool_search")); + assert!(!is_integration_action_name("GMAIL")); + assert!(!is_integration_action_name("_SEND")); +} + +#[test] +fn recorded_actions_are_filtered_from_a_mixed_declaration_set() { + let recorded = vec![ + spec("web_fetch"), + spec("GMAIL_SEND_EMAIL"), + spec("research"), + ]; + let actions = recorded_integration_actions(&recorded); + assert_eq!(actions.len(), 1); + assert_eq!(actions[0].name, "GMAIL_SEND_EMAIL"); +} + +#[test] +fn unavailable_authorization_does_not_rebuild_recorded_actions() { + let recorded = vec![spec("GMAIL_SEND_EMAIL"), spec("GMAIL_FETCH_EMAILS")]; + let rebuilt = rehydrate_integration_actions(&recorded, &[], &[], false); + assert!(rebuilt.is_empty()); +} + +#[test] +fn non_integration_declarations_are_never_rehydrated() { + let recorded = vec![spec("web_fetch"), spec("GMAIL_SEND_EMAIL")]; + let integrations = vec![ + integration("web", true, Vec::new()), + integration("gmail", true, Vec::new()), + ]; + + let rebuilt = rehydrate_integration_actions(&recorded, &[], &integrations, true); + let names: Vec<&str> = rebuilt.iter().map(|tool| tool.name()).collect(); + assert_eq!(names, vec!["GMAIL_SEND_EMAIL"]); +} + +#[test] +fn a_live_action_is_not_rebuilt_from_the_record() { + let recorded = vec![spec("GMAIL_SEND_EMAIL"), spec("SLACK_SEND_MESSAGE")]; + let integrations = vec![ + integration("gmail", true, Vec::new()), + integration("slack", true, Vec::new()), + ]; + let live: Vec> = + rehydrate_integration_actions(&[spec("GMAIL_SEND_EMAIL")], &[], &integrations, true); + let rebuilt = rehydrate_integration_actions(&recorded, &live, &integrations, true); + let names: Vec<&str> = rebuilt.iter().map(|tool| tool.name()).collect(); + assert_eq!(names, vec!["SLACK_SEND_MESSAGE"]); +} + +#[test] +fn a_rebuilt_declaration_is_byte_identical_to_the_recorded_one() { + let recorded = vec![spec("GMAIL_SEND_EMAIL")]; + let integrations = vec![integration("gmail", true, Vec::new())]; + let first = rehydrate_integration_actions(&recorded, &[], &integrations, true); + let second = rehydrate_integration_actions(&recorded, &[], &integrations, true); + let mut rebuilt = first[0].spec(); + rebuilt + .parameters + .get_mut("properties") + .and_then(serde_json::Value::as_object_mut) + .expect("rebuilt properties") + .remove("connection_id"); + assert_eq!( + serde_json::to_string(&rebuilt).unwrap(), + serde_json::to_string(&recorded[0]).unwrap() + ); + assert_eq!( + serde_json::to_string(&first[0].spec()).unwrap(), + serde_json::to_string(&second[0].spec()).unwrap() + ); +} + +#[test] +fn authoritative_integrations_do_not_restore_revoked_or_gated_actions() { + let recorded = vec![spec("GMAIL_SEND_EMAIL"), spec("SLACK_SEND_MESSAGE")]; + let integrations = vec![ + integration( + "gmail", + true, + vec![crate::agent::prompts::GatedIntegrationTool { + name: "GMAIL_SEND_EMAIL".into(), + description: String::new(), + required_scope: "write".into(), + unlock_paths: Vec::new(), + }], + ), + integration("slack", false, Vec::new()), + ]; + + let rebuilt = rehydrate_integration_actions(&recorded, &[], &integrations, true); + assert!(rebuilt.is_empty()); +} diff --git a/crates/openhuman-core/src/agent/session_host/runtime_session.rs b/crates/openhuman-core/src/agent/session_host/runtime_session.rs index f85f6bc429..5a6f3e93ea 100644 --- a/crates/openhuman-core/src/agent/session_host/runtime_session.rs +++ b/crates/openhuman-core/src/agent/session_host/runtime_session.rs @@ -146,6 +146,12 @@ struct OpenHumanTurnPreludeMutable { pending_skill_retraction: Vec, connected_integrations: Vec, connected_integrations_initialized: bool, + connected_integrations_authoritative: bool, + /// Integration action declarations this thread was already sent, + /// restored by the tinyagents session on resume. Rebuilt into deferred + /// executors whenever the live integrations list does not supply them + /// (see `recorded_tools`). + recorded_integration_actions: Vec, workflows: Vec, composio_events: Option>, skill_events: Option>, @@ -221,19 +227,6 @@ impl OpenHumanTurnPrelude { tools: Some(tools), }) } - - async fn refresh_turn_boundary(&self, cold: bool) { - if cold { - self.refresh_cold_integrations().await; - } else { - self.refresh_dynamic_announcements().await; - } - // Integration changes are authority changes, not only display - // announcements. Refresh the delegation executable set and rebuild - // its schema/policy in the same hook pass before the driver sees it. - self.refresh_delegation_tool_surface(); - } - fn begin_user_effects(&self, state: &mut OpenHumanSessionState, request: &SessionTurnRequest) { let user_text = request.input.text(); if self.auto_save && crate::agent::turn_origin::current_is_user_authored() { @@ -400,114 +393,13 @@ impl OpenHumanTurnPrelude { .build_system_prompt_tiered(&context) } - async fn refresh_cold_integrations(&self) { - let should_fetch = !self - .mutable + #[cfg(test)] + fn synthesized_tool_names_for_test(&self) -> std::collections::HashSet { + self.tool_surface .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) - .connected_integrations_initialized; - if !should_fetch { - return; - } - let config = match self.runtime_config.clone() { - Some(config) => Some(config), - None => crate::config::Config::load_or_init() - .await - .ok() - .map(Arc::new), - }; - let Some(config) = config else { - return; - }; - let connected = crate::integrations::composio::fetch_connected_integrations(&config).await; - let mcp_servers = crate::mcp::registry::connections::connected_overview() - .await - .into_iter() - .map(|server| server.qualified_name) - .collect::>(); - let mut mutable = self - .mutable - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - mutable.connected_integrations = connected; - mutable.connected_integrations_initialized = true; - mutable.announced_integrations = mutable - .connected_integrations - .iter() - .map(|item| item.toolkit.clone()) - .collect(); - mutable.announced_mcp_servers = mcp_servers; - } - - async fn refresh_dynamic_announcements(&self) { - let skills_changed = self.drain_host_events(); - if let Some(config) = self.runtime_config.as_deref() { - if let Some(current) = crate::integrations::composio::cached_active_integrations(config) - { - let mut mutable = self - .mutable - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let current_slugs: std::collections::HashSet<_> = - current.iter().map(|item| item.toolkit.clone()).collect(); - for slug in ¤t_slugs { - if mutable.announced_integrations.insert(slug.clone()) - && !mutable.pending_integration_announcement.contains(slug) - { - mutable.pending_integration_announcement.push(slug.clone()); - } - } - mutable.connected_integrations = current; - } - } - let connected_mcp = crate::mcp::registry::connections::connected_overview() - .await - .into_iter() - .map(|server| server.qualified_name) - .collect::>(); - let mut mutable = self - .mutable - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - for server in connected_mcp { - if mutable.announced_mcp_servers.insert(server.clone()) - && !mutable.pending_mcp_announcement.contains(&server) - { - mutable.pending_mcp_announcement.push(server); - } - } - if !skills_changed { - return; - } - // Event-driven metadata refresh keeps the steady-state hot path free - // of the old per-turn filesystem scan. - let latest = crate::skills::load_workflow_metadata(&self.workspace_dir); - let id = |workflow: &crate::skills::Workflow| { - if workflow.dir_name.is_empty() { - workflow.name.clone() - } else { - workflow.dir_name.clone() - } - }; - let previous: std::collections::HashSet<_> = mutable.workflows.iter().map(&id).collect(); - let current: std::collections::HashSet<_> = latest.iter().map(&id).collect(); - for id in current.difference(&previous) { - if mutable.announced_skills.insert((*id).clone()) - && !mutable.pending_skill_announcement.contains(id) - { - mutable.pending_skill_announcement.push((*id).clone()); - } - } - for id in previous.difference(¤t) { - mutable.announced_skills.remove(id); - mutable - .pending_skill_announcement - .retain(|pending| pending != id); - if !mutable.pending_skill_retraction.contains(id) { - mutable.pending_skill_retraction.push((*id).clone()); - } - } - mutable.workflows = latest; + .synthesized_tool_names + .clone() } /// Rebuild every delegation-dependent tool view from the current cached @@ -529,20 +421,54 @@ impl OpenHumanTurnPrelude { if definition.subagents.is_empty() { return; } - let integrations = self - .mutable - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .connected_integrations - .clone(); + let (integrations, integrations_are_authoritative) = { + let mutable = self + .mutable + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + ( + mutable.connected_integrations.clone(), + mutable.connected_integrations_authoritative, + ) + }; let mut surface = self .tool_surface .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); - let synthesized = super::builder::drop_synthesized_name_collisions( - &surface.tools, - collect_orchestrator_tools(&definition, registry, &integrations), - ); + let mut collected = collect_orchestrator_tools(&definition, registry, &integrations); + // Integration actions the thread already declared stay executable + // even when this process has not (re)fetched their integration yet. + // Only an agent that carries integration actions at all gets them. + if definition.subagents.iter().any(|entry| { + matches!( + entry, + crate::agent::harness::definition::SubagentEntry::Skills(wildcard) + if wildcard.matches_all() + ) + }) { + let recorded = self + .mutable + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .recorded_integration_actions + .clone(); + let rebuilt = super::recorded_tools::rehydrate_integration_actions( + &recorded, + &collected, + &integrations, + integrations_are_authoritative, + ); + if !rebuilt.is_empty() { + log::info!( + "[session] rebuilt {} recorded integration action(s) the live integrations did not supply agent={}", + rebuilt.len(), + self.agent_definition_id + ); + collected.extend(rebuilt); + } + } + let synthesized = + super::builder::drop_synthesized_name_collisions(&surface.tools, collected); let synthesized_names = synthesized .iter() .map(|tool| tool.name().to_string()) @@ -1400,6 +1326,37 @@ impl OpenHumanSessionHost { cancellation, run_context: context.into_tinyagents(root_config), }; + // `tinyagents_runtime::Session` owns the restored declaration + // snapshot. Load a bound, otherwise empty session before its normal + // lifecycle runs so the host prelude can rebuild only its permitted + // recorded integration executors for this turn. + if matches!(options.resume, ResumeMode::Session) + && self + .runtime_session + .as_ref() + .is_some_and(|session| session.history().is_empty()) + { + let runtime = self + .runtime_session + .as_mut() + .expect("runtime session initialized"); + let resumed = runtime + .resume(&options) + .await + .map_err(|error| anyhow::anyhow!(error.to_string()))?; + if resumed.loaded { + let recorded_tools = runtime.recorded_tools().cloned(); + if let Some(prelude) = self + .runtime_state + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .prelude + .clone() + { + prelude.adopt_recorded_tools(recorded_tools.as_ref()); + } + } + } let outcome = self .runtime_session .as_mut() @@ -1565,6 +1522,10 @@ impl OpenHumanSessionHost { pending_skill_retraction: self.pending_skill_retraction.clone(), connected_integrations: self.connected_integrations.clone(), connected_integrations_initialized: self.connected_integrations_initialized, + // Builder-provided integrations have not been verified by + // this session's current authorization refresh. + connected_integrations_authoritative: false, + recorded_integration_actions: Vec::new(), workflows: self.workflows.clone(), composio_events: None, skill_events: None, @@ -1597,9 +1558,6 @@ impl OpenHumanSessionHost { let state = state.clone(); let request_base_len = view.history.len() + usize::from(view.history.last() != Some(&request.input)); - let resumed_prefix = view - .resumed - .then(|| super::prefix_snapshot::leading_system_prefix(view.history)); Box::pin(async move { let transcript_snapshot = crate::agent::tinyagents::TranscriptSnapshotSink::default(); @@ -1660,7 +1618,9 @@ impl OpenHumanSessionHost { tinyagents_runtime::RuntimeError::Driver(error.to_string()) })?; if overrides.suppress_tools { - preparation.tools = Some(ToolSnapshot::default()); + // One-off tool-less turn: must not become the + // thread's recorded tool list. + preparation.tools = Some(ToolSnapshot::default().exact()); } let ( mut current_tools, @@ -1701,9 +1661,6 @@ impl OpenHumanSessionHost { .unwrap_or_else(|poisoned| poisoned.into_inner()) .required_output .clone(); - if let Some(prefix) = resumed_prefix { - preparation.prefix = prefix; - } Ok(preparation) }) } @@ -1871,7 +1828,8 @@ impl OpenHumanSessionHost { // driving a provider first. let mut builder = SessionBuilder::new(driver) .codec(Arc::new(OpenHumanTranscriptCodec)) - .hooks(hooks); + .hooks(hooks) + .retain_recorded_tools(true); if let Some(session) = self.session.clone() { builder = builder.session( self.session_locator(), @@ -1985,3 +1943,10 @@ impl OpenHumanSessionHost { } } } + +#[path = "prelude_integrations.rs"] +mod prelude_integrations; + +#[cfg(test)] +#[path = "runtime_session_tests.rs"] +mod tests; diff --git a/crates/openhuman-core/src/agent/session_host/runtime_session_tests.rs b/crates/openhuman-core/src/agent/session_host/runtime_session_tests.rs new file mode 100644 index 0000000000..71577ade52 --- /dev/null +++ b/crates/openhuman-core/src/agent/session_host/runtime_session_tests.rs @@ -0,0 +1,80 @@ +//! Tests for the turn prelude's tool surface across a resumed thread. + +use std::sync::Arc; + +use tinyagents_runtime::ToolSnapshot; +use tinytools::ToolSpec; + +fn spec(name: &str) -> ToolSpec { + ToolSpec { + name: name.into(), + description: format!("{name} description"), + parameters: serde_json::json!({ "type": "object", "properties": {} }), + } +} + +/// The incident this guards: a thread resumed in a fresh process (empty +/// integrations cache) lost every Composio action, so the orchestrator's +/// `tool_search` had nothing to find although its restored prompt told it to +/// search for the Gmail action. Rehydration is permitted only after the +/// current integration authorization snapshot confirms Gmail is connected. +#[tokio::test] +async fn a_resumed_orchestrator_keeps_the_integration_actions_it_was_sent() { + let _ = crate::agent::harness::definition::AgentDefinitionRegistry::init_global_builtins(); + let action_dir = tempfile::tempdir().expect("tempdir"); + let model: Arc> = + Arc::new(tinyagents_harness::testkit::ScriptedModel::new(Vec::new())); + let mut host = crate::agent::SessionHostBuilder::new() + .chat_model(model) + .tools(Vec::new()) + .action_dir(action_dir.path().to_path_buf()) + .memory(crate::memory::test_support::noop_memory()) + .tool_dispatcher(Box::new(tinytools_agent::dialect::XmlDialect)) + .agent_definition_name("orchestrator") + .build() + .expect("session build"); + host.ensure_runtime_session().expect("runtime session"); + + let state = host + .runtime_state + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let prelude = state.prelude.as_ref().expect("prelude"); + + // Fresh process: no integrations known, so no actions are synthesised. + prelude.refresh_delegation_tool_surface(); + assert!(!prelude + .synthesized_tool_names_for_test() + .contains("GMAIL_SEND_EMAIL")); + + // Resume hands back what the thread was sent. + let recorded = ToolSnapshot::new(vec![ + spec("GMAIL_SEND_EMAIL"), + spec("GMAIL_FETCH_EMAILS"), + spec("web_fetch"), + ]) + .expect("snapshot"); + prelude.adopt_recorded_tools(Some(&recorded)); + { + let mut mutable = prelude.mutable.lock().expect("prelude state"); + mutable.connected_integrations = vec![crate::agent::prompts::ConnectedIntegration { + toolkit: "gmail".into(), + description: String::new(), + tools: Vec::new(), + gated_tools: Vec::new(), + connected: true, + connections: Vec::new(), + non_active_status: None, + }]; + mutable.connected_integrations_authoritative = true; + } + prelude.refresh_delegation_tool_surface(); + + let names = prelude.synthesized_tool_names_for_test(); + assert!(names.contains("GMAIL_SEND_EMAIL"), "{names:?}"); + assert!(names.contains("GMAIL_FETCH_EMAILS"), "{names:?}"); + assert!( + !names.contains("web_fetch"), + "only integration actions are rebuilt from the record" + ); +} diff --git a/crates/openhuman-core/src/agent/session_import/live_tests.rs b/crates/openhuman-core/src/agent/session_import/live_tests.rs index 277a76acdf..cc32c835e4 100644 --- a/crates/openhuman-core/src/agent/session_import/live_tests.rs +++ b/crates/openhuman-core/src/agent/session_import/live_tests.rs @@ -317,6 +317,7 @@ async fn in_memory_store_reconstruction_diverges_from_legacy_on_sidecar_metadata .expect("assistant message present"); attach_chat_turn_usage_metadata(&mut live_messages[last_assistant], &usage); let reconstructed = SessionTranscript { + tools: None, meta: meta.clone(), messages: durable_messages(&live_messages), }; @@ -352,6 +353,7 @@ async fn shadow_read_unavailable_and_divergence() { // No store write yet: empty/absent stream against a non-empty legacy // transcript → Unavailable (no shadow), never a divergence. let legacy = SessionTranscript { + tools: None, meta: meta.clone(), messages: durable_messages(&[ChatMessage::user("hi"), ChatMessage::assistant("done")]), }; @@ -367,6 +369,7 @@ async fn shadow_read_unavailable_and_divergence() { .await .expect("live dual-write"); let diverging = SessionTranscript { + tools: None, meta, messages: durable_messages(&[ ChatMessage::user("hi"), diff --git a/vendor/tinyagents b/vendor/tinyagents index fcf7e884c4..3821811095 160000 --- a/vendor/tinyagents +++ b/vendor/tinyagents @@ -1 +1 @@ -Subproject commit fcf7e884c407479721e03f08aa78a5166e44dd52 +Subproject commit 382181109579b29092fec2ffd67837763a299148