Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ use crate::agentic::tools::framework::{
use crate::agentic::tools::ToolPathOperation;
use crate::util::errors::{BitFunError, BitFunResult};
use async_trait::async_trait;
use bitfun_agent_tools::strip_invalid_windows_drive_path_prefix;
use serde_json::{json, Value};
use std::path::Path;
use tokio::fs;
Expand Down Expand Up @@ -237,16 +238,21 @@ impl FileWriteTool {
logical_path: &str,
outcome: WriteLocalFileOutcome,
missing_path_fallback: bool,
path_format_warning: Option<&str>,
ignored_parameter_names: &[String],
) -> ToolResult {
let mut assistant_message = if missing_path_fallback {
format!(
"The Write payload did not start with the required '+++ {{file_path}}' marker. The entire payload was saved to {}. Use your shell tool to rename this file to the intended path instead of calling Write to resubmit the same content.",
"The entire payload was saved to {}. Use your shell tool to move this file to the intended path. Do not call Write to resubmit the same content because doing so wastes tokens and time. This happened because the Write payload did not start with the required '+++ {{file_path}}' marker. Future Write calls must follow the required payload format.",
logical_path
)
} else {
outcome.assistant_message
};
if let Some(warning) = path_format_warning {
assistant_message.push(' ');
assistant_message.push_str(warning);
}
if !ignored_parameter_names.is_empty() {
let formatted_names = ignored_parameter_names
.iter()
Expand All @@ -267,13 +273,23 @@ impl FileWriteTool {
"status": outcome.status.as_str(),
"missing_path_fallback": missing_path_fallback,
"rename_required": missing_path_fallback,
"path_format_corrected": path_format_warning.is_some(),
"path_format_warning": path_format_warning,
"message": assistant_message,
}),
result_for_assistant: Some(assistant_message),
image_attachments: None,
}
}

fn path_format_correction_warning(resolved: &ToolPathResolution) -> Option<String> {
strip_invalid_windows_drive_path_prefix(&resolved.requested_path)?;
Some(format!(
"The provided Windows path '{}' had an invalid leading '/'. It was normalized to '{}'. Use a drive-letter path without the leading '/' in future Write calls.",
resolved.requested_path, resolved.logical_path
))
}

