use rmcp::model::{CallToolResult, ContentBlock};
use rmcp::ErrorData as McpError;
use schemars::{JsonSchema, Schema, SchemaGenerator};
use serde::Serialize;
use serde_json::{json, Map, Value};
use crate::errors::WinxError;
use crate::runtime::BashCommandRuntimeResult;
use crate::state::turn::TurnState;
use crate::tools::bash_command::BashCommandState;
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub(super) enum ToolResultStatus {
Completed,
Running,
AwaitingInput,
AwaitingApproval,
NeedsRead,
NeedsInitialize,
Conflict,
NotFound,
Denied,
InvalidInput,
Failed,
}
#[derive(Clone, Debug, Serialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub(super) struct ToolNextAction {
pub tool: String,
pub instruction: String,
#[schemars(schema_with = "open_object_schema")]
#[serde(skip_serializing_if = "Option::is_none")]
pub arguments: Option<Value>,
}
#[derive(Clone, Debug, Serialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub(super) struct RequiredRead {
pub path: String,
pub ranges: Vec<String>,
}
#[derive(Clone, Debug, Serialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub(super) struct ToolResultEnvelope {
pub status: ToolResultStatus,
pub tool: String,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub error_code: Option<String>,
pub retryable: bool,
pub retry_same_call: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub retry_after_ms: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub next_action: Option<ToolNextAction>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub required_reads: Vec<RequiredRead>,
#[schemars(schema_with = "open_object_schema")]
#[serde(skip_serializing_if = "Option::is_none")]
pub data: Option<Value>,
}
fn open_object_schema(_: &mut SchemaGenerator) -> Schema {
schemars::json_schema!({ "type": "object" })
}
#[allow(dead_code)] #[derive(Clone, Debug, JsonSchema)]
pub(super) struct CodeMapToolResultEnvelope {
#[schemars(flatten)]
pub result: ToolResultEnvelope,
pub truncated: Option<bool>,
pub next_cursor: Option<String>,
pub snapshot_hash: Option<String>,
pub files_scanned: Option<usize>,
pub payload_bytes: Option<usize>,
pub source_kind: Option<String>,
pub canonical: Option<bool>,
pub temporary_helper_budget: Option<crate::types::CodeMapTemporaryHelperBudget>,
pub mode: Option<String>,
pub files_shown: Option<usize>,
pub files: Option<Vec<crate::types::OutlineFile>>,
pub file_extension: Option<String>,
pub language_supported: Option<bool>,
pub fallback: Option<crate::types::CodeMapFallback>,
pub name: Option<String>,
pub definitions: Option<usize>,
pub references: Option<usize>,
pub hits: Option<Vec<crate::types::ReferenceHit>>,
}
pub(super) fn tool_failure(
tool: &str,
error: &WinxError,
arguments: Option<&Value>,
) -> Result<CallToolResult, McpError> {
if matches!(error, WinxError::SerializationError(_) | WinxError::BashStateLockError(_)) {
return Err(McpError::internal_error(format!("{tool} failed: {error}"), None));
}
let text = match (tool, error) {
(
"Initialize",
WinxError::WorkspaceBindingMismatch { requested_workspace, bound_workspace, .. },
) => format!(
"Initialize rejected: this conversation is already bound to {}. Do not call \
Initialize again for {}. Keep the existing thread_id/workspace_root pair; an \
allowed target outside that workspace can be accessed by its absolute path without \
rebinding. If the user truly intends a different project, start a new conversation \
or client session.",
bound_workspace.display(),
requested_workspace.display()
),
("Initialize", WinxError::WorkspaceChangeRequiresNewSession { workspace_root }) => {
format!(
"Initialize rejected: an existing remote conversation cannot change its project \
identity in place to {}. Keep using the current binding for allowed external \
paths, or start a new conversation/client session for that project. Do not \
repeat this Initialize call.",
workspace_root.display()
)
}
_ => format!("{tool} failed: {error}"),
};
let envelope = error_envelope(tool, error, arguments, text.clone());
let mut result = CallToolResult::error(vec![ContentBlock::text(text)]);
result.structured_content = serde_json::to_value(envelope).ok();
Ok(result)
}
pub(super) fn decorate_success(tool: &str, arguments: Option<&Value>, result: &mut CallToolResult) {
if result.is_error == Some(true) || tool == "BashCommand" {
return;
}
if result.structured_content.as_ref().is_some_and(|structured| {
structured.get("tool").and_then(Value::as_str) == Some(tool)
&& structured.get("status").and_then(Value::as_str).is_some()
}) {
return;
}
let existing = result.structured_content.take();
let text = result_text(result);
let mut data = safe_success_data(tool, arguments, &text, false);
if let Some(existing) = existing.as_ref() {
if tool == "Initialize" {
if let Value::Object(metadata) = existing {
for (key, value) in metadata {
data.entry(key.clone()).or_insert_with(|| value.clone());
}
} else {
data.insert("result".to_string(), existing.clone());
}
} else if tool != "CodeMap" {
data.insert("result".to_string(), existing.clone());
}
}
let envelope = ToolResultEnvelope {
status: ToolResultStatus::Completed,
tool: tool.to_string(),
message: success_message(tool, ToolResultStatus::Completed),
error_code: None,
retryable: false,
retry_same_call: false,
retry_after_ms: None,
next_action: None,
required_reads: Vec::new(),
data: (!data.is_empty()).then_some(Value::Object(data)),
};
let Ok(mut value) = serde_json::to_value(envelope) else {
result.structured_content = existing;
return;
};
if tool == "CodeMap" {
if let (Value::Object(target), Some(Value::Object(source))) = (&mut value, existing) {
for (key, item) in source {
target.entry(key).or_insert(item);
}
}
}
result.structured_content = Some(value);
}
pub(super) fn edit_verification_result(
tool: &str,
arguments: Option<&Value>,
edit_text: &str,
verification: CallToolResult,
) -> CallToolResult {
let verification_text = result_text(&verification);
let verification_is_error = verification.is_error == Some(true);
let nested = verification.structured_content.unwrap_or_else(|| {
json!({
"status": if verification_is_error { "failed" } else { "completed" },
"tool": "BashCommand",
"message": "Verification returned no structured result."
})
});
let nested_status = nested.get("status").and_then(Value::as_str).unwrap_or("failed");
let exit_code =
nested.get("data").and_then(|data| data.get("exit_code")).and_then(Value::as_i64);
let nonzero_exit = exit_code.is_some_and(|code| code != 0);
let verification_error = verification_is_error || nonzero_exit;
let active = matches!(nested_status, "running" | "awaiting_input" | "awaiting_approval");
let outer_status = if nonzero_exit {
"failed"
} else if active || verification_error {
nested_status
} else {
"completed"
};
let message = if verification_error {
format!("{tool} applied the edit, but verification failed; the edit was not rolled back.")
} else if active {
format!("{tool} applied the edit; verification is still {outer_status}.")
} else {
format!("{tool} applied the edit and verification completed.")
};
let verification_summary = if verification_error {
exit_code.map_or_else(
|| "Verification failed; the edit remains applied.".to_string(),
|code| format!("Verification failed with exit code {code}; the edit remains applied."),
)
} else if active {
format!("Verification is still {outer_status}.")
} else {
exit_code.map_or_else(
|| "Verification completed.".to_string(),
|code| format!("Verification completed with exit code {code}."),
)
};
let combined_text = if verification_text.trim().is_empty() {
format!("{edit_text}\n\n{verification_summary}")
} else {
format!("{edit_text}\n\n{verification_summary}\n{verification_text}")
};
let mut data = safe_success_data(tool, arguments, &combined_text, false);
data.insert("edit_applied".to_string(), Value::Bool(true));
data.insert("verification".to_string(), nested.clone());
if let Some(exit_code) = exit_code {
data.insert("verification_exit_code".to_string(), json!(exit_code));
}
let mut envelope = json!({
"status": outer_status,
"tool": tool,
"message": message,
"retryable": nested.get("retryable").and_then(Value::as_bool).unwrap_or(false),
"retrySameCall": false,
"requiredReads": [],
"data": data,
});
if verification_error {
let nested_code =
nested.get("errorCode").and_then(Value::as_str).unwrap_or("execution_failed");
envelope["errorCode"] = Value::String(if nonzero_exit {
"verification_failed".to_string()
} else {
format!("verification_{nested_code}")
});
}
for key in ["retryAfterMs", "nextAction"] {
if let Some(value) = nested.get(key) {
envelope[key] = value.clone();
}
}
let mut result = if verification_error {
CallToolResult::error(vec![ContentBlock::text(combined_text)])
} else {
CallToolResult::success(vec![ContentBlock::text(combined_text)])
};
result.structured_content = Some(envelope);
result
}
pub(super) fn bash_success_result(
arguments: Option<&Value>,
outcome: BashCommandRuntimeResult,
compact_output: bool,
) -> Result<CallToolResult, McpError> {
let status = bash_success_status(&outcome.result.state);
let output_truncated = outcome.output_truncated;
let next_action = bash_next_action(status, arguments, &outcome.result.state);
let compact_rendered = compact_output && outcome.compact_output.is_some();
let rendered_output = if compact_output {
outcome.compact_output.unwrap_or(outcome.result.output)
} else {
outcome.result.output
};
let mut data = safe_success_data("BashCommand", arguments, &rendered_output, output_truncated);
if compact_rendered {
data.insert("output_format".to_string(), Value::String("compact".to_string()));
}
data.insert("cwd".to_string(), Value::String(outcome.result.state.cwd.display().to_string()));
if let Some(value) = outcome.result.state.exit_code {
data.insert("exit_code".to_string(), json!(value));
}
if let Some(value) = outcome.result.state.background_id.as_ref() {
data.insert("bg_command_id".to_string(), Value::String(value.clone()));
}
if let Some(value) = outcome.result.state.running_for_seconds {
data.insert("running_for_seconds".to_string(), json!(value));
data.insert("running_for".to_string(), Value::String(format!("{value} seconds")));
}
if let Some(value) = outcome.result.state.turn_state {
data.insert("turn_state".to_string(), Value::String(value.as_str().to_string()));
}
if let Some(value) = outcome.dropped_output_file.as_ref() {
data.insert(
"dropped_output_file".to_string(),
Value::String(value.to_string_lossy().into_owned()),
);
}
if let Some(generation) = outcome.command_generation {
data.insert("command_generation".to_string(), json!(generation));
}
if let Some(token) = outcome.execution_token.as_ref() {
data.insert(
"execution_id".to_string(),
Value::String(format!(
"{}:{}:{}",
token.guardian_epoch, token.session_epoch, token.generation
)),
);
}
let envelope = ToolResultEnvelope {
status,
tool: "BashCommand".to_string(),
message: success_message("BashCommand", status),
error_code: None,
retryable: false,
retry_same_call: false,
retry_after_ms: (status == ToolResultStatus::Running).then_some(1_000),
next_action,
required_reads: Vec::new(),
data: (!data.is_empty()).then_some(Value::Object(data)),
};
let structured_content = serde_json::to_value(envelope).map_err(|error| {
McpError::internal_error(
format!("failed to serialize typed BashCommand result: {error}"),
None,
)
})?;
let mut result = CallToolResult::success(vec![ContentBlock::text(rendered_output)]);
result.structured_content = Some(structured_content);
Ok(result)
}
pub(super) fn result_status(result: &CallToolResult) -> String {
result
.structured_content
.as_ref()
.and_then(|value| value.get("status"))
.and_then(Value::as_str)
.unwrap_or(if result.is_error == Some(true) { "failed" } else { "completed" })
.to_string()
}
pub(super) fn result_size_bytes(result: &CallToolResult) -> usize {
let content = result
.content
.iter()
.map(|block| {
block
.as_text()
.map_or(0, |text| text.text.len())
.saturating_add(block.as_image().map_or(0, |image| image.data.len()))
})
.fold(0usize, usize::saturating_add);
content.saturating_add(
result
.structured_content
.as_ref()
.and_then(|value| serde_json::to_vec(value).ok())
.map_or(0, |value| value.len()),
)
}
fn result_text(result: &CallToolResult) -> String {
result
.content
.iter()
.filter_map(|content| content.as_text().map(|text| text.text.as_str()))
.collect::<Vec<_>>()
.join("\n")
}
fn bash_success_status(state: &BashCommandState) -> ToolResultStatus {
if !state.is_running() {
return ToolResultStatus::Completed;
}
match state.turn_state {
Some(TurnState::AwaitingApproval) => ToolResultStatus::AwaitingApproval,
Some(TurnState::AwaitingInput) => ToolResultStatus::AwaitingInput,
Some(TurnState::Busy | TurnState::Unknown) | None => ToolResultStatus::Running,
}
}
fn success_message(tool: &str, status: ToolResultStatus) -> String {
match status {
ToolResultStatus::Running => format!("{tool} is still running."),
ToolResultStatus::AwaitingInput => format!("{tool} is waiting for input."),
ToolResultStatus::AwaitingApproval => format!("{tool} is waiting for approval."),
_ => format!("{tool} completed."),
}
}
fn bash_next_action(
status: ToolResultStatus,
arguments: Option<&Value>,
bash_state: &BashCommandState,
) -> Option<ToolNextAction> {
match status {
ToolResultStatus::Running => {
let mut action = json!({
"action_json": {
"type": "status_check",
"status_check": true
}
});
copy_session_binding(arguments, &mut action);
if let Some(background_id) = bash_state.background_id.as_ref() {
action["action_json"]["bg_command_id"] = Value::String(background_id.clone());
}
Some(ToolNextAction {
tool: "BashCommand".to_string(),
instruction: "Wait for retry_after_ms, then check status. Do not repeat the original command."
.to_string(),
arguments: Some(action),
})
}
ToolResultStatus::AwaitingInput => Some(ToolNextAction {
tool: "BashCommand".to_string(),
instruction: "Inspect the returned screen, then use send_text or send_specials with the required input."
.to_string(),
arguments: None,
}),
ToolResultStatus::AwaitingApproval => Some(ToolNextAction {
tool: "BashCommand".to_string(),
instruction: "Do not approve automatically. Ask the user when approval changes permissions, data, or system state."
.to_string(),
arguments: None,
}),
_ => None,
}
}
fn safe_success_data(
tool: &str,
arguments: Option<&Value>,
text: &str,
output_truncated: bool,
) -> Map<String, Value> {
let mut data = Map::new();
data.insert("output_bytes".to_string(), json!(text.len()));
data.insert("output_truncated".to_string(), json!(output_truncated));
for key in [
"thread_id",
"file_path",
"operation",
"path",
"name",
"id",
"mode_name",
"any_workspace_path",
"workspace_root",
] {
if let Some(value) = string_argument(arguments, key) {
data.insert(key.to_string(), Value::String(value));
}
}
if let Some(files) = arguments
.and_then(Value::as_object)
.and_then(|arguments| arguments.get("file_paths"))
.and_then(Value::as_array)
{
data.insert(
"file_paths".to_string(),
Value::Array(
files.iter().filter_map(Value::as_str).map(|value| json!(value)).collect(),
),
);
}
if tool == "MultiFileEdit" {
if let Some(files) = arguments
.and_then(Value::as_object)
.and_then(|arguments| arguments.get("files"))
.and_then(Value::as_array)
{
data.insert(
"file_paths".to_string(),
Value::Array(
files
.iter()
.filter_map(|file| file.get("file_path"))
.filter_map(Value::as_str)
.map(|value| json!(value))
.collect(),
),
);
}
}
if tool == "BashCommand" {
if let Some(action) = bash_action(arguments) {
data.insert("action".to_string(), Value::String(action));
}
if let (Some(thread_id), Some(workspace_root)) =
(string_argument(arguments, "thread_id"), string_argument(arguments, "workspace_root"))
{
let temporary_artifact = crate::utils::agent_temp::session_info(
std::path::Path::new(&workspace_root),
&thread_id,
);
data.insert(
"temporary_artifact_dir".to_string(),
Value::String(temporary_artifact.directory.to_string_lossy().into_owned()),
);
data.insert(
"temporary_artifact_env".to_string(),
Value::String("WINX_TEMP_DIR".into()),
);
}
}
data
}
#[allow(clippy::too_many_lines)] fn error_envelope(
tool: &str,
error: &WinxError,
arguments: Option<&Value>,
text: String,
) -> ToolResultEnvelope {
let mut status = ToolResultStatus::Failed;
let mut error_code = "execution_failed".to_string();
let mut retryable = false;
let mut retry_after_ms = None;
let mut next_action = None;
let mut required_reads = Vec::new();
match error {
WinxError::BashStateNotInitialized => {
status = ToolResultStatus::NeedsInitialize;
error_code = "not_initialized".to_string();
retryable = true;
next_action = Some(ToolNextAction {
tool: "Initialize".to_string(),
instruction: "Initialize the intended workspace once, preserve the returned thread_id, then retry."
.to_string(),
arguments: None,
});
}
WinxError::WorkspaceBindingRequired { .. } => {
status = ToolResultStatus::NeedsInitialize;
error_code = "workspace_binding_required".to_string();
retryable = true;
next_action = Some(ToolNextAction {
tool: "Initialize".to_string(),
instruction: "Initialize the intended workspace once, then copy the returned thread_id/workspace_root pair into every later call."
.to_string(),
arguments: None,
});
}
WinxError::WorkspaceThreadMismatch { workspace_root, .. } => {
status = ToolResultStatus::Conflict;
error_code = "workspace_thread_mismatch".to_string();
retryable = true;
next_action = Some(initialize_workspace_action(workspace_root));
}
WinxError::WorkspaceBindingMismatch { .. } if tool == "Initialize" => {
status = ToolResultStatus::Conflict;
error_code = "initialize_workspace_already_bound".to_string();
retryable = false;
}
WinxError::WorkspaceBindingMismatch { requested_workspace, .. } => {
status = ToolResultStatus::Conflict;
error_code = "workspace_binding_mismatch".to_string();
retryable = true;
next_action = Some(initialize_workspace_action(requested_workspace));
}
WinxError::WorkspaceChangeRequiresNewSession { .. } => {
status = ToolResultStatus::Conflict;
error_code = "workspace_change_requires_new_session".to_string();
retryable = false;
}
WinxError::CommandAlreadyRunning { .. } => {
status = ToolResultStatus::Running;
error_code = "command_already_running".to_string();
retryable = true;
retry_after_ms = Some(1_000);
next_action = Some(status_check_action(arguments));
}
WinxError::FileAccessError { path, message } => {
classify_file_error(
path.to_string_lossy().as_ref(),
message,
arguments,
&mut status,
&mut error_code,
&mut retryable,
&mut next_action,
&mut required_reads,
);
}
WinxError::TemporaryArtifactPolicy { temporary_artifact_dir, .. } => {
status = ToolResultStatus::InvalidInput;
error_code = "temporary_artifact_policy".to_string();
retryable = true;
next_action = Some(if tool == "CodeMap" {
ToolNextAction {
tool: "ReadFiles".to_string(),
instruction: "Use the canonical source or a targeted range from an existing \
file. For plain-text search use rg via BashCommand. Do not create \
or map another carrier."
.to_string(),
arguments: None,
}
} else {
ToolNextAction {
tool: tool.to_string(),
instruction: format!(
"Correct the helper path or content using temporary_artifact_dir `{}`; use \
a short descriptive path and do not repeat the rejected call unchanged.",
temporary_artifact_dir.display()
),
arguments: None,
}
});
}
WinxError::DerivedCodeMapBudget { .. } => {
status = ToolResultStatus::InvalidInput;
error_code = "derived_code_map_budget_exhausted".to_string();
retryable = true;
next_action = Some(ToolNextAction {
tool: "ReadFiles".to_string(),
instruction: "Stop creating or mapping temporary carriers. Reuse prior results, \
run CodeMap on canonical source, or read only the exact canonical \
ranges needed; use rg via BashCommand for plain-text search."
.to_string(),
arguments: None,
});
}
WinxError::SearchBlockNotFound(_) | WinxError::SearchBlockAmbiguous { .. } => {
status = ToolResultStatus::Conflict;
error_code = if matches!(error, WinxError::SearchBlockAmbiguous { .. }) {
"search_block_ambiguous".to_string()
} else {
"search_block_not_found".to_string()
};
retryable = true;
if let Some(path) = string_argument(arguments, "file_path") {
required_reads.push(RequiredRead { path: path.clone(), ranges: Vec::new() });
next_action = read_action(arguments, &path, &[]);
}
}
WinxError::MultiFilePlanError { path, source, .. } => {
status = ToolResultStatus::Conflict;
error_code = if matches!(source.as_ref(), WinxError::SearchBlockAmbiguous { .. }) {
"search_block_ambiguous".to_string()
} else {
"search_block_not_found".to_string()
};
retryable = true;
let path = path.to_string_lossy().into_owned();
required_reads.push(RequiredRead { path: path.clone(), ranges: Vec::new() });
next_action = read_action(arguments, &path, &[]);
}
WinxError::PathSecurityError { .. } | WinxError::CommandNotAllowed(_) => {
status = ToolResultStatus::Denied;
error_code = "operation_denied".to_string();
}
WinxError::WorkspacePathError(_) | WinxError::BackgroundSessionNotFound(_) => {
status = ToolResultStatus::NotFound;
error_code = "target_not_found".to_string();
}
WinxError::ThreadIdMismatch(_) => {
status = ToolResultStatus::Conflict;
error_code = "thread_id_mismatch".to_string();
}
WinxError::ParameterValidationError { .. }
| WinxError::MissingParameterError { .. }
| WinxError::NullValueError { .. }
| WinxError::ArgumentParseError(_)
| WinxError::JsonParseError(_)
| WinxError::DeserializationError(_)
| WinxError::InvalidInput(_)
| WinxError::ParseError(_)
| WinxError::SearchReplaceSyntaxError(_)
| WinxError::SearchReplaceSyntaxErrorDetailed { .. }
| WinxError::EmptyInteractiveInput { .. }
| WinxError::InteractiveTargetNotRunning(_)
| WinxError::NoActiveCommand(_) => {
status = ToolResultStatus::InvalidInput;
error_code = "invalid_tool_input".to_string();
retryable = true;
}
WinxError::RecoverableSuggestionError { .. } => {
retryable = true;
error_code = "recoverable_failure".to_string();
}
WinxError::CommandTimeout { timeout_seconds, .. } => {
error_code = "command_timeout".to_string();
retryable = true;
retry_after_ms = Some(timeout_seconds.saturating_mul(1_000));
}
_ => {}
}
let mut data = Map::new();
if let Some(thread_id) = string_argument(arguments, "thread_id") {
data.insert("thread_id".to_string(), Value::String(thread_id));
}
if let Some(workspace_root) = string_argument(arguments, "workspace_root") {
data.insert("workspace_root".to_string(), Value::String(workspace_root));
}
if let WinxError::WorkspaceBindingMismatch { requested_workspace, bound_workspace, .. } = error
{
data.insert(
"bound_workspace".to_string(),
Value::String(bound_workspace.to_string_lossy().into_owned()),
);
data.insert(
"requested_workspace".to_string(),
Value::String(requested_workspace.to_string_lossy().into_owned()),
);
if tool == "Initialize" {
data.insert("continue_with_bound_session".to_string(), Value::Bool(true));
data.insert("external_targets_require_reinitialize".to_string(), Value::Bool(false));
}
}
if let WinxError::TemporaryArtifactPolicy { path, temporary_artifact_dir, .. } = error {
data.insert(
"rejected_path".to_string(),
Value::String(path.to_string_lossy().into_owned()),
);
data.insert(
"temporary_artifact_dir".to_string(),
Value::String(temporary_artifact_dir.to_string_lossy().into_owned()),
);
}
if let WinxError::DerivedCodeMapBudget {
path,
temporary_artifact_dir,
calls_used,
calls_limit,
unique_files_used,
unique_files_limit,
..
} = error
{
data.insert(
"rejected_path".to_string(),
Value::String(path.to_string_lossy().into_owned()),
);
data.insert(
"temporary_artifact_dir".to_string(),
Value::String(temporary_artifact_dir.to_string_lossy().into_owned()),
);
data.insert("calls_used".to_string(), json!(calls_used));
data.insert("calls_limit".to_string(), json!(calls_limit));
data.insert("unique_files_used".to_string(), json!(unique_files_used));
data.insert("unique_files_limit".to_string(), json!(unique_files_limit));
}
ToolResultEnvelope {
status,
tool: tool.to_string(),
message: text,
error_code: Some(error_code),
retryable,
retry_same_call: false,
retry_after_ms,
next_action,
required_reads,
data: (!data.is_empty()).then_some(Value::Object(data)),
}
}
#[allow(clippy::too_many_arguments)]
fn classify_file_error(
path: &str,
message: &str,
arguments: Option<&Value>,
status: &mut ToolResultStatus,
error_code: &mut String,
retryable: &mut bool,
next_action: &mut Option<ToolNextAction>,
required_reads: &mut Vec<RequiredRead>,
) {
let lower = message.to_ascii_lowercase();
if lower.contains("hasn't been read")
|| lower.contains("changed on disk since you last read")
|| lower.contains("read more of the file")
{
*status = ToolResultStatus::NeedsRead;
*error_code = "read_required".to_string();
*retryable = true;
let ranges = unread_ranges(message);
required_reads.push(RequiredRead { path: path.to_string(), ranges: ranges.clone() });
*next_action = read_action(arguments, path, &ranges);
} else if lower.contains("changed since its last winx edit") {
*status = ToolResultStatus::Conflict;
*error_code = "file_changed_after_edit".to_string();
*retryable = true;
required_reads.push(RequiredRead { path: path.to_string(), ranges: Vec::new() });
*next_action = read_action(arguments, path, &[]);
} else if lower.contains("does not exist")
|| lower.contains("path not found")
|| lower.contains("no undo checkpoint")
{
*status = ToolResultStatus::NotFound;
*error_code = "file_not_found".to_string();
} else if lower.contains("not allowed") || lower.contains("permission denied") {
*status = ToolResultStatus::Denied;
*error_code = "file_operation_denied".to_string();
}
}
fn status_check_action(arguments: Option<&Value>) -> ToolNextAction {
let mut value = json!({
"action_json": {
"type": "status_check",
"status_check": true
}
});
copy_session_binding(arguments, &mut value);
ToolNextAction {
tool: "BashCommand".to_string(),
instruction: "Check the running command instead of submitting the original command again."
.to_string(),
arguments: Some(value),
}
}
fn read_action(arguments: Option<&Value>, path: &str, ranges: &[String]) -> Option<ToolNextAction> {
if path.is_empty() {
return None;
}
let file_paths = if ranges.is_empty() {
vec![Value::String(path.to_string())]
} else {
ranges.iter().map(|range| Value::String(format!("{path}:{range}"))).collect()
};
let mut value = json!({"file_paths": file_paths});
copy_session_binding(arguments, &mut value);
Some(ToolNextAction {
tool: "ReadFiles".to_string(),
instruction: "Perform every required read before retrying the edit. Do not retry the same edit unchanged first."
.to_string(),
arguments: Some(value),
})
}
fn initialize_workspace_action(workspace_root: &std::path::Path) -> ToolNextAction {
ToolNextAction {
tool: "Initialize".to_string(),
instruction: "Initialize this intended project as a separate coherent session, then use the returned thread_id/workspace_root pair."
.to_string(),
arguments: Some(json!({
"type": "first_call",
"any_workspace_path": workspace_root.to_string_lossy(),
"initial_files_to_read": [],
"mode_name": "wcgw",
"thread_id": ""
})),
}
}
fn copy_session_binding(arguments: Option<&Value>, target: &mut Value) {
for key in ["thread_id", "workspace_root"] {
if let Some(value) = string_argument(arguments, key) {
target[key] = Value::String(value);
}
}
}
fn unread_ranges(message: &str) -> Vec<String> {
message
.split_once("Unread line ranges:")
.map(|(_, ranges)| {
ranges
.split(',')
.map(str::trim)
.filter(|range| !range.is_empty())
.map(ToString::to_string)
.collect()
})
.unwrap_or_default()
}
fn bash_action(arguments: Option<&Value>) -> Option<String> {
let arguments = arguments?.as_object()?;
let fallback = Value::Object(arguments.clone());
let action = arguments.get("action_json").unwrap_or(&fallback);
match action {
Value::Object(action) => {
action.get("type").and_then(Value::as_str).map(ToString::to_string).or_else(|| {
[
"command",
"status_check",
"send_text",
"send_specials",
"send_ascii",
"screen",
"wait_for_turn",
]
.into_iter()
.find(|kind| action.contains_key(*kind))
.map(ToString::to_string)
})
}
Value::String(_) => Some("command".to_string()),
_ => None,
}
}
fn string_argument(arguments: Option<&Value>, key: &str) -> Option<String> {
arguments?
.as_object()?
.get(key)?
.as_str()
.filter(|value| !value.is_empty())
.map(ToString::to_string)
}
#[cfg(test)]
mod tests {
#![allow(clippy::expect_used)]
use super::*;
#[test]
fn code_map_navigation_payload_is_not_duplicated_inside_data_result() {
let navigation = json!({
"mode": "repo",
"files_shown": 1,
"files": [{"file": "src/lib.rs", "symbols": []}],
"truncated": false,
"files_scanned": 1,
"payload_bytes": 128
});
let mut result = CallToolResult::success(vec![ContentBlock::text("src/lib.rs\n")]);
result.structured_content = Some(navigation);
decorate_success("CodeMap", None, &mut result);
let structured = result.structured_content.expect("structured CodeMap result");
assert_eq!(structured["files"][0]["file"], "src/lib.rs");
assert!(structured["data"].get("result").is_none(), "{structured}");
}
#[test]
fn initialize_metadata_is_flattened_into_the_shared_data_envelope() {
let mut result = CallToolResult::success(vec![ContentBlock::text("attached")]);
result.structured_content = Some(json!({
"initialize_transition": "attached_existing",
"initialize_reused": true,
"initialize_response_mode": "compact",
"context_bytes": 0,
"workspace_root": "/workspace"
}));
decorate_success(
"Initialize",
Some(&json!({"thread_id":"thread","mode_name":"wcgw"})),
&mut result,
);
let structured = result.structured_content.expect("structured Initialize result");
assert_eq!(structured["status"], "completed");
assert_eq!(structured["data"]["initialize_transition"], "attached_existing");
assert_eq!(structured["data"]["initialize_reused"], true);
assert_eq!(structured["data"]["initialize_response_mode"], "compact");
assert_eq!(structured["data"]["workspace_root"], "/workspace");
assert!(structured["data"].get("result").is_none());
}
#[test]
fn unread_file_error_provides_exact_read_call() {
let error = WinxError::FileAccessError {
path: "/workspace/README.md".into(),
message: "Read more of the file before overwriting. Unread line ranges: 20-40, 90-"
.to_string(),
};
let result = tool_failure(
"FileWriteOrEdit",
&error,
Some(&json!({
"thread_id":"thread",
"workspace_root":"/workspace",
"file_path":"/workspace/README.md"
})),
)
.expect("tool-level error");
assert_eq!(result.is_error, Some(true));
let structured = result.structured_content.expect("structured error");
assert_eq!(structured["status"], "needs_read");
assert_eq!(structured["retrySameCall"], false);
assert_eq!(
structured["nextAction"]["arguments"]["file_paths"],
json!(["/workspace/README.md:20-40", "/workspace/README.md:90-"])
);
assert_eq!(structured["nextAction"]["arguments"]["workspace_root"], "/workspace");
}
#[test]
fn temporary_artifact_policy_returns_a_correctable_structured_result() {
let error = WinxError::TemporaryArtifactPolicy {
path: "/workspace/.winx_tmp/carrier.py".into(),
temporary_artifact_dir: "/workspace/.winx/tmp/session-deadbeefdeadbeef".into(),
message: "use the managed session directory".to_string(),
};
let result = tool_failure(
"FileWriteOrEdit",
&error,
Some(&json!({
"thread_id":"thread",
"workspace_root":"/workspace",
"file_path":"/workspace/.winx_tmp/carrier.py"
})),
)
.expect("tool result");
assert_eq!(result.is_error, Some(true));
let structured = result.structured_content.expect("structured policy result");
assert_eq!(structured["status"], "invalid_input");
assert_eq!(structured["errorCode"], "temporary_artifact_policy");
assert_eq!(structured["retryable"], true);
assert_eq!(structured["retrySameCall"], false);
assert_eq!(
structured["data"]["temporary_artifact_dir"],
"/workspace/.winx/tmp/session-deadbeefdeadbeef"
);
assert_eq!(structured["nextAction"]["tool"], "FileWriteOrEdit");
}
#[test]
fn derived_code_map_budget_redirects_to_canonical_source_tools() {
let error = WinxError::DerivedCodeMapBudget {
path: "/workspace/.winx/tmp/session-deadbeefdeadbeef/carrier.py".into(),
temporary_artifact_dir: "/workspace/.winx/tmp/session-deadbeefdeadbeef".into(),
calls_used: 64,
calls_limit: 64,
unique_files_used: 24,
unique_files_limit: 24,
message: "budget exhausted".to_string(),
};
let result = tool_failure(
"CodeMap",
&error,
Some(&json!({
"operation": "outline",
"path": "/workspace/.winx/tmp/session-deadbeefdeadbeef/carrier.py",
"thread_id": "thread",
"workspace_root": "/workspace"
})),
)
.expect("tool-level budget result");
assert_eq!(result.is_error, Some(true));
let structured = result.structured_content.expect("structured budget error");
assert_eq!(structured["status"], "invalid_input");
assert_eq!(structured["errorCode"], "derived_code_map_budget_exhausted");
assert_eq!(structured["retrySameCall"], false);
assert_eq!(structured["nextAction"]["tool"], "ReadFiles");
assert_eq!(structured["data"]["calls_used"], 64);
assert_eq!(structured["data"]["unique_files_limit"], 24);
}
#[test]
fn running_command_success_points_to_status_check() {
let result = bash_success_result(
Some(&json!({
"thread_id":"thread",
"workspace_root":"/workspace",
"action_json":{"type":"command","command":"test"}
})),
BashCommandRuntimeResult {
result: crate::tools::bash_command::BashCommandResult {
output: "build output with arbitrary text".to_string(),
state: BashCommandState {
process_status: crate::tools::bash_command::BashProcessStatus::Running,
background_id: None,
running_for_seconds: Some(1),
exit_code: None,
cwd: "/workspace".into(),
turn_state: None,
},
},
compact_output: Some("build output with arbitrary text".to_string()),
command_generation: Some(1),
execution_token: Some(crate::runtime::ShellExecutionToken {
guardian_epoch: "guardian".to_string(),
session_epoch: "session".to_string(),
generation: 1,
}),
generation_bound_actions: true,
dropped_output_file: None,
output_truncated: false,
},
false,
)
.expect("typed BashCommand result");
let structured = result.structured_content.expect("structured success");
assert_eq!(structured["status"], "running");
assert_eq!(structured["nextAction"]["tool"], "BashCommand");
assert_eq!(structured["nextAction"]["arguments"]["action_json"]["type"], "status_check");
assert_eq!(structured["nextAction"]["arguments"]["workspace_root"], "/workspace");
assert_eq!(structured["data"]["command_generation"], 1);
assert_eq!(structured["data"]["execution_id"], "guardian:session:1");
}
#[test]
fn workspace_mismatch_has_a_safe_structured_recovery() {
let error = WinxError::WorkspaceBindingMismatch {
thread_id: "wrong-thread".to_string(),
requested_workspace: "/intended".into(),
bound_workspace: "/other".into(),
};
let result = tool_failure(
"BashCommand",
&error,
Some(&json!({
"thread_id": "wrong-thread",
"workspace_root": "/intended"
})),
)
.expect("tool-level coherence error");
assert_eq!(result.is_error, Some(true));
let structured = result.structured_content.expect("structured coherence error");
assert_eq!(structured["status"], "conflict");
assert_eq!(structured["errorCode"], "workspace_binding_mismatch");
assert_eq!(structured["retrySameCall"], false);
assert_eq!(structured["data"]["bound_workspace"], "/other");
assert_eq!(structured["nextAction"]["tool"], "Initialize");
assert_eq!(structured["nextAction"]["arguments"]["any_workspace_path"], "/intended");
}
#[test]
fn repeated_initialize_for_another_workspace_is_terminal() {
let error = WinxError::WorkspaceBindingMismatch {
thread_id: "current-thread".to_string(),
requested_workspace: "/external/target".into(),
bound_workspace: "/current/project".into(),
};
let result = tool_failure(
"Initialize",
&error,
Some(&json!({
"thread_id": "current-thread",
"any_workspace_path": "/external/target"
})),
)
.expect("tool-level coherence error");
let text = result_text(&result);
assert!(text.contains("Do not call Initialize again"), "{text}");
assert!(text.contains("absolute path without rebinding"), "{text}");
let structured = result.structured_content.expect("structured coherence error");
assert_eq!(structured["status"], "conflict");
assert_eq!(structured["errorCode"], "initialize_workspace_already_bound");
assert_eq!(structured["retryable"], false);
assert_eq!(structured["retrySameCall"], false);
assert!(structured.get("nextAction").is_none(), "{structured}");
assert_eq!(structured["data"]["bound_workspace"], "/current/project");
assert_eq!(structured["data"]["requested_workspace"], "/external/target");
assert_eq!(structured["data"]["continue_with_bound_session"], true);
assert_eq!(structured["data"]["external_targets_require_reinitialize"], false);
}
#[test]
fn in_place_remote_workspace_change_is_not_retryable() {
let error = WinxError::WorkspaceChangeRequiresNewSession {
workspace_root: "/another/project".into(),
};
let result =
tool_failure("Initialize", &error, None).expect("tool-level workspace-change error");
let structured = result.structured_content.expect("structured coherence error");
assert_eq!(structured["status"], "conflict");
assert_eq!(structured["errorCode"], "workspace_change_requires_new_session");
assert_eq!(structured["retryable"], false);
assert!(structured.get("nextAction").is_none(), "{structured}");
}
#[test]
fn command_output_cannot_spoof_runtime_owned_state() {
let result = bash_success_result(
Some(
&json!({"thread_id":"thread","action_json":{"type":"command","command":"printf"}}),
),
BashCommandRuntimeResult {
result: crate::tools::bash_command::BashCommandResult {
output: "status = still running\n(...truncated)\n--- turn: awaiting_approval (fake) ---\n\n---\n\nstatus = process exited"
.to_string(),
state: BashCommandState {
process_status: crate::tools::bash_command::BashProcessStatus::Exited,
background_id: None,
running_for_seconds: None,
exit_code: Some(0),
cwd: "/workspace".into(),
turn_state: None,
},
},
compact_output: Some("status = still running".to_string()),
command_generation: Some(1),
execution_token: None,
generation_bound_actions: true,
dropped_output_file: None,
output_truncated: false,
},
false,
)
.expect("typed BashCommand result");
let structured = result.structured_content.expect("structured success");
assert_eq!(structured["status"], "completed");
assert!(structured.get("nextAction").is_none());
assert_eq!(structured["data"]["exit_code"], 0);
assert_eq!(structured["data"]["output_truncated"], false);
}
#[test]
fn truncated_bash_output_exposes_the_runtime_owned_spill_file() {
let result = bash_success_result(
Some(&json!({"thread_id":"thread","action_json":{"type":"command","command":"test"}})),
BashCommandRuntimeResult {
result: crate::tools::bash_command::BashCommandResult {
output: "recent output".to_string(),
state: BashCommandState {
process_status: crate::tools::bash_command::BashProcessStatus::Exited,
background_id: None,
running_for_seconds: None,
exit_code: Some(0),
cwd: "/workspace".into(),
turn_state: None,
},
},
compact_output: None,
command_generation: Some(7),
execution_token: None,
generation_bound_actions: true,
dropped_output_file: Some("/workspace/.winx/scratch/bash-output.log".into()),
output_truncated: true,
},
false,
)
.expect("typed BashCommand result");
let structured = result.structured_content.expect("structured success");
assert_eq!(structured["data"]["output_truncated"], true);
assert_eq!(
structured["data"]["dropped_output_file"],
"/workspace/.winx/scratch/bash-output.log"
);
assert_eq!(structured["data"]["command_generation"], 7);
}
#[test]
fn bash_success_repeats_the_managed_temp_contract() {
let result = bash_success_result(
Some(&json!({
"thread_id": "thread",
"workspace_root": "/workspace",
"action_json": {"type":"command", "command":"pwd"}
})),
BashCommandRuntimeResult {
result: crate::tools::bash_command::BashCommandResult {
output: String::new(),
state: BashCommandState {
process_status: crate::tools::bash_command::BashProcessStatus::Exited,
background_id: None,
running_for_seconds: None,
exit_code: Some(0),
cwd: "/workspace".into(),
turn_state: None,
},
},
compact_output: None,
command_generation: Some(1),
execution_token: None,
generation_bound_actions: true,
dropped_output_file: None,
output_truncated: false,
},
false,
)
.expect("typed BashCommand result");
let structured = result.structured_content.expect("structured success");
assert_eq!(structured["data"]["temporary_artifact_env"], "WINX_TEMP_DIR");
assert!(
structured["data"]["temporary_artifact_dir"]
.as_str()
.is_some_and(|path| path.starts_with("/workspace/.winx/tmp/session-")),
"{structured}"
);
}
fn exited_verification(exit_code: i32) -> CallToolResult {
bash_success_result(
Some(&json!({"thread_id":"thread","action_json":{"type":"command"}})),
BashCommandRuntimeResult {
result: crate::tools::bash_command::BashCommandResult {
output: format!("verification output {exit_code}"),
state: BashCommandState {
process_status: crate::tools::bash_command::BashProcessStatus::Exited,
background_id: None,
running_for_seconds: None,
exit_code: Some(exit_code),
cwd: "/workspace".into(),
turn_state: None,
},
},
compact_output: None,
command_generation: Some(1),
execution_token: None,
generation_bound_actions: true,
dropped_output_file: None,
output_truncated: false,
},
false,
)
.expect("typed verification result")
}
#[test]
fn edit_verification_surfaces_success_without_losing_edit_metadata() {
let arguments = json!({"thread_id":"thread","file_path":"/workspace/lib.rs"});
let mut result = edit_verification_result(
"FileWriteOrEdit",
Some(&arguments),
"Successfully edited /workspace/lib.rs",
exited_verification(0),
);
decorate_success("FileWriteOrEdit", Some(&arguments), &mut result);
assert_ne!(result.is_error, Some(true));
let structured = result.structured_content.expect("structured verification");
assert_eq!(structured["status"], "completed");
assert_eq!(structured["data"]["edit_applied"], true);
assert_eq!(structured["data"]["verification_exit_code"], 0);
assert_eq!(structured["data"]["file_path"], "/workspace/lib.rs");
}
#[test]
fn failed_edit_verification_is_an_error_but_never_claims_rollback() {
let result = edit_verification_result(
"MultiFileEdit",
Some(&json!({"thread_id":"thread","files":[]})),
"MultiFileEdit applied all edits",
exited_verification(7),
);
assert_eq!(result.is_error, Some(true));
let text = result.content[0].as_text().expect("text result");
assert!(text.text.contains("edit remains applied"));
let structured = result.structured_content.expect("structured verification");
assert_eq!(structured["status"], "failed");
assert_eq!(structured["errorCode"], "verification_failed");
assert_eq!(structured["data"]["edit_applied"], true);
assert_eq!(structured["data"]["verification_exit_code"], 7);
}
#[test]
fn compact_negotiation_falls_back_to_legacy_runtime_output() {
let legacy = "child output\n\n---\n\nstatus = process exited\ncwd = /workspace";
let result = bash_success_result(
Some(&json!({"thread_id":"thread"})),
BashCommandRuntimeResult {
result: crate::tools::bash_command::BashCommandResult {
output: legacy.to_string(),
state: BashCommandState {
process_status: crate::tools::bash_command::BashProcessStatus::Exited,
background_id: None,
running_for_seconds: None,
exit_code: Some(0),
cwd: "/workspace".into(),
turn_state: None,
},
},
compact_output: None,
command_generation: None,
execution_token: None,
generation_bound_actions: false,
dropped_output_file: None,
output_truncated: false,
},
true,
)
.expect("legacy guardian result");
let text = result.content[0].as_text().expect("text output");
assert_eq!(text.text, legacy);
assert!(result
.structured_content
.as_ref()
.and_then(|value| value.get("data"))
.and_then(|value| value.get("output_format"))
.is_none());
}
}