Skip to content
Open
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
195 changes: 193 additions & 2 deletions src/openhuman/agent/harness/session/agent_tool_exec.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,55 @@ pub(super) struct AgentToolExecCtx<'a> {
pub artifact_store: Option<&'a ToolResultArtifactStore>,
}

fn sorted_tool_names<'a>(names: impl IntoIterator<Item = &'a str>) -> Vec<String> {
let mut names = names.into_iter().map(str::to_string).collect::<Vec<_>>();
names.sort();
names.dedup();
names
}

fn format_available_tools_hint(available_tool_names: &[String]) -> String {
if available_tool_names.is_empty() {
"No tools are currently available.".to_string()
} else {
format!("Available tools: {}", available_tool_names.join(", "))
}
}

fn format_unknown_tool_message(tool_name: &str, available_tool_names: &[String]) -> String {
format!(
"Unknown tool: {tool_name}. {}",
format_available_tools_hint(available_tool_names)
)
}

fn available_tool_names_for_ctx(ctx: &AgentToolExecCtx<'_>) -> Vec<String> {
let mut names = Vec::new();
let mut filtered_out = Vec::new();
for tool in ctx.tools {
let name = tool.name();
let visible_by_scope =
ctx.visible_tool_names.is_empty() || ctx.visible_tool_names.contains(name);
let allowed_by_policy = ctx.tool_policy_session.is_allowed(name);

if visible_by_scope && allowed_by_policy {
names.push(name);
} else if visible_by_scope {
filtered_out.push(name.to_string());
}
}

if !filtered_out.is_empty() {
log::debug!(
"[agent] filtered unavailable tools from unknown-tool hint channel={} tools={:?}",
ctx.event_channel,
filtered_out
);
}

sorted_tool_names(names.into_iter())
}

/// Execute one parsed tool call end-to-end with the Agent's semantics, emitting
/// `ToolCallStarted` / `ToolCallCompleted` through `progress`. Returns the
/// result (for history formatting) + the call record (for post-turn hooks).
Expand Down Expand Up @@ -98,8 +147,13 @@ pub(super) async fn run_agent_tool_call(
"[agent] blocked tool call '{}' — not in visible tool set",
call.name
);
let available = available_tool_names_for_ctx(ctx);
(
format!("Tool '{}' is not available to this agent", call.name),
format!(
"Tool '{}' is not available to this agent. {}",
call.name,
format_available_tools_hint(&available)
),
false,
)
} else if let Some(tool) = ctx.tools.iter().find(|t| t.name() == call.name) {
Expand Down Expand Up @@ -298,7 +352,8 @@ pub(super) async fn run_agent_tool_call(
}
}
} else {
(format!("Unknown tool: {}", call.name), false)
let available = available_tool_names_for_ctx(ctx);
(format_unknown_tool_message(&call.name, &available), false)
};