fn input_schema() -> Value {
json!({
"type": "object",
Expand Down Expand Up @@ -306,10 +322,10 @@ Usage:
- Only use emojis if the user explicitly requests it. Avoid writing emojis to files unless asked.

Examples:
<good-example>
`{"payload":"+++ /path/to/main.py\ndef main():\n\tprint(\"Hello world\")\n\nmain()"}`
<good-example platform="macos-linux">
`{"payload":"+++ /example/main.py\ndef main():\n\tprint(\"Hello world\")\n\nmain()"}`

This call creates or overwrites `/path/to/main.py` with the following content:
This call creates or overwrites `/example/main.py` with the following content:
```
def main():
print("Hello world")
Expand All @@ -318,14 +334,18 @@ main()
```
</good-example>

<good-example platform="windows">
`{"payload":"+++ C:/foo/main.py\ndef main():\n\tprint(\"Hello world\")\n\nmain()"}`
</good-example>

<bad-example>
`{"file_path":"/path/to/main.py","content":"print(\"Hello world\")"}`
`{"file_path":"/example/main.py","content":"print(\"Hello world\")"}`

This call is invalid because Write requires the single `payload` parameter. Do not pass `file_path` and `content` separately.
</bad-example>

<bad-example>
`{"payload":"+++ /path/to/main.py\nprint(\"Hello world\")","file_path":"/path/to/main.py"}`
`{"payload":"+++ /example/main.py\nprint(\"Hello world\")","file_path":"/example/main.py"}`

This call includes an unnecessary `file_path` parameter. Write only uses `payload`; specify the target path in the first `+++ {file_path}` line and do not pass additional parameters.
</bad-example>
Expand Down Expand Up @@ -522,6 +542,7 @@ impl Tool for FileWriteTool {
};

let resolved = context.resolve_tool_path(&file_path)?;
let path_format_warning = Self::path_format_correction_warning(&resolved);
context.enforce_path_operation(ToolPathOperation::Write, &resolved)?;
context
.record_light_checkpoint(
Expand All @@ -539,6 +560,7 @@ impl Tool for FileWriteTool {
&resolved.logical_path,
write_same_content_outcome(&resolved.logical_path),
missing_path_fallback,
path_format_warning.as_deref(),
&ignored_parameter_names,
);
return Ok(vec![result]);
Expand Down Expand Up @@ -574,6 +596,7 @@ impl Tool for FileWriteTool {
&resolved.logical_path,
write_file_success_outcome(&resolved.logical_path, file_already_exists, &content),
missing_path_fallback,
path_format_warning.as_deref(),
&ignored_parameter_names,
);
return Ok(vec![result]);
Expand Down Expand Up @@ -609,6 +632,7 @@ impl Tool for FileWriteTool {
&resolved.logical_path,
outcome,
missing_path_fallback,
path_format_warning.as_deref(),
&ignored_parameter_names,
);

Expand Down Expand Up @@ -832,18 +856,6 @@ mod tests {
assert_eq!(data["lines_written"], 0);
}

#[test]
fn description_includes_bad_examples_for_invalid_parameter_shapes() {
let description = FileWriteTool::description();

assert_eq!(description.matches("<bad-example>").count(), 2);
assert!(description
.contains(r#"{"file_path":"/path/to/main.py","content":"print(\"Hello world\")"}"#));
assert!(description.contains(
r#"{"payload":"+++ /path/to/main.py\nprint(\"Hello world\")","file_path":"/path/to/main.py"}"#
));
}

#[tokio::test]
async fn schema_requires_single_payload_parameter() {
let tool = FileWriteTool::new();
Expand Down Expand Up @@ -881,6 +893,60 @@ mod tests {
assert!(validation.message.is_none());
}

#[cfg(windows)]
#[tokio::test]
async fn write_result_reports_normalized_windows_drive_path() {
let tool = FileWriteTool::new();
let context = local_context(PathBuf::from(r"E:\workspace"));
let requested_path = "/E:/workspace/project/example.txt";

let validation = tool
.validate_input(
&json!({
"payload": format!("+++ {requested_path}\ncontent")
}),
Some(&context),
)
.await;
assert!(validation.result);
assert!(validation.message.is_none());

let resolved = context
.resolve_tool_path(requested_path)
.expect("mixed Windows path should be normalized");
assert_eq!(
PathBuf::from(&resolved.logical_path),
PathBuf::from(r"E:\workspace\project\example.txt")
);

let warning = FileWriteTool::path_format_correction_warning(&resolved)
.expect("normalization should produce a warning");
let result = FileWriteTool::write_success_result(
&resolved.logical_path,
super::write_file_success_outcome(&resolved.logical_path, false, "content"),
false,
Some(&warning),
&[],
);
let ToolResult::Result {
data,
result_for_assistant,
..
} = result
else {
panic!("expected result");
};

assert_eq!(data["path_format_corrected"], true);
assert_eq!(data["path_format_warning"], warning);
assert!(warning.contains(requested_path));
assert!(warning.contains(r"E:\workspace\project\example.txt"));
assert!(result_for_assistant
.as_deref()
.unwrap_or_default()
.contains(&warning));
}

#[test]
fn parse_payload_recognizes_marked_path_with_lf_or_crlf() {
for value in [
Expand Down Expand Up @@ -967,10 +1033,17 @@ mod tests {
);
assert_eq!(data["missing_path_fallback"], true);
assert_eq!(data["rename_required"], true);
assert!(result_for_assistant
.as_deref()
.unwrap_or_default()
.contains("Use your shell tool to rename this file"));
let assistant_message = result_for_assistant.as_deref().unwrap_or_default();
assert!(assistant_message
.contains("Use your shell tool to move this file to the intended path"));
assert!(assistant_message.contains(
"Do not call Write to resubmit the same content because doing so wastes tokens and time"
));
assert!(assistant_message.contains(
"the Write payload did not start with the required '+++ {file_path}' marker"
));
assert!(assistant_message
.contains("Future Write calls must follow the required payload format"));

let _ = std::fs::remove_dir_all(&root);
}
Expand Down
22 changes: 22 additions & 0 deletions src/crates/execution/tool-contracts/src/framework.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1798,10 +1798,32 @@ pub fn normalize_host_path(path: &str) -> String {
.to_string()
}

/// Returns a Windows drive path without an invalid POSIX-style leading slash.
/// Non-Windows hosts leave the same spelling available for POSIX path semantics.
pub fn strip_invalid_windows_drive_path_prefix(path: &str) -> Option<&str> {
#[cfg(windows)]
{
let bytes = path.as_bytes();
if bytes.len() >= 4
&& bytes[0] == b'/'
&& bytes[1].is_ascii_alphabetic()
&& bytes[2] == b':'
&& matches!(bytes[3], b'/' | b'\\')
{
return Some(&path[1..]);
}
}

let _ = path;
None
}

pub fn resolve_host_path_with_workspace(
path: &str,
workspace_root: Option<&Path>,
) -> Result<String, ToolPathContractError> {
let path = strip_invalid_windows_drive_path_prefix(path).unwrap_or(path);

if Path::new(path).is_absolute() {
Ok(normalize_host_path(path))
} else {
Expand Down
37 changes: 19 additions & 18 deletions src/crates/execution/tool-contracts/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -78,24 +78,25 @@ pub use framework::{
resolve_host_path, resolve_host_path_with_workspace, resolve_readonly_enabled_tools,
resolve_tool_manifest_policy, resolve_tool_path_with_context,
resolve_tool_path_with_context_roots, resolve_workspace_tool_path,
sort_tool_manifest_definitions, summarize_get_tool_spec_deferred_tools,
tool_manifest_sort_rank, tool_path_is_effectively_absolute,
tool_restrictions_for_delegation_policy, validate_deferred_tool_usage,
validate_get_tool_spec_input, validate_tool_allowed_by_list, ContextualToolManifest,
ContextualToolManifestItem, ContextualVisibleTools, DeferredToolUsageError, DynamicMcpToolInfo,
DynamicToolInfo, GetToolSpecCatalogProvider, GetToolSpecDeferredToolSummary, GetToolSpecDetail,
GetToolSpecExecutionError, GetToolSpecExecutionPlan, GetToolSpecLoadObservation,
GetToolSpecRuntime, LoadedDeferredToolSpec, ParsedBitFunCurrentSessionUri,
ParsedBitFunRuntimeUri, PortableToolContextProvider, PromptVisibleToolManifestItem,
SnapshotToolDecorator, SnapshotToolWrapper, SnapshotToolWrapperRef,
StaticToolMaterializationError, StaticToolProvider, StaticToolProviderFactory,
StaticToolProviderGroup, StaticToolProviderPlan, ToolCatalogRuntime,
ToolCatalogSnapshotProvider, ToolContextFacts, ToolDecoratorRef, ToolExecutionAccessError,
ToolExposure, ToolManifestDefinition, ToolManifestPolicyResolution, ToolManifestPolicyTool,
ToolPathBackend, ToolPathContractError, ToolPathOperation, ToolPathPolicy, ToolPathResolution,
ToolRef, ToolRegistry, ToolRegistryItem, ToolRenderOptions, ToolRestrictionError, ToolResult,
ToolRuntimeAssembly, ToolRuntimeRestrictions, ToolWorkspaceKind, ValidationResult,
BITFUN_CURRENT_SESSION_URI_PREFIX, BITFUN_RUNTIME_URI_PREFIX, GET_TOOL_SPEC_TOOL_NAME,
sort_tool_manifest_definitions, strip_invalid_windows_drive_path_prefix,
summarize_get_tool_spec_deferred_tools, tool_manifest_sort_rank,
tool_path_is_effectively_absolute, tool_restrictions_for_delegation_policy,
validate_deferred_tool_usage, validate_get_tool_spec_input, validate_tool_allowed_by_list,
ContextualToolManifest, ContextualToolManifestItem, ContextualVisibleTools,
DeferredToolUsageError, DynamicMcpToolInfo, DynamicToolInfo, GetToolSpecCatalogProvider,
GetToolSpecDeferredToolSummary, GetToolSpecDetail, GetToolSpecExecutionError,
GetToolSpecExecutionPlan, GetToolSpecLoadObservation, GetToolSpecRuntime,
LoadedDeferredToolSpec, ParsedBitFunCurrentSessionUri, ParsedBitFunRuntimeUri,
PortableToolContextProvider, PromptVisibleToolManifestItem, SnapshotToolDecorator,
SnapshotToolWrapper, SnapshotToolWrapperRef, StaticToolMaterializationError,
StaticToolProvider, StaticToolProviderFactory, StaticToolProviderGroup, StaticToolProviderPlan,
ToolCatalogRuntime, ToolCatalogSnapshotProvider, ToolContextFacts, ToolDecoratorRef,
ToolExecutionAccessError, ToolExposure, ToolManifestDefinition, ToolManifestPolicyResolution,
ToolManifestPolicyTool, ToolPathBackend, ToolPathContractError, ToolPathOperation,
ToolPathPolicy, ToolPathResolution, ToolRef, ToolRegistry, ToolRegistryItem, ToolRenderOptions,
ToolRestrictionError, ToolResult, ToolRuntimeAssembly, ToolRuntimeRestrictions,
ToolWorkspaceKind, ValidationResult, BITFUN_CURRENT_SESSION_URI_PREFIX,
BITFUN_RUNTIME_URI_PREFIX, GET_TOOL_SPEC_TOOL_NAME,
};
pub use input_validator::InputValidator;
#[cfg(feature = "mcp-bridge")]
Expand Down
44 changes: 44 additions & 0 deletions src/crates/execution/tool-contracts/tests/tool_contracts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1697,6 +1697,50 @@ fn host_path_contract_keeps_local_workspace_resolution_semantics() {
);
}

#[cfg(windows)]
#[test]
fn host_path_contract_normalizes_posix_prefixed_windows_drive_paths() {
let workspace = PathBuf::from(r"E:\workspace");

for (malformed, expected) in [
(
"/E:/workspace/project/example.txt",
r"E:\workspace\project\example.txt",
),
(
"/e:/workspace/project/example.txt",
r"e:\workspace\project\example.txt",
),
(
r"/E:\workspace\project\example.txt",
r"E:\workspace\project\example.txt",
),
] {
let resolved = resolve_host_path_with_workspace(malformed, Some(workspace.as_path()))
.expect("mixed POSIX and Windows drive syntax should be normalized");

assert_eq!(PathBuf::from(resolved), PathBuf::from(expected));
}

let valid = resolve_host_path_with_workspace(
"E:/workspace/project/example.txt",
Some(workspace.as_path()),
)
.expect("native Windows drive syntax must remain valid");
assert_eq!(
PathBuf::from(valid),
PathBuf::from(r"E:\workspace\project\example.txt")
);

let remote = resolve_workspace_tool_path(
"/E:/workspace/project/example.txt",
Some("/workspace"),
true,
)
.expect("remote workspaces keep POSIX path semantics");
assert_eq!(remote, "/E:/workspace/project/example.txt");
}

#[test]
fn unified_tool_path_contract_selects_host_or_remote_semantics() {
let local = resolve_workspace_tool_path("src/lib.rs", Some("/repo/project"), false)
Expand Down
Loading