use aion_mcp::tools::service::ToolFailure;
use aion_proto::{WireError, WireErrorCode};
use serde_json::json;
pub(crate) fn tool_failure(error: &WireError) -> ToolFailure {
let code = wire_code_label(error.code);
let guidance = guidance_for(error.code);
let message = match guidance {
Some(guidance) => format!("{} — {guidance}", error.message),
None => error.message.clone(),
};
ToolFailure::new(
message,
json!({
"code": code,
"message": error.message,
"error_type": error.error_type,
}),
)
}
pub(crate) fn wire_code_label(code: WireErrorCode) -> &'static str {
match code {
WireErrorCode::NotFound => "not_found",
WireErrorCode::NamespaceDenied => "namespace_denied",
WireErrorCode::SequenceConflict => "sequence_conflict",
WireErrorCode::UnknownQuery => "unknown_query",
WireErrorCode::QueryTimeout => "query_timeout",
WireErrorCode::NotRunning => "not_running",
WireErrorCode::Lagged => "lagged",
WireErrorCode::InvalidInput => "invalid_input",
WireErrorCode::Backend => "backend",
WireErrorCode::QueryFailed => "query_failed",
WireErrorCode::DeployDenied => "deploy_denied",
WireErrorCode::GrantDenied => "grant_denied",
WireErrorCode::VersionPinned => "version_pinned",
WireErrorCode::NotOwner => "not_owner",
WireErrorCode::InvalidState => "invalid_state",
}
}
fn guidance_for(code: WireErrorCode) -> Option<&'static str> {
match code {
WireErrorCode::NotFound | WireErrorCode::NamespaceDenied => Some(
"the workflow does not exist in that namespace, or you do not hold it. Both answer \
the same way on purpose. Use list_runs to find a real id; do not retry with a \
guessed one",
),
WireErrorCode::NotRunning => Some(
"the run has already reached a terminal status, so it cannot be signalled, \
queried, or cancelled. Call describe_run to see which terminal it reached",
),
WireErrorCode::UnknownQuery => Some(
"the workflow registered no query by that name. Query names come from the \
workflow's own code and cannot be invented",
),
WireErrorCode::QueryTimeout => Some(
"the workflow did not answer within its configured window; it may be busy or \
parked. describe_run will say whether anything can currently serve it",
),
WireErrorCode::NotOwner | WireErrorCode::SequenceConflict => Some(
"this is a retryable routing or write race, not a mistake in your request. Retry \
the same call",
),
WireErrorCode::DeployDenied => Some(
"saving or deploying needs the deploy grant, which your credential does not carry. \
The refusal names the exact knob that grants it; ask the operator to grant your \
subject deploy, and do not retry until it is granted",
),
WireErrorCode::GrantDenied => Some(
"this surface needs a grant your credential does not carry. The refusal names the \
word and the exact knob that grants it; ask the operator to grant your subject that \
word, and do not retry until it is granted",
),
WireErrorCode::InvalidInput
| WireErrorCode::Lagged
| WireErrorCode::Backend
| WireErrorCode::QueryFailed
| WireErrorCode::VersionPinned
| WireErrorCode::InvalidState => None,
}
}
#[cfg(test)]
mod tests {
use aion_proto::{WireError, WireErrorCode};
use super::{tool_failure, wire_code_label};
#[test]
fn a_not_found_carries_its_code_and_tells_the_model_not_to_guess() {
let failure = tool_failure(&WireError::not_found("workflow x was not found"));
assert_eq!(failure.detail["code"], "not_found");
assert!(failure.message.contains("workflow x was not found"));
assert!(failure.message.contains("do not retry with a guessed one"));
}
#[test]
fn a_deploy_denial_names_the_missing_grant_and_forbids_blind_retry() {
let failure = tool_failure(&WireError::deploy_denied(
"subject `assistant` is not authorized to deploy; \
set x-aion-deploy: true for subject `assistant`",
));
assert_eq!(failure.detail["code"], "deploy_denied");
assert!(
failure.message.contains("deploy grant"),
"{}",
failure.message
);
assert!(
failure.message.contains("x-aion-deploy"),
"the wire message's own hint must survive the rewrite: {}",
failure.message
);
}
#[test]
fn a_grant_denial_names_the_missing_grant_and_forbids_blind_retry() {
let failure = tool_failure(&WireError::grant_denied(
"subject `assistant` is not authorized for `assistant.sessions`; \
set `x-aion-assistant-sessions`: true for subject `assistant`",
));
assert_eq!(failure.detail["code"], "grant_denied");
assert!(
failure.message.contains("assistant.sessions"),
"the wire message's own hint must survive the rewrite: {}",
failure.message
);
assert!(
failure.message.contains("do not retry until it is granted"),
"{}",
failure.message
);
}
#[test]
fn a_code_without_guidance_keeps_the_bare_message() {
let failure = tool_failure(&WireError::invalid_input("attempt must be positive"));
assert_eq!(failure.message, "attempt must be positive");
assert_eq!(failure.detail["code"], "invalid_input");
}
#[test]
fn every_wire_code_has_a_distinct_label() {
let codes = [
WireErrorCode::NotFound,
WireErrorCode::NamespaceDenied,
WireErrorCode::SequenceConflict,
WireErrorCode::UnknownQuery,
WireErrorCode::QueryTimeout,
WireErrorCode::NotRunning,
WireErrorCode::Lagged,
WireErrorCode::InvalidInput,
WireErrorCode::Backend,
WireErrorCode::QueryFailed,
WireErrorCode::DeployDenied,
WireErrorCode::GrantDenied,
WireErrorCode::VersionPinned,
WireErrorCode::NotOwner,
WireErrorCode::InvalidState,
];
let mut labels: Vec<&str> = codes.into_iter().map(wire_code_label).collect();
let total = labels.len();
labels.sort_unstable();
labels.dedup();
assert_eq!(labels.len(), total, "two wire codes share one label");
}
}