mirror of
https://github.com/feigeCode/navop.git
synced 2026-09-22 16:01:30 +00:00
309 lines
11 KiB
Rust
309 lines
11 KiB
Rust
use agent_runtime::tools::{
|
|
ObservationData, permission_policy_for_tool_mode, runtime_tool_invocation_from_call,
|
|
};
|
|
use agent_runtime::{
|
|
ResourceContext, ResourceKind, ResourceRef, ResourceScope, RiskLevel, SessionId, SkillContext,
|
|
ToolCall, ToolCallId, ToolExecutionMode, ToolName, ToolSpec, TurnId,
|
|
};
|
|
use serde_json::json;
|
|
use std::sync::atomic::{AtomicBool, Ordering};
|
|
use std::sync::{Arc, Mutex};
|
|
use tool_runtime::{
|
|
RuntimeToolDescriptor, ToolAdapter, ToolAnnotations, ToolContext, ToolDescriptor, ToolHandler,
|
|
ToolId, ToolMode, ToolOrigin, ToolRegistry, ToolResult, ToolTargetSpec,
|
|
};
|
|
|
|
#[test]
|
|
fn runtime_descriptor_converts_to_agent_tool_spec() {
|
|
let descriptor = runtime_descriptor("db.query", ToolAnnotations::read_only("Query"));
|
|
|
|
let spec = ToolSpec::from_runtime_descriptor(&descriptor);
|
|
|
|
assert_eq!(ToolName::new("db.query"), spec.name);
|
|
assert_eq!("Run query", spec.description);
|
|
assert_eq!(json!({ "type": "object" }), spec.parameters);
|
|
assert_eq!(RiskLevel::Read, spec.risk);
|
|
}
|
|
|
|
#[test]
|
|
fn runtime_descriptor_maps_high_risk_annotations() {
|
|
let descriptor = runtime_descriptor(
|
|
"db.exec",
|
|
ToolAnnotations::mutating("Exec").with_risk(tool_runtime::RiskLevel::High),
|
|
);
|
|
|
|
let spec = ToolSpec::from_runtime_descriptor(&descriptor);
|
|
|
|
assert_eq!(RiskLevel::High, spec.risk);
|
|
}
|
|
|
|
#[test]
|
|
fn resource_context_converts_to_runtime_resource_pool() {
|
|
let context = ResourceContext::new()
|
|
.with_resource(
|
|
ResourceRef::new("db-prod", ResourceKind::Mysql, "prod db")
|
|
.with_scope(ResourceScope::new("database", "Database", "ai_app")),
|
|
)
|
|
.with_resource(ResourceRef::new("ssh-prod", ResourceKind::Ssh, "prod ssh"));
|
|
|
|
let pool = context.to_runtime_resource_pool();
|
|
|
|
assert_eq!(
|
|
Some(&tool_runtime::ResourceId::new("db-prod")),
|
|
pool.default_target.as_ref()
|
|
);
|
|
assert_eq!("prod db", pool.resolve_target("prod db").unwrap().label);
|
|
assert_eq!("ai_app", pool.resources[0].scopes[0].value);
|
|
}
|
|
|
|
#[test]
|
|
fn tool_execution_mode_maps_to_runtime_permission_profile() {
|
|
assert_eq!(
|
|
tool_runtime::PermissionProfile::Safe,
|
|
permission_policy_for_tool_mode(ToolExecutionMode::ReadOnly).mode,
|
|
);
|
|
assert_eq!(
|
|
tool_runtime::PermissionProfile::Confirm,
|
|
permission_policy_for_tool_mode(ToolExecutionMode::Manual).mode,
|
|
);
|
|
let auto = permission_policy_for_tool_mode(ToolExecutionMode::Auto);
|
|
assert_eq!(tool_runtime::PermissionProfile::Auto, auto.mode);
|
|
assert_eq!(tool_runtime::OperationPolicy::Allow, auto.high_risk_policy);
|
|
}
|
|
|
|
#[test]
|
|
fn tool_call_converts_to_runtime_invocation_with_resource_pool() {
|
|
let context = ResourceContext::new().with_resource(ResourceRef::new(
|
|
"ssh-prod",
|
|
ResourceKind::Ssh,
|
|
"prod ssh",
|
|
));
|
|
let call = ToolCall::new("ssh.exec", json!({ "command": "df -h" }))
|
|
.with_call_id(ToolCallId::from_string("call-1".to_string()));
|
|
|
|
let invocation = runtime_tool_invocation_from_call(
|
|
&call,
|
|
&context,
|
|
ToolExecutionMode::Manual,
|
|
SessionId::from_string("session-1".to_string()),
|
|
TurnId::from_string("turn-1".to_string()),
|
|
);
|
|
|
|
assert_eq!(tool_runtime::ToolId::new("ssh_exec"), invocation.tool_id);
|
|
assert_eq!(json!({ "command": "df -h" }), invocation.arguments);
|
|
assert_eq!(tool_runtime::ToolCaller::Agent, invocation.caller);
|
|
assert_eq!(
|
|
tool_runtime::PermissionProfile::Confirm,
|
|
invocation.permission.mode
|
|
);
|
|
assert_eq!(Some("session-1".to_string()), invocation.audit.session_id);
|
|
assert_eq!(Some("turn-1".to_string()), invocation.audit.turn_id);
|
|
assert_eq!(Some("call-1".to_string()), invocation.audit.request_id);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn runtime_registry_exposes_agent_specs_with_canonical_runtime_id() {
|
|
let registry = ToolRegistry::new(vec![Arc::new(RuntimeEchoTool::new("db.query"))]);
|
|
let agent_registry = agent_runtime::tools::tool_runtime_agent_tool_registry(
|
|
registry,
|
|
tool_runtime::ToolAdapter::FunctionCalling,
|
|
);
|
|
|
|
let specs = agent_registry.specs(&ResourceContext::new());
|
|
|
|
assert_eq!(1, specs.len());
|
|
assert_eq!("db_query", specs[0].name.as_str());
|
|
assert_eq!("Echo input", specs[0].description);
|
|
assert_eq!(json!({ "type": "object" }), specs[0].parameters);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn runtime_registry_agent_tool_executes_canonical_runtime_tool() {
|
|
let handler = Arc::new(RuntimeEchoTool::new("db.query"));
|
|
let registry = ToolRegistry::new(vec![handler.clone()]);
|
|
let agent_registry = agent_runtime::tools::tool_runtime_agent_tool_registry(
|
|
registry,
|
|
tool_runtime::ToolAdapter::FunctionCalling,
|
|
);
|
|
let tool = agent_registry
|
|
.get(&ToolName::new("db.query"))
|
|
.expect("runtime tool should be exposed to agent");
|
|
|
|
let observation = tool
|
|
.execute(agent_invocation("db.query", json!({ "message": "hello" })))
|
|
.await
|
|
.expect("runtime tool call should execute");
|
|
|
|
assert!(observation.success);
|
|
assert_eq!("db_query", observation.tool_name.as_str());
|
|
assert_eq!(json!({ "message": "hello" }), handler.last_input());
|
|
assert_eq!(
|
|
json!({ "message": "hello" }),
|
|
observation_data_json(&observation)
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn runtime_registry_agent_tool_forwards_invocation_cancellation() {
|
|
let handler = Arc::new(RuntimeEchoTool::new("terminal.exec"));
|
|
let registry = ToolRegistry::new(vec![handler.clone()]);
|
|
let agent_registry = agent_runtime::tools::tool_runtime_agent_tool_registry(
|
|
registry,
|
|
tool_runtime::ToolAdapter::FunctionCalling,
|
|
);
|
|
let tool = agent_registry
|
|
.get(&ToolName::new("terminal.exec"))
|
|
.expect("runtime tool should be exposed to agent");
|
|
let invocation = agent_invocation("terminal.exec", json!({ "command": "pwd" }));
|
|
invocation.cancellation.cancel();
|
|
|
|
tool.execute(invocation)
|
|
.await
|
|
.expect("runtime tool call should execute");
|
|
|
|
assert!(handler.last_cancellation_state());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn runtime_registry_allocates_unique_stable_names_for_sanitized_collisions() {
|
|
let underscore = Arc::new(RuntimeEchoTool::new("sample_echo"));
|
|
let dotted = Arc::new(RuntimeEchoTool::new("sample.echo"));
|
|
let registry = ToolRegistry::new(vec![underscore.clone(), dotted.clone()]);
|
|
let agent_registry = agent_runtime::tools::tool_runtime_agent_tool_registry(
|
|
registry,
|
|
tool_runtime::ToolAdapter::FunctionCalling,
|
|
);
|
|
|
|
assert_eq!(
|
|
vec!["sample_echo".to_string(), "sample_echo_2".to_string()],
|
|
agent_registry
|
|
.names()
|
|
.into_iter()
|
|
.map(|name| name.to_string())
|
|
.collect::<Vec<_>>()
|
|
);
|
|
assert_eq!(
|
|
vec!["sample_echo".to_string(), "sample_echo_2".to_string()],
|
|
agent_registry
|
|
.specs(&ResourceContext::new())
|
|
.into_iter()
|
|
.map(|spec| spec.name.to_string())
|
|
.collect::<Vec<_>>()
|
|
);
|
|
|
|
agent_registry
|
|
.get(&ToolName::new("sample_echo"))
|
|
.expect("dotted runtime tool should use the base public name")
|
|
.execute(agent_invocation(
|
|
"sample_echo",
|
|
json!({ "source": "dotted" }),
|
|
))
|
|
.await
|
|
.expect("dotted runtime tool should execute");
|
|
agent_registry
|
|
.get(&ToolName::new("sample_echo_2"))
|
|
.expect("underscore runtime tool should use the suffixed public name")
|
|
.execute(agent_invocation(
|
|
"sample_echo_2",
|
|
json!({ "source": "underscore" }),
|
|
))
|
|
.await
|
|
.expect("underscore runtime tool should execute");
|
|
|
|
assert_eq!(json!({ "source": "dotted" }), dotted.last_input());
|
|
assert_eq!(json!({ "source": "underscore" }), underscore.last_input());
|
|
}
|
|
|
|
fn runtime_descriptor(id: &str, annotations: ToolAnnotations) -> RuntimeToolDescriptor {
|
|
RuntimeToolDescriptor {
|
|
id: ToolId::new(id),
|
|
title: "Query".to_string(),
|
|
description: "Run query".to_string(),
|
|
input_schema: json!({ "type": "object" }),
|
|
output_schema: json!({ "type": "object" }),
|
|
permissions: Vec::new(),
|
|
mode: ToolMode::Deterministic,
|
|
adapters: vec![ToolAdapter::FunctionCalling],
|
|
annotations,
|
|
target: ToolTargetSpec::default(),
|
|
origin: ToolOrigin::Database,
|
|
aliases: Vec::new(),
|
|
}
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
struct RuntimeEchoTool {
|
|
descriptor: ToolDescriptor,
|
|
last_input: Arc<Mutex<Option<serde_json::Value>>>,
|
|
last_cancellation_state: Arc<AtomicBool>,
|
|
}
|
|
|
|
impl RuntimeEchoTool {
|
|
fn new(id: &str) -> Self {
|
|
Self {
|
|
descriptor: ToolDescriptor {
|
|
id: id.to_string(),
|
|
title: "Echo".to_string(),
|
|
description: "Echo input".to_string(),
|
|
input_schema: json!({ "type": "object" }),
|
|
output_schema: json!({ "type": "object" }),
|
|
permissions: Vec::new(),
|
|
mode: ToolMode::Deterministic,
|
|
adapters: vec![tool_runtime::ToolAdapter::FunctionCalling],
|
|
annotations: ToolAnnotations::read_only("Echo"),
|
|
},
|
|
last_input: Arc::new(Mutex::new(None)),
|
|
last_cancellation_state: Arc::new(AtomicBool::new(false)),
|
|
}
|
|
}
|
|
|
|
fn last_input(&self) -> serde_json::Value {
|
|
self.last_input
|
|
.lock()
|
|
.expect("last input lock")
|
|
.clone()
|
|
.expect("runtime tool should receive input")
|
|
}
|
|
|
|
fn last_cancellation_state(&self) -> bool {
|
|
self.last_cancellation_state.load(Ordering::Acquire)
|
|
}
|
|
}
|
|
|
|
impl ToolHandler for RuntimeEchoTool {
|
|
fn descriptor(&self) -> ToolDescriptor {
|
|
self.descriptor.clone()
|
|
}
|
|
|
|
fn call(&self, input: serde_json::Value, context: ToolContext) -> tool_runtime::ToolFuture {
|
|
*self.last_input.lock().expect("last input lock") = Some(input.clone());
|
|
self.last_cancellation_state
|
|
.store(context.cancellation.is_cancelled(), Ordering::Release);
|
|
Box::pin(async move { Ok(ToolResult::structured(input)) })
|
|
}
|
|
}
|
|
|
|
fn agent_invocation(
|
|
tool_name: &str,
|
|
arguments: serde_json::Value,
|
|
) -> agent_runtime::tools::ToolInvocation {
|
|
agent_runtime::tools::ToolInvocation {
|
|
session_id: SessionId::from_string("session-1"),
|
|
turn_id: TurnId::from_string("turn-1"),
|
|
call_id: ToolCallId::from_string("call-1"),
|
|
tool_name: ToolName::new(tool_name),
|
|
arguments,
|
|
resource_id: None,
|
|
resources: ResourceContext::new(),
|
|
skills: SkillContext::new(),
|
|
cancellation: tokio_util::sync::CancellationToken::new(),
|
|
}
|
|
}
|
|
|
|
fn observation_data_json(observation: &agent_runtime::tools::ToolObservation) -> serde_json::Value {
|
|
match &observation.data {
|
|
ObservationData::Json(value) => value.clone(),
|
|
other => panic!("expected json observation data, got {other:?}"),
|
|
}
|
|
}
|