Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
use super::*;

#[test]
fn parse_tool_calls_nested_xml_tags_handled() {
// Double-wrapped tool call should still parse the inner call
fn parse_tool_calls_nested_xml_tags_are_rejected() {
// A nested tool_call span is malformed protocol output. The strict parser
// must leave it unexecuted rather than guessing which tag owns the JSON.
let response =
r#"<tool_call><tool_call>{"name":"echo","arguments":{"msg":"hi"}}</tool_call></tool_call>"#;
let (_text, calls) = parse_tool_calls(response);
// Should find at least one tool call
// Nested markup must not become an executable call.
assert!(
!calls.is_empty(),
"nested XML tags should still yield at least one tool call"
calls.is_empty(),
"nested XML tags must not yield an ambiguous executable tool call"
);
}

Expand Down
9 changes: 6 additions & 3 deletions crates/openhuman-core/src/agent/message_convert_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,11 @@ fn native_image_round_trip_preserves_adjacent_text_for_claude_code() {
);
let line: serde_json::Value = serde_json::from_slice(&stdin).unwrap();
let content = line["message"]["content"].as_array().unwrap();
assert_eq!(content[0]["text"], "before ");
// The Claude Code bridge separates typed source blocks with newlines;
// retain the text/image/text order rather than collapsing those boundaries.
assert_eq!(content[0]["text"], "before \n");
assert_eq!(content[1]["type"], "image");
assert_eq!(content[2]["text"], " after");
assert_eq!(content[2]["text"], "\n after");
}

#[test]
Expand All @@ -70,7 +72,8 @@ fn native_image_round_trip_preserves_literal_private_marker_text() {
let content = line["message"]["content"].as_array().unwrap();
assert_eq!(content[0]["text"], "literal ");
assert_eq!(content[1]["text"], "[OH_IMAGE:data:image/png;base64,QUJD]");
assert_eq!(content[2]["type"], "image");
assert_eq!(content[2]["text"], "\n");
assert_eq!(content[3]["type"], "image");
}

// An image-only turn must not emit an empty text block (some providers 400
Expand Down
17 changes: 17 additions & 0 deletions crates/openhuman-core/src/agent/orchestration/tools.rs
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,23 @@ mod worker_thread;

pub(crate) use dispatch::DelegationDispatch;

/// Recreate the minimal live TinyAgents carrier for callers that invoke a
/// concrete tool directly inside `with_parent_context`. Normal agent turns
/// always arrive through the typed dispatchers with their original carrier;
/// this compatibility path keeps controller/test callers inside an explicit
/// parent context from losing their recursive delegation authority.
pub(crate) fn ambient_parent_run_context(
kind: &str,
) -> Option<
tinyagents_harness::context::RunContext<crate::agent::tinyagents::host::OpenHumanRunContext>,
> {
crate::agent::harness::current_parent().map(|parent| {
crate::agent::tinyagents::host::OpenHumanRunContext::new()
.with_parent(parent)
.into_tinyagents(tinyagents_harness::context::RunConfig::new(kind))
})
}

pub(crate) use agent_prepare_context::AgentPrepareContextDispatch;
pub use agent_prepare_context::{
run_context_scout, run_context_scout_with_catalog, AgentPrepareContextTool,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -225,12 +225,6 @@ pub(super) async fn run_context_scout_with_catalog_and_workspace(
>,
>,
) -> anyhow::Result<ToolResult> {
let Some(live_parent) = live_parent else {
return Ok(ToolResult::error(
"agent_prepare_context requires a live harness run context.",
));
};
let parent = run_context.parent.clone();
let question = question.trim().to_string();
let focus = focus.map(|s| s.to_string());

Expand All @@ -247,6 +241,13 @@ pub(super) async fn run_context_scout_with_catalog_and_workspace(
));
}

let Some(live_parent) = live_parent else {
return Ok(ToolResult::error(
"agent_prepare_context requires a live harness run context.",
));
};
let parent = run_context.parent.clone();

