Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 114 additions & 0 deletions crates/openhuman-core/src/agent/orchestration/fleet_tools.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
//! Which fleet-control tools a parent agent can actually call.
//!
//! The `[async_subagent_ref]` envelope and the ambient `[active_subagents]`
//! roster used to hard-code a full fleet vocabulary — `wait_subagent`,
//! `steer_subagent`, `wait_loop`, `close_subagent` — while the orchestrator's
//! definition deliberately dropped most of it (#5701: a sub-agent result is
//! delivered back automatically on a later turn, so nothing needs to block).
//! The model was told to call tools it did not have, spent an iteration
//! reasoning about the mismatch, and improvised (`shell echo "waiting for
//! subagent"`). Every delegation paid a full extra model call for nothing.
//!
//! This module reads the parent's definition once per render and answers
//! "does this parent see tool X?", so both texts only ever name tools that
//! are in the caller's belt.

use crate::agent::harness::definition::{AgentDefinitionRegistry, ToolScope};

/// The fleet-control tools whose availability shapes the delegation texts.
const FLEET_TOOLS: &[&str] = &[
"steer_subagent",
"wait_subagent",
"wait",
"wait_loop",
"close_subagent",
"continue_subagent",
"list_subagents",
];

/// The subset of [`FLEET_TOOLS`] a given parent definition exposes.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct FleetToolSet {
available: Vec<&'static str>,
}

impl FleetToolSet {
/// Every fleet tool — the pre-#5701 assumption, used when the parent's
/// definition cannot be resolved so the texts degrade to their old shape
/// rather than to silence.
pub(crate) fn all() -> Self {
Self {
available: FLEET_TOOLS.to_vec(),
}
}

/// Resolve the set for `agent_definition_id` from the global registry.
pub(crate) fn for_parent(agent_definition_id: &str) -> Self {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

priority high security confident

Wire FleetToolSet into the fleet prompt renderers

This pull request adds the resolver but does not connect it to either the [async_subagent_ref] envelope or the [active_subagents] roster. The production prompts therefore continue using the hard-coded fleet vocabulary, so agents can still be instructed to call controls absent from their tool surface. Pass the effective FleetToolSet into both renderers and use it when generating their guidance.

[RULE] missing-feature-wiring ·

let Some(registry) = AgentDefinitionRegistry::global() else {
return Self::all();
};
let Some(definition) = registry.get(agent_definition_id) else {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

priority medium critique confident

Do not advertise the full fleet for unknown parents

When the registry exists but agent_definition_id is unknown, this returns every fleet tool even though the parent’s actual visible surface is unresolved. For example, a parent with only steer_subagent registered will be instructed that it can wait, close, continue, or list subagents. Resolve availability from the parent’s effective visible tool names, or fail closed when the parent cannot be resolved.

[RULE] tool-availability-mismatch ·

log::debug!(
"[fleet_tools] parent definition '{}' not in registry; assuming full fleet vocabulary",
agent_definition_id
);
return Self::all();
};
Self::from_scope(&definition.tools, &definition.disallowed_tools)
}

/// Resolve the set from a turn's *effective* visible tool names — the
/// live, already-filtered membership set (hides, named restrictions, and
/// policy narrowing all applied), as opposed to [`Self::for_parent`]'s
/// static read of the parent's registered definition. Prefer this
/// whenever the caller already has that snapshot: a hide or restriction
/// applied mid-session can narrow a turn's real tool surface below what
/// the definition alone would suggest, and offering a control absent
/// from this snapshot invites a denied tool call.
pub(crate) fn from_visible_tool_names(names: &std::collections::HashSet<String>) -> Self {
let available = FLEET_TOOLS
.iter()
.copied()
.filter(|name| names.contains(*name))
.collect();
Self { available }
}

/// Derive the set from a definition's tool scope and denylist. A
/// `Named` scope exposes exactly the fleet tools it lists; `Wildcard`
/// exposes all of them. `disallowed_tools` (exact or trailing-`*`
/// prefix) removes entries from either.
pub(crate) fn from_scope(scope: &ToolScope, disallowed: &[String]) -> Self {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

priority medium security confident

Constrain wildcard fleet tools to the actual parent belt

The ToolScope contract says Wildcard means all tools the parent has, subject to policy and runtime registration; it does not mean every fleet-control tool is registered or visible. The wildcard branch currently marks every fleet tool available, so any renderer using this resolver can advertise controls the current parent cannot call. Derive wildcard availability from the parent's effective visible or registered tool names, rather than treating wildcard as unconditional availability.

[RULE] tool-availability-mismatch ·

let denied = |name: &str| {
disallowed
.iter()
.any(|entry| match entry.strip_suffix('*') {
Some(prefix) => name.starts_with(prefix),
None => entry == name,
})
};
let available = FLEET_TOOLS
.iter()
.copied()
.filter(|name| match scope {
ToolScope::Wildcard => true,
ToolScope::Named(named) => named.iter().any(|n| n == name),
})
.filter(|name| !denied(name))
.collect();
Self { available }
}

pub(crate) fn has(&self, tool: &str) -> bool {
self.available.contains(&tool)
}

/// Whether the parent can block on or poll a worker at all.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

priority medium critique confident

Treat delayed wait tools as polling capabilities

wait and wait_loop are explicitly included in FLEET_TOOLS, but can_wait() returns false unless wait_subagent is present. A parent exposing only wait_loop will consequently receive automatic-delivery guidance and be told not to wait or poll, despite having a supported polling tool. Return true when any supported wait tool is available.

[RULE] incomplete-capability-check ·

pub(crate) fn can_wait(&self) -> bool {
self.has("wait_subagent")
}
Comment on lines +107 to +109

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

priority medium security confident

Treat delayed wait tools as polling capabilities

wait and wait_loop are explicitly recognized fleet tools, but can_wait ignores them. A parent exposing either delayed polling tool will be reported as unable to wait, causing roster or delegation guidance to claim results arrive automatically and discourage the supported polling path. Include all supported wait tools in this capability check.

Suggested change
pub(crate) fn can_wait(&self) -> bool {
self.has("wait_subagent")
}
pub(crate) fn can_wait(&self) -> bool {
self.has("wait_subagent") || self.has("wait") || self.has("wait_loop")
}

[RULE] incomplete-capability-check ·

}