// Stage 1a — content-aware compaction via the TokenJuice content router.
Expand Down Expand Up @@ -396,13 +451,19 @@ mod tests {
use crate::openhuman::agent::tool_policy::AllowAllToolPolicy;
use crate::openhuman::agent_tool_policy::ToolPolicyEngine;
use crate::openhuman::tools::traits::{ToolResult, ToolTimeout};
use crate::openhuman::tools::PermissionLevel;
use async_trait::async_trait;
use serde_json::json;
use std::collections::{HashMap, HashSet};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Duration;

struct HangingTool;
struct PermissionedTool {
name: &'static str,
permission: PermissionLevel,
}

struct TestProgress {
completed: AtomicUsize,
timeout_completions: AtomicUsize,
Expand Down Expand Up @@ -450,6 +511,136 @@ mod tests {
}
}

#[async_trait]
impl Tool for PermissionedTool {
fn name(&self) -> &str {
self.name
}

fn description(&self) -> &str {
"test permissioned tool"
}

fn parameters_schema(&self) -> serde_json::Value {
json!({ "type": "object", "properties": {} })
}

async fn execute(&self, _args: serde_json::Value) -> anyhow::Result<ToolResult> {
Ok(ToolResult::success("ok"))
}

fn permission_level(&self) -> PermissionLevel {
self.permission
}
}

#[tokio::test(flavor = "current_thread")]
async fn session_tool_executor_unknown_tool_lists_available_tools() {
let tools: Vec<Box<dyn Tool>> = vec![Box::new(HangingTool)];
let visible_tool_names = HashSet::new();
let policy_session = ToolPolicyEngine::build_session(
"context_scout",
"web",
"test",
&HashMap::new(),
&tools,
&visible_tool_names,
);
let tool_policy = AllowAllToolPolicy;
let ctx = AgentToolExecCtx {
tools: &tools,
visible_tool_names: &visible_tool_names,
tool_policy_session: &policy_session,
tool_policy: &tool_policy,
payload_summarizer: None,
event_session_id: "session-1",
event_channel: "web",
agent_definition_id: "context_scout",
prefer_markdown: false,
budget_bytes: 4096,
compaction_enabled: false,
tokenjuice_compression: crate::openhuman::tokenjuice::AgentTokenjuiceCompression::Off,
artifact_store: None,
};
let call = ParsedToolCall {
name: "search_files".to_string(),
arguments: json!({}),
tool_call_id: Some("call-unknown".to_string()),
};
let progress = TestProgress {
completed: AtomicUsize::new(0),
timeout_completions: AtomicUsize::new(0),
};

let (result, record) = run_agent_tool_call(&ctx, &progress, &call, 0).await;

assert!(!result.success);
assert!(result.output.contains("Unknown tool: search_files"));
assert!(result.output.contains("Available tools: memory_tree"));
assert!(!record.success);
assert_eq!(progress.completed.load(Ordering::Relaxed), 1);
}

#[tokio::test(flavor = "current_thread")]
async fn session_tool_executor_unknown_tool_hint_uses_policy_filtered_tools() {
let tools: Vec<Box<dyn Tool>> = vec![
Box::new(PermissionedTool {
name: "read_notes",
permission: PermissionLevel::ReadOnly,
}),
Box::new(PermissionedTool {
name: "write_notes",
permission: PermissionLevel::Write,
}),
];
let visible_tool_names = HashSet::new();
let channel_permissions = HashMap::from([("web".to_string(), "readonly".to_string())]);
let policy_session = ToolPolicyEngine::build_session(
"context_scout",
"web",
"test",
&channel_permissions,
&tools,
&visible_tool_names,
);
let tool_policy = AllowAllToolPolicy;
let ctx = AgentToolExecCtx {
tools: &tools,
visible_tool_names: &visible_tool_names,
tool_policy_session: &policy_session,
tool_policy: &tool_policy,
payload_summarizer: None,
event_session_id: "session-1",
event_channel: "web",
agent_definition_id: "context_scout",
prefer_markdown: false,
budget_bytes: 4096,
compaction_enabled: false,
tokenjuice_compression: crate::openhuman::tokenjuice::AgentTokenjuiceCompression::Off,
artifact_store: None,
};
let call = ParsedToolCall {
name: "missing_tool".to_string(),
arguments: json!({}),
tool_call_id: Some("call-unknown".to_string()),
};
let progress = TestProgress {
completed: AtomicUsize::new(0),
timeout_completions: AtomicUsize::new(0),
};

let (result, _record) = run_agent_tool_call(&ctx, &progress, &call, 0).await;

assert!(!result.success);
assert!(result.output.contains("Unknown tool: missing_tool"));
assert!(result.output.contains("Available tools: read_notes"));
assert!(
!result.output.contains("write_notes"),
"unknown-tool hints must not advertise policy-denied tools: {}",
result.output
);
}

#[tokio::test(flavor = "current_thread")]
async fn session_tool_executor_enforces_tool_timeout_policy() {
let tools: Vec<Box<dyn Tool>> = vec![Box::new(HangingTool)];
Expand Down
7 changes: 6 additions & 1 deletion src/openhuman/agent/harness/session/turn/core.rs
Original file line number Diff line number Diff line change
Expand Up @@ -928,7 +928,11 @@ impl Agent {
temperature,
messages,
tools: self.tools.clone(),
visible_tool_names: self.visible_tool_names.clone(),
visible_tool_names: self
.visible_tool_specs
.iter()
.map(|spec| spec.name.clone())
.collect(),
max_iterations,
on_progress: self.on_progress.clone(),
context_window,
Expand All @@ -942,6 +946,7 @@ impl Agent {
session_id: self.event_session_id.clone(),
channel: self.event_channel().to_string(),
agent_definition_id: self.agent_definition_id.clone(),
visibility_filter_active: !self.visible_tool_names.is_empty(),
}),
}),
)
Expand Down
59 changes: 51 additions & 8 deletions src/openhuman/tinyagents/middleware.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ use tinyagents::harness::runtime::AgentHarness;
use tinyagents::harness::steering::{SteeringCommand, SteeringHandle};
use tinyagents::harness::tool::{ToolCall as TaToolCall, ToolResult as TaToolResult};

use super::tools::UNKNOWN_TOOL_SENTINEL;
use super::tools::{format_available_tools_hint, UNKNOWN_TOOL_SENTINEL};
use crate::openhuman::agent::harness::payload_summarizer::PayloadSummarizer;
use crate::openhuman::approval::{
redact_args, summarize_action, ApprovalGate, ExecutionOutcome, GateOutcome,
Expand Down Expand Up @@ -423,6 +423,18 @@ impl ToolOutputMiddleware {
.find(|t| t.name() == name)
.and_then(|t| t.max_result_size_chars())
}

fn is_recovery_guidance(result: &TaToolResult) -> bool {
if result.error.is_none() {
return false;
}
let content = result.content.as_str();
content.starts_with("Unknown tool: ")
|| (content.starts_with("Tool '")
&& content.contains(" is not available to this agent"))
|| (content.starts_with("Error: tool '")
&& content.contains(" is not available to this sub-agent"))
}
}

