blob: bbfd14e5ac81e3470a68476667f2ddda8b395fb7 [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
//! Explicit service-to-tool projection. Imports describe bindings, not grants.
mod approval;
mod computer;
pub(crate) mod image;
mod shell;
mod title;
mod web;
use crate::contract::{ServiceTool, ToolConfirmation, ToolInput, ToolResult};
use futures_util::FutureExt;
use maka_agent_facts::execution::EditingTools;
use maka_agent_facts::language::Language;
use maka_agent_facts::tool_call::ToolRejection;
use maka_agent_facts::tool_output::ToolOutput;
use maka_agent_facts::tool_output::ToolSuccess;
use maka_agent_facts::tools::PreparationFuture;
use maka_agent_facts::tools::PreparedEffect;
use maka_agent_facts::tools::ToolCallContext;
use maka_agent_facts::tools::ToolDefinition;
use maka_agent_facts::tools::ToolError;
use maka_agent_facts::tools::ToolHandler;
use maka_agent_facts::tools::ToolNesting;
use maka_agent_facts::tools::ToolPreparer;
use maka_agent_facts::tools::ToolRegistration;
use maka_agent_facts::tools::ToolSemantics;
use maka_capabilities::call::Attachments;
use maka_capabilities::call::Identity;
use maka_event_log::EventLog;
use maka_host_runtime::application::NativeServices;
use maka_plugin_core::{
contract::{Interface, MethodMode},
operation::{OperationCleanup, OperationOutcome, OperationReply, Receipt},
};
use maka_tools::ToolCatalog;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use sha2::{Digest, Sha256};
use std::{panic::AssertUnwindSafe, sync::Arc};
use tokio_util::sync::CancellationToken;
use uuid::Uuid;
/// Keep model tool schemas object-rooted while preserving each typed request.
#[derive(Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
pub(crate) struct ToolRequest<T> {
pub(crate) request: T,
}
#[derive(Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct SelectedTool {
selection: ServiceTool,
binding: String,
}
pub(crate) struct Catalog {
pub tools: ToolCatalog,
pub basis: Vec<SelectedTool>,
/// Derived from actual bound contracts and methods, never display names.
pub editing: Vec<(String, EditingTools)>,
/// Planning eligibility follows the actual service contract and method.
pub planning: Vec<String>,
}
pub(crate) fn catalog(
services: NativeServices,
store: Arc<EventLog>,
configuration: Arc<maka_config::ConfigurationStore>,
selected: &[ServiceTool],
additional: Vec<ToolRegistration>,
workers: tokio_util::task::TaskTracker,
) -> Result<Catalog, String> {
let mut basis = Vec::new();
let file_contract = maka_workspace_api::files::contract();
let editing: Vec<_> = selected
.iter()
.filter_map(|selection| {
let (_, bindings) = services
.imports()
.find(|(name, _)| *name == selection.import)?;
let [binding] = bindings else {
return None;
};
if binding.contract() != &file_contract {
return None;
}
let style = match selection.method.as_str() {
"write" | "edit" => EditingTools::Structured,
"applyPatch" => EditingTools::ApplyPatch,
_ => return None,
};
Some((selection.name.clone(), style))
})
.collect();
let mut planning = Vec::new();
let questions_contract = maka_interactions_api::questions::contract();
let web_contract = maka_web_api::contract();
let mut web_bindings = Vec::new();
let mut computer_bindings = Vec::new();
let mut shell_bindings = Vec::new();
let skill_contract = maka_skills_api::contract();
let tool_names: Arc<Vec<(String, Option<EditingTools>)>> = Arc::new(
selected
.iter()
.map(|tool| tool.name.clone())
.chain(additional.iter().map(|tool| tool.definition.name.clone()))
.map(|name| {
let style = editing
.iter()
.find(|(editor, _)| *editor == name)
.map(|(_, style)| *style);
(name, style)
})
.collect(),
);
let registrations = selected
.iter()
.map(|selection| {
let bindings = services
.imports()
.find(|(name, _)| *name == selection.import)
.map(|(_, bindings)| bindings)
.ok_or("agent_tool_import_unavailable")?;
let [binding] = bindings else {
return Err("agent_tool_import_ambiguous".to_owned());
};
let Interface::Service { methods } = &binding.contract().interface else {
return Err("agent_tool_contract_invalid".to_owned());
};
let method = methods
.get(&selection.method)
.ok_or("agent_tool_method_unavailable")?;
// The actual binding owns the title meaning, never a tool alias.
let title_kind = if binding.contract() == &file_contract {
match selection.method.as_str() {
"readText" | "readImage" => Some(title::Kind::Read),
"write" => Some(title::Kind::Write),
"edit" => Some(title::Kind::Edit),
"applyPatch" => Some(title::Kind::Patch),
"glob" => Some(title::Kind::Glob),
"grep" => Some(title::Kind::Grep),
_ => None,
}
} else if binding.contract() == &web_contract {
match selection.method.as_str() {
"fetch" => Some(title::Kind::Fetch),
"search" => Some(title::Kind::Search),
_ => None,
}
} else {
None
};
if (binding.contract() == &file_contract
&& matches!(
selection.method.as_str(),
"readText" | "readImage" | "glob" | "grep"
))
|| (binding.contract() == &web_contract
&& matches!(selection.method.as_str(), "fetch" | "search"))
|| (binding.contract() == &questions_contract && selection.method == "ask")
{
planning.push(selection.name.clone());
}
if !method.resources.is_empty()
|| match selection.result {
ToolResult::Json => method.mode == MethodMode::Subscription,
ToolResult::Image => method.mode != MethodMode::Subscription,
ToolResult::Computer => {
method.mode != MethodMode::Mutation
|| binding.contract() != &maka_workspace_api::computer::contract()
|| selection.nestable
}
}
{
return Err("agent_tool_method_unsupported".to_owned());
}
if selection.description.trim().is_empty() {
return Err("agent_tool_description_invalid".to_owned());
}
if let Some(key) = &selection.presentation {
maka_agent_facts::display::validate_presentation(key)
.map_err(|_| "agent_tool_presentation_invalid".to_owned())?;
}
if let Some(title) = &selection.title {
title
.validate()
.map_err(|_| "agent_tool_title_invalid".to_owned())?;
}
let input_schema = match selection.input {
ToolInput::Shell => {
if binding.contract() != &maka_workspace_api::processes::contract()
|| selection.method != "execute"
|| selection.result != ToolResult::Json
{
return Err("agent_shell_tool_contract_invalid".into());
}
shell::schema(method.input.as_ref().clone(), true)
}
ToolInput::Json => {
if binding.contract() == &maka_web_api::contract()
&& maka_web_api::contract::model_input_schema(&selection.method).is_some()
{
return Err("agent_web_tool_projection_required".into());
}
if binding.contract() == &skill_contract
&& maka_skills_api::model_input_schema(&selection.method).is_some()
{
return Err("agent_skill_tool_projection_required".into());
}
method.input.as_ref().clone()
}
ToolInput::Web => {
if binding.contract() != &maka_web_api::contract()
|| selection.result != ToolResult::Json
{
return Err("agent_web_tool_contract_invalid".into());
}
maka_web_api::contract::model_input_schema(&selection.method)
.ok_or("agent_web_tool_method_invalid")?
}
ToolInput::Skill => {
if binding.contract() != &skill_contract || selection.result != ToolResult::Json
{
return Err("agent_skill_tool_contract_invalid".into());
}
maka_skills_api::model_input_schema(&selection.method)
.ok_or("agent_skill_tool_method_invalid")?
}
};
basis.push(SelectedTool {
selection: selection.clone(),
binding: binding.execution_basis().into(),
});
let service = Service {
services: services.clone(),
store: store.clone(),
binding: binding.execution_basis().into(),
target: format!(
"{} · {} v{} · {} / {} · {}",
selection.import,
binding.contract().name,
binding.contract().version,
serde_json::to_string(binding.provider().instance())
.expect("instance identity"),
binding.export(),
binding
.provider()
.remote_target()
.map_or("local", |target| target.deployment.as_str())
),
tool_names: tool_names.clone(),
selection: selection.clone(),
title_kind,
mode: method.mode,
web_basis: None,
};
if selection.result == ToolResult::Computer {
computer_bindings.push((
selection.name.clone(),
Arc::new(computer::Binding {
service: service.clone(),
workers: workers.clone(),
}),
));
}
if selection.input == ToolInput::Shell {
shell_bindings.push((
selection.name.clone(),
Arc::new(shell::Binding {
service: service.clone(),
configuration: configuration.clone(),
input: method.input.as_ref().clone(),
}),
));
}
if selection.input == ToolInput::Web {
web_bindings.push((
selection.name.clone(),
Arc::new(web::Binding {
service: service.clone(),
workers: workers.clone(),
}),
));
}
let mut description = selection.description.clone();
if binding.contract() == &file_contract && selection.method == "readText" {
description.push_str("\n\nReads Session attachment and archived tool-result resources. Pass page.next unchanged to this tool to continue.");
}
Ok(ToolRegistration {
definition: ToolDefinition {
provider: None,
name: selection.name.clone(),
description,
input_schema,
output_schema: match selection.result {
ToolResult::Json => Some(method.output.as_ref().clone()),
ToolResult::Image | ToolResult::Computer => None,
},
freeform: None,
},
nesting: if selection.nestable {
ToolNesting::Nestable
} else {
ToolNesting::DirectOnly
},
semantics: if method.mode == MethodMode::Mutation {
ToolSemantics::ExclusiveStep
} else {
ToolSemantics::Parallel
},
handler: ToolHandler::Prepared(Arc::new(service)),
})
})
.collect::<Result<Vec<_>, String>>()?;
let mut catalog = ToolCatalog::new(registrations.into_iter().chain(additional))
.map_err(|_| "agent_tool_catalog_invalid")?;
for (name, binding) in web_bindings {
catalog = catalog
.with_request_binding([name].into(), binding)
.map_err(|_| "agent_web_binding_invalid")?;
}
for (name, binding) in computer_bindings {
catalog = catalog
.with_request_binding([name].into(), binding)
.map_err(|_| "agent_computer_binding_invalid")?;
}
for (name, binding) in shell_bindings {
catalog = catalog
.with_request_binding([name].into(), binding)
.map_err(|_| "agent_shell_binding_invalid")?;
}
Ok(Catalog {
tools: catalog,
basis,
editing,
planning,
})
}
#[derive(Clone)]
struct Service {
web_basis: Option<maka_web_api::contract::Basis>,
tool_names: Arc<Vec<(String, Option<EditingTools>)>>,
services: NativeServices,
store: Arc<EventLog>,
binding: String,
target: String,
selection: ServiceTool,
title_kind: Option<title::Kind>,
mode: MethodMode,
}
impl ToolPreparer for Service {
fn names(&self) -> Vec<String> {
vec![self.selection.name.clone()]
}
fn prepare(
&self,
name: String,
input: Value,
context: ToolCallContext,
cancellation: CancellationToken,
) -> PreparationFuture {
let tool_names = self.tool_names.clone();
let web_basis = self.web_basis.clone();
let services = self.services.clone();
let store = self.store.clone();
let binding = self.binding.clone();
let target = self.target.clone();
let selection = self.selection.clone();
let default_title = self.title_kind;
let mode = self.mode;
Box::pin(async move {
if name != selection.name {
return Err(ToolRejection::Unavailable);
}
if cancellation.is_cancelled() {
return Err(ToolRejection::Cancelled);
}
// No request or effect is admitted until the existing ToolJournal T1.
let title = selection
.title
.clone()
.or_else(|| {
if selection.input == ToolInput::Shell {
shell::title(&input)
} else if selection.input == ToolInput::Skill {
maka_skills_api::model_title(&selection.method, &input)
} else {
(selection.result == ToolResult::Computer)
.then(|| computer::title(&selection.method, &input))
}
})
.or_else(|| default_title.and_then(|kind| kind.render(&input)));
let presentation = selection.presentation.clone();
let prepared = PreparedEffect::new(move |cancellation| {
Box::pin(async move {
if selection.confirmation == ToolConfirmation::Required {
approval::confirm(
&store,
&context,
&selection,
&binding,
target,
&input,
&cancellation,
)
.await?;
}
if cancellation.is_cancelled() {
return Err(ToolError::Failed(
"service call cancelled before admission".into(),
));
}
let input = match selection.input {
ToolInput::Json => input,
ToolInput::Shell => {
let mut input = input;
if let Some(fields) = input.as_object_mut() {
fields.remove("title");
}
input
}
ToolInput::Web => {
let basis = web_basis.ok_or_else(|| {
ToolError::Failed("Web tool has no request capture".into())
})?;
serde_json::json!({"input":input,"basis":basis})
}
ToolInput::Skill => {
let configuration = maka_agent_authority::permissions::live(
&store,
&context.invocation,
)
.await?;
let editing = store
.invocation_configuration(&context.invocation)
.await
.map_err(|error| ToolError::Failed(error.to_string()))?
.ok_or_else(|| {
ToolError::Failed(
"Skill call has no invocation configuration".into(),
)
})?
.editing_tools;
let names: Vec<_> = tool_names
.iter()
.filter(|(name, style)| {
(editing.is_none() || style.is_none() || editing == *style)
&& configuration
.tools
.as_ref()
.is_none_or(|allowed| allowed.contains(name))
})
.map(|(name, _)| name)
.collect();
serde_json::json!({"input": input, "tools": names, "workspace":configuration.workspace})
}
};
let invocation = context.invocation.clone();
let operation = operation_id(&context);
let request = services
.request(
Identity::Agent {
invocation: context.invocation,
operation_id: Some(context.operation_id),
},
cancellation.clone(),
)
.map_err(ToolError::Failed)?;
// Keep original request custody even if a native descriptor panics.
let mut observation = None;
let result = {
let mut business = Box::pin(
AssertUnwindSafe(async {
match mode {
MethodMode::Query => {
let result = request
.call(
&selection.import,
0,
selection.method,
input,
Attachments::default(),
)
.map_err(ToolError::Failed)?
.await
.map_err(ToolError::Failed)?;
Ok(ToolSuccess::from(result))
}
MethodMode::Mutation => {
let language = store
.invocation_configuration(&invocation)
.await
.map_err(|error| ToolError::Failed(error.to_string()))?
.ok_or_else(|| {
ToolError::Failed(
"Service call has no invocation configuration"
.into(),
)
})?
.language;
let observer = request
.mutate(
&selection.import,
0,
selection.method,
input,
operation,
Attachments::default(),
)
.map_err(ToolError::Failed)?;
// This outer error is admission refusal, not an accepted
// effect result. Scope.finish still must confirm cleanup;
// every accepted mutation returns its original typed receipt.
let reply = observer.await.map_err(ToolError::Failed)?;
observation = Some(Box::new(reply.clone()));
if selection.result == ToolResult::Computer {
computer::project(&store, &invocation, reply, language)
.await
} else {
mutation_result(reply, language)
}
}
MethodMode::Subscription => {
image::read(
&request,
&selection.import,
selection.method.clone(),
input,
&cancellation,
)
.await
}
}
})
.catch_unwind(),
);
tokio::select! {
biased;
result = &mut business => result,
_ = request.scope().cancellation.cancelled() => {
// Reach the original cleanup budget even when the native
// callback has not returned. Dropping this observer never
// drops the Host's actual admitted job or its fence.
request.finish().await.map_err(ToolError::CleanupUnconfirmed)?;
// A completed parent Ticket precedes the original Host
// reply in the same owner turn. Keep that exact observer.
business.await
}
}
}
.unwrap_or_else(|_| {
Err(ToolError::OutcomeUnknown(
"native tool observation panicked".into(),
))
});
if let Err(message) = request.finish().await {
return Err(match observation {
Some(observation) => ToolError::OperationCleanupUnconfirmed {
message,
observation,
},
None => ToolError::CleanupUnconfirmed(message),
});
}
// finish() cancels the request itself; only the original
// Tool token can distinguish a stopped run at publication.
if selection.result == ToolResult::Image
&& result.is_ok()
&& cancellation.is_cancelled()
{
return Err(ToolError::Failed(
"Image tool cancelled before publication".into(),
));
}
match (result, observation) {
(Err(ToolError::CleanupUnconfirmed(message)), Some(observation)) => {
Err(ToolError::OperationCleanupUnconfirmed {
message,
observation,
})
}
(result, _) => result,
}
})
});
let prepared = match presentation {
Some(key) => prepared.presented(key),
None => prepared,
};
Ok(match title {
Some(title) => prepared.titled(title),
None => prepared,
})
})
}
}
fn operation_id(context: &ToolCallContext) -> Uuid {
let bytes = serde_json::to_vec(&(&context.invocation, &context.operation_id))
.expect("Host tool identity");
let digest = Sha256::digest(bytes);
let mut bytes = [0; 16];
bytes.copy_from_slice(&digest[..16]);
// A deterministic nonnil UUID in the native Agent's actual caller namespace.
bytes[6] = (bytes[6] & 0x0f) | 0x80;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
Uuid::from_bytes(bytes)
}
fn mutation_result(reply: OperationReply, language: Language) -> Result<ToolSuccess, ToolError> {
let unknown = match language {
Language::En => {
"Native service result is unknown; inspect its original receipt before retrying."
}
Language::ZhCn => "原生服务结果未知;重试前请检查其原始回执。",
Language::ZhTw => "原生服務結果未知;重試前請檢查其原始回執。",
};
match &reply.receipt {
Receipt::Recorded { outcome, cleanup } => {
if *cleanup != OperationCleanup::Confirmed {
return Err(ToolError::CleanupUnconfirmed(
"native service cleanup is unconfirmed".into(),
));
}
match outcome {
OperationOutcome::Returned { .. } => {
Ok(ToolOutput::Operation(Box::new(reply)).into())
}
OperationOutcome::Unknown { .. } | OperationOutcome::Unresolved => {
Err(ToolError::OperationUnknown {
message: unknown.into(),
observation: Box::new(reply),
})
}
}
}
Receipt::Unavailable { .. } => Err(ToolError::OperationUnknown {
message: unknown.into(),
observation: Box::new(reply),
}),
Receipt::NotRecorded => Err(ToolError::Failed(
"native service operation was not recorded".into(),
)),
}
}
#[cfg(test)]
mod tests {
use super::*;
use maka_plugin_core::composition::InstanceId;
use maka_plugin_core::contract::Transport;
use maka_plugin_core::deployment::DeploymentRef;
use maka_plugin_core::operation::{RecoveryContract, RecoveryProtocol, RecoveryRef};
#[test]
fn uncertain_service_projection_localizes_recovery_and_preserves_original_receipt() {
let reference = RecoveryRef {
source: DeploymentRef {
state_domain: "a".repeat(64),
deployment: Uuid::from_u128(1),
revision: 3,
},
namespace: InstanceId(vec![Uuid::from_u128(2)]),
export: "Service".into(),
method: "apply".into(),
operation: Uuid::from_u128(3),
fingerprint: format!("sha256-{}", "b".repeat(64)),
contract: RecoveryContract {
name: "example.Service".into(),
version: 1,
transport: Transport::Remote,
digest: format!("sha256-{}", "c".repeat(64)),
},
recovery: RecoveryProtocol::JournalV1,
};
reference.validate().unwrap();
for receipt in [
Receipt::Recorded {
outcome: OperationOutcome::Unknown {
diagnostic: "原始诊断 / no reply".into(),
},
cleanup: OperationCleanup::Confirmed,
},
Receipt::Recorded {
outcome: OperationOutcome::Unresolved,
cleanup: OperationCleanup::Confirmed,
},
Receipt::Unavailable {
diagnostic: "原始查询失败 / lookup failed".into(),
},
] {
let original = OperationReply {
reference: reference.clone(),
receipt,
};
for (language, original_receipt, retry) in [
(Language::En, "original receipt", "retrying"),
(Language::ZhCn, "原始回执", "重试"),
(Language::ZhTw, "原始回執", "重試"),
] {
let Err(ToolError::OperationUnknown {
message,
observation,
}) = mutation_result(original.clone(), language)
else {
panic!("uncertain receipt must remain an unknown operation");
};
assert!(message.contains(original_receipt) && message.contains(retry));
assert_eq!(*observation, original);
}
}
}
}