#[cfg(test)]
#[path = "fleet_tools_tests.rs"]
mod tests;
67 changes: 67 additions & 0 deletions crates/openhuman-core/src/agent/orchestration/fleet_tools_tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
use super::FleetToolSet;
use crate::agent::harness::definition::ToolScope;

fn named(tools: &[&str]) -> ToolScope {
ToolScope::Named(tools.iter().map(|t| t.to_string()).collect())
}

#[test]
fn named_scope_exposes_only_listed_fleet_tools() {
let set = FleetToolSet::from_scope(
&named(&[
"spawn_async_subagent",
"list_subagents",
"continue_subagent",
"shell",
]),
&[],
);
assert!(set.has("list_subagents"));
assert!(set.has("continue_subagent"));
assert!(!set.has("wait_subagent"));
assert!(!set.has("steer_subagent"));
assert!(!set.has("wait_loop"));
assert!(!set.can_wait());
}

#[test]
fn wildcard_scope_exposes_every_fleet_tool_minus_denylist() {
let set = FleetToolSet::from_scope(&ToolScope::Wildcard, &["wait*".to_string()]);
assert!(set.has("steer_subagent"));
assert!(set.has("close_subagent"));
assert!(!set.has("wait"));
assert!(!set.has("wait_loop"));
assert!(!set.has("wait_subagent"));
assert!(!set.can_wait());
}

#[test]
fn all_is_the_full_vocabulary() {
let set = FleetToolSet::all();
for tool in [
"steer_subagent",
"wait_subagent",
"wait",
"wait_loop",
"close_subagent",
"continue_subagent",
"list_subagents",
] {
assert!(set.has(tool), "{tool}");
}
assert!(set.can_wait());
}