let registry = match AgentDefinitionRegistry::global() {
Some(reg) => reg,
None => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,18 @@ impl Tool for AgentPrepareContextTool {
_options: ToolCallOptions,
tool_context: Option<&dyn ToolRunContext>,
) -> anyhow::Result<ToolResult> {
if let Some(live_parent) = super::super::ambient_parent_run_context("direct-context-scout")
{
let run_context = live_parent.data.child();
return self
.execute_with_live_parent_context(
args,
tool_context,
run_context,
Some(&live_parent),
)
.await;
}
self.execute_with_parent_context(
args,
tool_context,
Expand Down Expand Up @@ -260,7 +272,13 @@ impl AgentPrepareContextTool {
run_context: crate::agent::tinyagents::host::OpenHumanRunContext,
live_parent: Option<&RunContext<crate::agent::tinyagents::host::OpenHumanRunContext>>,
) -> anyhow::Result<ToolResult> {
let prepared_sources = run_context.prepared_context_sources.as_ref();
let ambient_prepared_sources =
crate::agent::harness::current_agent_context_prepared_sources();
let prepared_sources = if run_context.prepared_context_sources.is_empty() {
ambient_prepared_sources.as_slice()
} else {
run_context.prepared_context_sources.as_ref()
};
if !prepared_sources.is_empty() {
tracing::info!(
target: "agent_prepare_context",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,18 @@ pub(crate) async fn execute_archetype_delegation(
tool_context: Option<&dyn ToolRunContext>,
run_context: crate::agent::tinyagents::host::OpenHumanRunContext,
) -> anyhow::Result<ToolResult> {
if let Some(live_parent) = super::ambient_parent_run_context("direct-archetype-delegation") {
let run_context = live_parent.data.child();
return execute_archetype_delegation_with_live_parent(
agent_id,
tool_name,
args,
tool_context,
run_context,
Some(&live_parent),
)
.await;
}
execute_archetype_delegation_with_live_parent(
agent_id,
tool_name,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -92,8 +92,12 @@ impl Tool for CloseSubagentTool {
_options: ToolCallOptions,
tool_context: Option<&dyn ToolRunContext>,
) -> anyhow::Result<ToolResult> {
self.execute_with_parent_context(args, None, tool_context)
.await
self.execute_with_parent_context(
args,
crate::agent::harness::current_parent(),
tool_context,
)
.await
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,17 @@ impl Tool for ContinueSubagentTool {
_options: ToolCallOptions,
tool_context: Option<&dyn ToolRunContext>,
) -> anyhow::Result<ToolResult> {
if let Some(live_parent) = super::ambient_parent_run_context("direct-continue-subagent") {
let run_context = live_parent.data.child();
return self
.execute_with_live_parent_context(
args,
tool_context,
run_context,
Some(&live_parent),
)
.await;
}
self.execute_with_parent_context(
args,
tool_context,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,18 @@ pub(crate) async fn execute_skill_delegation(
tool_context: Option<&dyn ToolRunContext>,
run_context: crate::agent::tinyagents::host::OpenHumanRunContext,
) -> anyhow::Result<ToolResult> {
if let Some(live_parent) = super::ambient_parent_run_context("direct-skill-delegation") {
let run_context = live_parent.data.child();
return execute_skill_delegation_with_live_parent(
tool_name,
connected_toolkits,
args,
tool_context,
run_context,
Some(&live_parent),
)
.await;
}
execute_skill_delegation_with_live_parent(
tool_name,
connected_toolkits,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -163,13 +163,37 @@ impl Tool for SpawnAsyncSubagentTool {

async fn execute_with_context(
&self,
_args: serde_json::Value,
_options: ToolCallOptions,
_tool_context: Option<&dyn ToolRunContext>,
args: serde_json::Value,
options: ToolCallOptions,
tool_context: Option<&dyn ToolRunContext>,
) -> anyhow::Result<ToolResult> {
Ok(ToolResult::error(
"spawn_async_subagent requires a live harness run context.",
))
if let Some(live_parent) = super::ambient_parent_run_context("direct-async-subagent") {
let detached_data = live_parent.data.detached_child();
let detached_cancellation = detached_data.cancellation.clone();
let detached_parent = live_parent
.child(
RunConfig::new(format!("async-subagent-{}", uuid::Uuid::new_v4())),
detached_data,
)
.map_err(|error| anyhow::anyhow!(error.to_string()))?
.with_cancellation(detached_cancellation);
return self
.execute_with_live_parent_context(
args,
tool_context,
live_parent.data.child(),
detached_parent,
)
.await;
}
self.execute_with_context_inner(
args,
options,
tool_context,
crate::agent::tinyagents::host::OpenHumanRunContext::new(),
None,
)
.await
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,6 @@ impl SpawnAsyncSubagentTool {
>,
>,
) -> anyhow::Result<ToolResult> {
let Some(detached_parent) = detached_parent else {
return Ok(ToolResult::error(
"spawn_async_subagent requires a live harness run context.",
));
};
let agent_id = args
.get("agent_id")
.and_then(|v| v.as_str())
Expand Down Expand Up @@ -63,6 +58,11 @@ impl SpawnAsyncSubagentTool {
"spawn_async_subagent: `prompt` is required",
));
}
let Some(detached_parent) = detached_parent else {
return Ok(ToolResult::error(
"spawn_async_subagent requires a live harness run context.",
));
};

let parent = match run_context.parent.clone() {
Some(parent) => parent,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -88,11 +88,6 @@ pub(crate) async fn execute_spawn_parallel_agents(
run_context: crate::agent::tinyagents::host::OpenHumanRunContext,
live_parent: Option<&RunContext<crate::agent::tinyagents::host::OpenHumanRunContext>>,
) -> anyhow::Result<ToolResult> {
let Some(live_parent) = live_parent else {
return Ok(ToolResult::error(
"spawn_parallel_agents requires a live harness run context.",
));
};
tracing::debug!("[spawn_parallel_agents] execute entry");
let tasks = match parse_parallel_agent_tasks(&args) {
Ok(tasks) => tasks,
Expand All @@ -104,6 +99,11 @@ pub(crate) async fn execute_spawn_parallel_agents(
return Ok(ToolResult::error(message));
}
};
let Some(live_parent) = live_parent else {
return Ok(ToolResult::error(
"spawn_parallel_agents requires a live harness run context.",
));
};
let outcome = run_spawn_parallel_tasks_with_cancellation_and_workspace(
tasks,
cancellation,
Expand Down Expand Up @@ -237,6 +237,16 @@ impl Tool for SpawnParallelAgentsTool {
_options: ToolCallOptions,
tool_context: Option<&dyn ToolRunContext>,
) -> anyhow::Result<ToolResult> {
if let Some(live_parent) = super::ambient_parent_run_context("direct-spawn-parallel") {
return execute_spawn_parallel_agents(
args,
live_parent.cancellation.clone(),
live_parent.workspace.clone(),
live_parent.data.child(),
Some(&live_parent),
)
.await;
}
let workspace_descriptor = tool_context.and_then(|ctx| ctx.workspace().cloned());
execute_spawn_parallel_agents(
args,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,17 @@ impl Tool for SpawnSubagentTool {
_options: ToolCallOptions,
tool_context: Option<&dyn ToolRunContext>,
) -> anyhow::Result<ToolResult> {
if let Some(live_parent) = super::ambient_parent_run_context("direct-spawn-subagent") {
let run_context = live_parent.data.child();
return self
.execute_with_live_parent_context(
args,
tool_context,
run_context,
Some(&live_parent),
)
.await;
}
self.execute_with_parent_context(
args,
tool_context,
Expand Down Expand Up @@ -136,11 +147,6 @@ impl SpawnSubagentTool {
>,
>,
) -> anyhow::Result<ToolResult> {
let Some(live_parent) = live_parent else {
return Ok(ToolResult::error(
"spawn_subagent requires a live harness run context.",
));
};
// ── Argument extraction with back-compat ───────────────────────
let agent_id = args
.get("agent_id")
Expand Down Expand Up @@ -197,6 +203,11 @@ impl SpawnSubagentTool {
if prompt.is_empty() {
return Ok(ToolResult::error("spawn_subagent: `prompt` is required"));
}
let Some(live_parent) = live_parent else {
return Ok(ToolResult::error(
"spawn_subagent requires a live harness run context.",
));
};

let registry = match AgentDefinitionRegistry::global() {
Some(reg) => reg,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,17 @@ impl Tool for SpawnWorkerThreadTool {
_options: ToolCallOptions,
tool_context: Option<&dyn ToolRunContext>,
) -> anyhow::Result<ToolResult> {
if let Some(live_parent) = super::ambient_parent_run_context("direct-spawn-worker") {
let run_context = live_parent.data.child();
return self
.execute_with_live_parent_context(
args,
tool_context,
run_context,
Some(&live_parent),
)
.await;
}
self.execute_with_parent_context(
args,
tool_context,
Expand Down
Loading
Loading