fix(agent): synthesise delegate_<toolkit> tools after live integrations fetch (#1670)

This commit is contained in:
Steven Enamakel
2026-05-13 11:06:09 -07:00
committed by GitHub
parent 39f641d467
commit bd72d78b32
4 changed files with 154 additions and 0 deletions
+3
View File
@@ -216,6 +216,9 @@ async fn render_via_session(config: &Config, agent_id: &str) -> Result<DumpedPro
// sees. Best-effort — failures degrade to an empty integration
// list, same as the live runtime.
agent.fetch_connected_integrations().await;
// Mirror turn-1: synthesise `delegate_*` tools for connected
// Composio toolkits now that we know what's actually authorised.
agent.refresh_delegation_tools();
let text = agent
.build_system_prompt(LearnedContextData::default())
@@ -380,6 +380,15 @@ impl AgentBuilder {
.agent_definition_name
.clone()
.unwrap_or_else(|| "main".to_string()),
// Canonical registry id — captured here at build time
// before any caller can call `set_agent_definition_name`
// and clobber the transcript-facing name. Used by
// `refresh_delegation_tools` to re-resolve the agent's
// `subagents` declaration against the global registry.
agent_definition_id: self
.agent_definition_name
.clone()
.unwrap_or_else(|| "main".to_string()),
session_transcript_path: None,
session_key: {
let unix_ts = std::time::SystemTime::now()
+124
View File
@@ -82,6 +82,14 @@ impl Agent {
// inference backend has already tokenised. Fetching it later
// would just burn memory-store reads on data we throw away.
self.fetch_connected_integrations().await;
// The synchronous builder couldn't synthesise `delegate_*`
// tools for connected Composio toolkits (no async runtime
// handle for the Composio fetcher), so it baked in `&[]`.
// Now that integrations are live, inject the matching
// delegation tools so the orchestrator's prompt + tool-spec
// list actually expose `delegate_gmail`, `delegate_notion`,
// etc.
self.refresh_delegation_tools();
let learned = self.fetch_learned_context().await;
let rendered_prompt = self.build_system_prompt(learned)?;
log::info!("[agent] system prompt built — initialising conversation history");
@@ -1251,6 +1259,122 @@ impl Agent {
self.composio_client = crate::openhuman::composio::build_composio_client(&config);
}
/// Re-synthesise `delegate_*` tools for the orchestrator's `subagents`
/// declaration using the live `connected_integrations` slice.
///
/// The synchronous `AgentBuilder` path cannot do this — it has no
/// async runtime handle to reach Composio — so it passes an empty
/// integrations slice to `collect_orchestrator_tools` and produces
/// zero skill delegation tools. That leaves the orchestrator without
/// `delegate_gmail` / `delegate_notion` / … even when those toolkits
/// are actually authorised. Call this *after*
/// [`Agent::fetch_connected_integrations`] (and before
/// [`Agent::build_system_prompt`]) on turn 1 to add the missing
/// delegation tools to the live tool registry, tool-spec list, and
/// visible-tool whitelist so the LLM sees them as first-class tools.
///
/// No-op when:
/// * the agent definition isn't in the global registry,
/// * the definition has no `subagents` entries,
/// * no new tools would be added (every synthesised name already
/// exists in `self.tools`),
/// * the `tools` / `tool_specs` Arc is shared (e.g. a sub-agent has
/// already captured a snapshot — should never happen on turn 1).
pub fn refresh_delegation_tools(&mut self) {
use crate::openhuman::agent::harness::definition::AgentDefinitionRegistry;
use crate::openhuman::tools::orchestrator_tools::collect_orchestrator_tools;
let Some(reg) = AgentDefinitionRegistry::global() else {
return;
};
let Some(def) = reg.get(&self.agent_definition_id) else {
log::debug!(
"[agent] refresh_delegation_tools: definition '{}' not in registry — skipping",
self.agent_definition_id
);
return;
};
if def.subagents.is_empty() {
return;
}
let synthed = collect_orchestrator_tools(def, reg, &self.connected_integrations);
if synthed.is_empty() {
return;
}
let existing: std::collections::HashSet<String> =
self.tools.iter().map(|t| t.name().to_string()).collect();
let new_tools: Vec<Box<dyn Tool>> = synthed
.into_iter()
.filter(|t| !existing.contains(t.name()))
.collect();
if new_tools.is_empty() {
return;
}
let new_names: Vec<String> = new_tools.iter().map(|t| t.name().to_string()).collect();
let new_specs: Vec<crate::openhuman::tools::ToolSpec> =
new_tools.iter().map(|t| t.spec()).collect();
// The tools / tool_specs Arcs are uniquely owned at turn-1 build
// time (no sub-agent has captured a snapshot yet). If a caller
// managed to clone them out before the first turn fired, the
// mutation below would silently no-op — log that case so it's
// visible in diagnostics.
match (
Arc::get_mut(&mut self.tools),
Arc::get_mut(&mut self.tool_specs),
) {
(Some(tools_vec), Some(specs_vec)) => {
tools_vec.extend(new_tools);
specs_vec.extend(new_specs);
}
_ => {
log::warn!(
"[agent] refresh_delegation_tools: tools/tool_specs Arc is shared — \
cannot inject delegation tools ({} would have been added: {:?})",
new_names.len(),
new_names
);
return;
}
}
// Definitions with a Named ToolScope carry an explicit visible
// whitelist; the synthesised delegation tools must opt in to that
// whitelist or the LLM never sees them. Wildcard-scope agents
// keep `visible_tool_names` empty ("no filter"), so we skip the
// insert there.
if !self.visible_tool_names.is_empty() {
for name in &new_names {
self.visible_tool_names.insert(name.clone());
}
}
// Rebuild the visible-spec cache so the next prompt build picks
// up the new tools.
let visible_specs: Vec<crate::openhuman::tools::ToolSpec> =
if self.visible_tool_names.is_empty() {
(*self.tool_specs).clone()
} else {
self.tool_specs
.iter()
.filter(|spec| self.visible_tool_names.contains(&spec.name))
.cloned()
.collect()
};
self.visible_tool_specs = Arc::new(visible_specs);
log::info!(
"[agent] refresh_delegation_tools: added {} delegation tool(s) for agent '{}' (display='{}'): {:?}",
new_names.len(),
self.agent_definition_id,
self.agent_definition_name,
new_names
);
}
/// Builds the system prompt for the current turn, including tool
/// instructions and learned context.
pub fn build_system_prompt(&self, learned: LearnedContextData) -> Result<String> {
@@ -71,7 +71,25 @@ pub struct Agent {
/// Human-readable agent definition name (e.g. `"main"`,
/// `"code_executor"`). Used as the `{agent}` component in session
/// transcript paths: `sessions/DDMMYYYY/{agent}_{index}.md`.
///
/// May be rewritten mid-session by
/// [`Agent::set_agent_definition_name`] (e.g. the web channel
/// stamps `"orchestrator_<short_thread>"` so each thread gets its
/// own transcript namespace). Anything that needs to resolve the
/// session back to its registry entry must use
/// [`Self::agent_definition_id`], not this field.
pub(super) agent_definition_name: String,
/// Canonical agent id as registered in
/// [`AgentDefinitionRegistry`] (e.g. `"orchestrator"`,
/// `"integrations_agent"`). Set once at build time and never
/// rewritten — `set_agent_definition_name` only touches the
/// transcript-facing `agent_definition_name`, so registry lookups
/// (e.g. `refresh_delegation_tools` re-resolving the agent's
/// `subagents` list post-fetch) stay correct even after the web
/// channel's per-thread rename.
///
/// [`AgentDefinitionRegistry`]: crate::openhuman::agent::harness::definition::AgentDefinitionRegistry
pub(super) agent_definition_id: String,
/// Resolved filesystem path for this session's transcript file.
/// Set on first write, reused for subsequent overwrites within the
/// same session.