/// The shipped orchestrator definition is the case that motivated this
/// module: it must not be told about wait/steer/close tools.
#[test]
fn builtin_orchestrator_has_no_wait_or_steer() {
let registry = crate::agent::harness::definition::AgentDefinitionRegistry::builtins_only();
let def = registry.get("orchestrator").expect("built-in orchestrator");
let set = FleetToolSet::from_scope(&def.tools, &def.disallowed_tools);
assert!(set.has("list_subagents"));
assert!(set.has("continue_subagent"));
assert!(!set.can_wait());
assert!(!set.has("steer_subagent"));
assert!(!set.has("close_subagent"));
}
1 change: 1 addition & 0 deletions crates/openhuman-core/src/agent/orchestration/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ pub(crate) mod background_completions;
pub(crate) mod background_delivery;
pub mod command_center;
pub(crate) mod delegation;
pub(crate) mod fleet_tools;
mod ops;
pub(crate) mod parent_context;
pub(crate) mod run_ledger_finalize;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
//! `[active_subagents]` context block.

use super::registry::{registry, SubagentStatus};
use crate::agent::orchestration::fleet_tools::FleetToolSet;

/// Compact, read-only view of one registered sub-agent, for ambient injection
/// into a parent's turn context (see [`active_subagents_context_block`]).
Expand Down Expand Up @@ -52,6 +53,40 @@ pub(crate) fn snapshot_for_parent(parent_session: &str) -> Vec<SubagentSnapshot>
out
}

/// The follow-up guidance sentence, built from the tools the parent can see.
fn roster_guidance(fleet: &FleetToolSet) -> String {
let mut parts: Vec<String> = Vec::new();
if fleet.has("wait_subagent") {
parts.push("use wait_subagent to collect a `completed` one".into());
} else {
parts.push(
"a `running` or `completed` worker's result is delivered to you automatically on a \
later turn — do not wait or poll for it"
.into(),
);
}
if fleet.has("steer_subagent") {
parts.push("steer_subagent to redirect a `running` one".into());
}
if fleet.has("continue_subagent") {
parts.push(
"continue_subagent to answer an `awaiting_user` one or to RESUME an `idle` one with \
a follow-up (it keeps its full prior context — do NOT re-delegate the same task \
from scratch)"
.into(),
);
}
if fleet.has("close_subagent") {
parts.push("close_subagent when done".into());
}
if fleet.has("list_subagents") {
parts.push("list_subagents to re-enumerate".into());
}
let mut sentence = parts.join(", ");
sentence.push('.');
sentence
}