#[async_trait]
Expand Down Expand Up @@ -481,7 +493,7 @@ impl Middleware<()> for ToolOutputMiddleware {
// 3. Shared byte-cap backstop — truncate at a UTF-8 boundary with a marker.
// Only for tools with no cap of their own (a capped tool already bounded
// itself above; stacking the two markers would double-truncate).
if tool_cap.is_none() && self.budget_bytes > 0 {
if tool_cap.is_none() && self.budget_bytes > 0 && !Self::is_recovery_guidance(result) {
let (capped, outcome) =
apply_tool_result_budget(std::mem::take(&mut result.content), self.budget_bytes);
if outcome.truncated {
Expand Down Expand Up @@ -674,11 +686,13 @@ pub struct ToolPolicyMiddleware {
/// `Tool` can be resolved for its generated-tool runtime context and its
/// per-call permission level.
tool_sets: Vec<Arc<Vec<Box<dyn Tool>>>>,
/// The advertised (visible) tool-name whitelist. Non-empty = restricted; a
/// call outside it is "not available to this agent" (the engine's first gate).
/// A non-visible tool is never registered, so it reaches here rewritten onto
/// the recovery sentinel — its original name rides `requested_tool`.
/// The advertised/callable tool-name whitelist for this run.
visible_tool_names: HashSet<String>,
/// Whether the user/agent explicitly scoped the visible tool set. Keep this
/// separate from `visible_tool_names`: channel policy can narrow callable
/// names even when no explicit visibility filter was configured, and those
/// unknown calls should still reach the generic unknown-tool sentinel.
visibility_filter_active: bool,
session_id: String,
channel: String,
agent_definition_id: String,
Expand All @@ -690,6 +704,7 @@ impl ToolPolicyMiddleware {
session: crate::openhuman::agent_tool_policy::ToolPolicySession,
tool_sets: Vec<Arc<Vec<Box<dyn Tool>>>>,
visible_tool_names: HashSet<String>,
visibility_filter_active: bool,
session_id: String,
channel: String,
agent_definition_id: String,
Expand All @@ -699,6 +714,7 @@ impl ToolPolicyMiddleware {
session,
tool_sets,
visible_tool_names,
visibility_filter_active,
session_id,
channel,
agent_definition_id,
Expand Down Expand Up @@ -749,6 +765,12 @@ impl ToolPolicyMiddleware {
.find(|t| t.name() == name)
.and_then(|t| t.generated_runtime_context(args))
}

fn available_tools_hint(&self) -> String {
let mut names = self.visible_tool_names.iter().cloned().collect::<Vec<_>>();
names.sort();
format_available_tools_hint(&names)
}
}

#[async_trait]
Expand Down Expand Up @@ -777,14 +799,17 @@ impl ToolMiddleware<()> for ToolPolicyMiddleware {
// unknown call with no visibility restriction still falls through to the
// sentinel's "Unknown tool" result.
if call.name == UNKNOWN_TOOL_SENTINEL {
if !self.visible_tool_names.is_empty() {
if self.visibility_filter_active {
let requested = call
.arguments
.get("requested_tool")
.and_then(|v| v.as_str())
.unwrap_or_default();
if !requested.is_empty() && !self.visible_tool_names.contains(requested) {
let content = format!("Tool '{requested}' is not available to this agent");
let content = format!(
"Tool '{requested}' is not available to this agent. {} Use one of the advertised tools, or answer directly.",
self.available_tools_hint()
);
return Ok(MiddlewareToolOutcome::Result(TaToolResult {
call_id: call.id,
name: call.name,
Expand Down Expand Up @@ -1481,6 +1506,24 @@ mod tests {
);
}

#[tokio::test]
async fn tool_output_preserves_unknown_tool_guidance_under_small_budget() {
let mw = ToolOutputMiddleware {
budget_bytes: 96,
payload_summarizer: None,
tool_sets: vec![],
};
let content = "Unknown tool: hidden_tool. Available tools: cli_only, round17_boom, round17_error, round17_ok. Use one of the advertised tools, or answer directly.";
let mut result = tool_result(UNKNOWN_TOOL_SENTINEL, content);
result.error = Some(content.to_string());

mw.after_tool(&mut ctx(), &(), &mut result).await.unwrap();

assert!(result.content.contains("hidden_tool"));
assert!(result.content.contains("Available tools: cli_only"));
assert!(!result.content.contains("truncated by tool_result_budget"));
}

#[tokio::test]
async fn tool_output_leaves_small_results_untouched() {
let mw = ToolOutputMiddleware {
Expand Down
Loading
Loading