/// Most-recent durable sessions surfaced in the roster when they are not in
/// the live registry (cold boot / later turn). Bounds prompt growth on
/// threads with a long delegation history.
Expand All @@ -71,9 +106,15 @@ const DURABLE_ROSTER_CAP: usize = 12;
/// cold-booted parent had no idea its previous sub-agents existed and
/// would re-delegate from scratch instead of resuming by
/// `subagent_session_id` (the "fresh context from day 0" bug).
///
/// `fleet` is the parent's fleet-control vocabulary: the guidance sentence
/// only names tools the parent can call (the orchestrator has no
/// `wait_subagent` / `steer_subagent` / `close_subagent` since #5701, and
/// telling it otherwise cost an iteration of confused reasoning per turn).
pub(crate) fn active_subagents_context_block(
parent_session: &str,
workspace_dir: &std::path::Path,
fleet: &FleetToolSet,
) -> Option<String> {
let workers = snapshot_for_parent(parent_session);

Expand Down Expand Up @@ -114,13 +155,10 @@ pub(crate) fn active_subagents_context_block(
"[active_subagents]\n\
You have {} sub-agent worker(s) for this conversation (live and/or from earlier \
turns). This is your authoritative roster — trust it over memory. Track each by \
subagent_session_id; use wait_subagent to collect a `completed` one, steer_subagent \
to redirect a `running` one, continue_subagent to answer an `awaiting_user` one or \
to RESUME an `idle` one with a follow-up (it keeps its full prior context — do NOT \
re-delegate the same task from scratch), close_subagent when done, and \
list_subagents to re-enumerate. Never fabricate a result for a worker still running \
subagent_session_id. {} Never fabricate a result for a worker still running \
or one that has failed.\n",
workers.len() + durable.len()
workers.len() + durable.len(),
roster_guidance(fleet)
);
for w in &workers {
let session = w.subagent_session_id.as_deref().unwrap_or("(none)");
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use super::*;
use crate::agent::orchestration::fleet_tools::FleetToolSet;
use crate::agent::orchestration::running_subagents::registry::DETACHED_LEDGER_TIMEOUT_MS;
use crate::agent::orchestration::running_subagents::resolve::resume_ref_for_task;
use crate::agent::orchestration::running_subagents::resolve::task_id_for_session;
Expand Down Expand Up @@ -242,15 +243,45 @@ async fn snapshot_and_block_scope_to_parent_and_reflect_live_status() {
assert_eq!(snap[1].status, "running");

let block =
active_subagents_context_block("fleet-parent", &test_workspace()).expect("block present");
active_subagents_context_block("fleet-parent", &test_workspace(), &FleetToolSet::all())
.expect("block present");
assert!(block.contains("[active_subagents]"));
assert!(block.contains("use wait_subagent to collect"));
assert!(block.contains("You have 2 sub-agent worker(s)"));
assert!(block.contains("session=subsess-a"));
assert!(block.contains("session=subsess-b · task=task-fleet-b · status=awaiting_user"));
assert!(block.ends_with("[/active_subagents]\n\n"));

// A parent with no registered workers gets no block (no perturbation).
assert!(active_subagents_context_block("nobody-here", &test_workspace()).is_none());
assert!(
active_subagents_context_block("nobody-here", &test_workspace(), &FleetToolSet::all())
.is_none()
);

// The shipped orchestrator has no wait/steer/close tools (#5701): the
// guidance must not name them and must say results arrive on their own.
{
use crate::agent::harness::definition::AgentDefinitionRegistry;
let registry = AgentDefinitionRegistry::builtins_only();
let def = registry.get("orchestrator").expect("built-in orchestrator");
let fleet = FleetToolSet::from_scope(&def.tools, &def.disallowed_tools);
let block = active_subagents_context_block("fleet-parent", &test_workspace(), &fleet)
.expect("block present");
for name in [
"wait_subagent",
"steer_subagent",
"close_subagent",
"wait_loop",
] {
assert!(
!block.contains(name),
"{name} named for a parent without it:\n{block}"
);
}
assert!(block.contains("delivered to you automatically"));
assert!(block.contains("continue_subagent"));
assert!(block.contains("list_subagents"));
}

// Durable-store fallback: a session persisted by an EARLIER turn /
// process lifetime (empty live registry for this parent) must still
Expand Down Expand Up @@ -295,13 +326,19 @@ async fn snapshot_and_block_scope_to_parent_and_reflect_live_status() {
)
.expect("mark idle");

let block = active_subagents_context_block("cold-parent", durable_ws.path())
.expect("durable-only roster present");
let block =
active_subagents_context_block("cold-parent", durable_ws.path(), &FleetToolSet::all())
.expect("durable-only roster present");
assert!(block.contains(&format!("session={}", session.subagent_session_id)));
assert!(block.contains("status=idle"));
assert!(block.contains("about: Daily X trending email workflow"));
// Other parents' durable sessions must not leak in.
assert!(active_subagents_context_block("unrelated-parent", durable_ws.path()).is_none());
assert!(active_subagents_context_block(
"unrelated-parent",
durable_ws.path(),
&FleetToolSet::all()
)
.is_none());
}

let _ = tx_a.send(SubagentStatus::Completed {
Expand Down
Loading
Loading