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
4 changes: 2 additions & 2 deletions crates/bashkit-cli/src/mcp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -709,7 +709,7 @@ mod tests {
fn make_test_tool() -> ScriptedTool {
ScriptedTool::builder("test_api")
.short_description("Test API tool")
.tool(ToolDef::new("greet", "Greet someone"), |args: &ToolArgs| {
.tool_fn(ToolDef::new("greet", "Greet someone"), |args: &ToolArgs| {
let name = args.param_str("name").unwrap_or("world");
Ok(format!("hello {name}\n"))
})
Expand Down Expand Up @@ -759,7 +759,7 @@ mod tests {
let mut server = McpServer::new(bashkit::Bash::new);
let tool = ScriptedTool::builder("err_api")
.short_description("Error API")
.tool(ToolDef::new("fail", "Always fails"), |_args: &ToolArgs| {
.tool_fn(ToolDef::new("fail", "Always fails"), |_args: &ToolArgs| {
Err("service down".to_string())
})
.build();
Expand Down
2 changes: 1 addition & 1 deletion crates/bashkit-eval/src/scripting_agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ pub async fn run_scripted_agent(
for mock_tool in &task.tools {
let def = build_tool_def(mock_tool);
let callback = make_mock_callback(mock_tool.mock.clone());
builder = builder.tool(def, move |args: &ToolArgs| callback(args));
builder = builder.tool_fn(def, move |args: &ToolArgs| callback(args));
}
if task.discovery_mode {
builder = builder.with_discovery();
Expand Down
2 changes: 1 addition & 1 deletion crates/bashkit-js/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1544,7 +1544,7 @@ impl ScriptedTool {
.map_err(|_| format!("{}: callback channel closed", tool_name))?
};

builder = builder.tool(
builder = builder.tool_fn(
ToolDef::new(&entry.name, &entry.description).with_schema(entry.schema.clone()),
callback,
);
Expand Down
4 changes: 2 additions & 2 deletions crates/bashkit-python/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1577,7 +1577,7 @@ impl ScriptedTool {
})
})
};
builder = builder.tool(def, callback);
builder = builder.tool_fn(def, callback);
} else {
// Sync callback: ctx.run(fn, params, stdin) with ContextVars.
let callback = move |args: &ToolArgs| -> Result<String, String> {
Expand All @@ -1593,7 +1593,7 @@ impl ScriptedTool {
})
})
};
builder = builder.tool(def, callback);
builder = builder.tool_fn(def, callback);
}
}

Expand Down
8 changes: 4 additions & 4 deletions crates/bashkit/examples/scripted_tool.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,10 @@ async fn main() -> anyhow::Result<()> {
// In production the callbacks would call real APIs.
let tool = ScriptedTool::builder("ecommerce_api")
.short_description("E-commerce API orchestrator with user, order, and inventory tools")
.tool(fakes::get_user_def(), fakes::get_user)
.tool(fakes::list_orders_def(), fakes::list_orders)
.tool(fakes::get_inventory_def(), fakes::get_inventory)
.tool(fakes::create_discount_def(), fakes::create_discount)
.tool_fn(fakes::get_user_def(), fakes::get_user)
.tool_fn(fakes::list_orders_def(), fakes::list_orders)
.tool_fn(fakes::get_inventory_def(), fakes::get_inventory)
.tool_fn(fakes::create_discount_def(), fakes::create_discount)
.env("STORE_NAME", "Bashkit Shop")
.build();

Expand Down
5 changes: 5 additions & 0 deletions crates/bashkit/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -418,6 +418,9 @@ mod snapshot;
mod ssh;
/// Tool contract for LLM integration
pub mod tool;
/// Reusable tool primitives: ToolDef, ToolArgs, ToolImpl, exec types.
#[cfg(feature = "scripted_tool")]
pub(crate) mod tool_def;
/// Structured execution trace events.
pub mod trace;

Expand Down Expand Up @@ -456,6 +459,8 @@ pub use scripted_tool::{
ScriptedCommandKind, ScriptedExecutionTrace, ScriptedTool, ScriptedToolBuilder,
ScriptingToolSet, ScriptingToolSetBuilder, ToolArgs, ToolCallback, ToolDef,
};
#[cfg(feature = "scripted_tool")]
pub use tool_def::{AsyncToolExec, SyncToolExec, ToolImpl};

#[cfg(feature = "http_client")]
pub use network::{HttpClient, HttpHandler};
Expand Down
131 changes: 14 additions & 117 deletions crates/bashkit/src/scripted_tool/execute.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
//! ScriptedTool execution: Tool impl, builtin adapter, flag parser, documentation helpers.
//! ScriptedTool execution: Tool impl, builtin adapter, documentation helpers.

use super::{
CallbackKind, ScriptedCommandInvocation, ScriptedCommandKind, ScriptedExecutionTrace,
Expand All @@ -12,6 +12,7 @@ use crate::tool::{
Tool, ToolError, ToolExecution, ToolOutputChunk, ToolRequest, ToolResponse, ToolStatus,
VERSION, localized, tool_output_from_response, tool_request_from_value,
};
use crate::tool_def::{parse_flags, usage_from_schema};
use async_trait::async_trait;
use schemars::schema_for;
use std::sync::{Arc, Mutex};
Expand All @@ -34,110 +35,6 @@ fn push_invocation(
});
}

// ============================================================================
// Flag parser — `--key value` / `--key=value` → JSON object
// ============================================================================

/// Parse `--key value` and `--key=value` flags into a JSON object.
/// Types are coerced according to the schema's property definitions.
/// Unknown flags (not in schema) are kept as strings.
/// Bare `--flag` without a value is treated as `true` if the schema says boolean,
/// otherwise as `true` when the next arg also starts with `--` or is absent.
fn parse_flags(
raw_args: &[String],
schema: &serde_json::Value,
) -> std::result::Result<serde_json::Value, String> {
let properties = schema
.get("properties")
.and_then(|p| p.as_object())
.cloned()
.unwrap_or_default();

let mut result = serde_json::Map::new();
let mut i = 0;

while i < raw_args.len() {
let arg = &raw_args[i];

let Some(flag) = arg.strip_prefix("--") else {
return Err(format!("expected --flag, got: {arg}"));
};

// --key=value
if let Some((key, raw_value)) = flag.split_once('=') {
let value = coerce_value(raw_value, properties.get(key));
result.insert(key.to_string(), value);
i += 1;
continue;
}

// --flag (boolean) or --key value
let key = flag;
let prop_schema = properties.get(key);
let is_boolean = prop_schema
.and_then(|s| s.get("type"))
.and_then(|t| t.as_str())
== Some("boolean");

if is_boolean {
result.insert(key.to_string(), serde_json::Value::Bool(true));
i += 1;
} else if i + 1 < raw_args.len() && !raw_args[i + 1].starts_with("--") {
let raw_value = &raw_args[i + 1];
let value = coerce_value(raw_value, prop_schema);
result.insert(key.to_string(), value);
i += 2;
} else {
// No value follows and not boolean — treat as true
result.insert(key.to_string(), serde_json::Value::Bool(true));
i += 1;
}
}

Ok(serde_json::Value::Object(result))
}

/// Coerce a raw string value to the type declared in the property schema.
fn coerce_value(raw: &str, prop_schema: Option<&serde_json::Value>) -> serde_json::Value {
let type_str = prop_schema
.and_then(|s| s.get("type"))
.and_then(|t| t.as_str())
.unwrap_or("string");

match type_str {
"integer" => raw
.parse::<i64>()
.map(serde_json::Value::from)
.unwrap_or_else(|_| serde_json::Value::String(raw.to_string())),
"number" => raw
.parse::<f64>()
.map(|n| serde_json::json!(n))
.unwrap_or_else(|_| serde_json::Value::String(raw.to_string())),
"boolean" => match raw {
"true" | "1" | "yes" => serde_json::Value::Bool(true),
"false" | "0" | "no" => serde_json::Value::Bool(false),
_ => serde_json::Value::String(raw.to_string()),
},
_ => serde_json::Value::String(raw.to_string()),
}
}

/// Generate a usage hint from schema properties: `--id <integer> --name <string>`.
fn usage_from_schema(schema: &serde_json::Value) -> Option<String> {
let props = schema.get("properties")?.as_object()?;
if props.is_empty() {
return None;
}
let flags: Vec<String> = props
.iter()
.map(|(key, prop)| {
let ty = prop.get("type").and_then(|t| t.as_str()).unwrap_or("value");
format!("--{key} <{ty}>")
})
.collect();
Some(flags.join(" "))
}

// ============================================================================
// ToolBuiltinAdapter — wraps ToolCallback as a Builtin
// ============================================================================
Expand Down Expand Up @@ -795,7 +692,7 @@ mod tests {
fn build_help_test_tool() -> ScriptedTool {
ScriptedTool::builder("test_api")
.short_description("Test API")
.tool(
.tool_fn(
ToolDef::new("get_user", "Fetch user by ID").with_schema(serde_json::json!({
"type": "object",
"properties": {
Expand All @@ -804,7 +701,7 @@ mod tests {
})),
|_args: &super::ToolArgs| Ok("{\"id\":1}\n".to_string()),
)
.tool(
.tool_fn(
ToolDef::new("list_orders", "List orders for user").with_schema(
serde_json::json!({
"type": "object",
Expand Down Expand Up @@ -909,7 +806,7 @@ mod tests {
async fn test_compact_prompt_omits_usage() {
let tool = ScriptedTool::builder("compact_test")
.compact_prompt(true)
.tool(
.tool_fn(
ToolDef::new("get_user", "Fetch user").with_schema(serde_json::json!({
"type": "object",
"properties": { "id": {"type": "integer"} }
Expand All @@ -925,7 +822,7 @@ mod tests {
#[tokio::test]
async fn test_non_compact_prompt_has_usage() {
let tool = ScriptedTool::builder("full_test")
.tool(
.tool_fn(
ToolDef::new("get_user", "Fetch user").with_schema(serde_json::json!({
"type": "object",
"properties": { "id": {"type": "integer"} }
Expand All @@ -945,7 +842,7 @@ mod tests {

let tool = ScriptedTool::builder("test")
.short_description("test")
.tool(
.tool_fn(
ToolDef::new("fail", "Always fails"),
|_args: &super::ToolArgs| Err("service error".to_string()),
)
Expand All @@ -969,31 +866,31 @@ mod tests {
fn build_discover_test_tool() -> ScriptedTool {
ScriptedTool::builder("big_api")
.short_description("Big API")
.tool(
.tool_fn(
ToolDef::new("create_charge", "Create a payment charge")
.with_category("payments")
.with_tags(&["billing", "write"]),
|_args: &super::ToolArgs| Ok("ok\n".to_string()),
)
.tool(
.tool_fn(
ToolDef::new("refund", "Issue a refund")
.with_category("payments")
.with_tags(&["billing", "write"]),
|_args: &super::ToolArgs| Ok("ok\n".to_string()),
)
.tool(
.tool_fn(
ToolDef::new("get_user", "Fetch user by ID")
.with_category("users")
.with_tags(&["read"]),
|_args: &super::ToolArgs| Ok("ok\n".to_string()),
)
.tool(
.tool_fn(
ToolDef::new("delete_user", "Delete a user account")
.with_category("users")
.with_tags(&["admin", "write"]),
|_args: &super::ToolArgs| Ok("ok\n".to_string()),
)
.tool(
.tool_fn(
ToolDef::new("get_inventory", "Check inventory levels").with_category("inventory"),
|_args: &super::ToolArgs| Ok("ok\n".to_string()),
)
Expand Down Expand Up @@ -1154,7 +1051,7 @@ mod tests {
#[tokio::test]
async fn test_callback_error_sanitized_by_default() {
let tool = ScriptedTool::builder("api")
.tool(
.tool_fn(
ToolDef::new("fail", "Always fails"),
|_args: &super::ToolArgs| {
Err("connection failed: postgres://admin:secret@internal-db:5432/prod".into())
Expand All @@ -1181,7 +1078,7 @@ mod tests {
async fn test_callback_error_unsanitized_when_disabled() {
let tool = ScriptedTool::builder("api")
.sanitize_errors(false)
.tool(
.tool_fn(
ToolDef::new("fail", "Always fails"),
|_args: &super::ToolArgs| {
Err("connection failed: postgres://admin:secret@internal-db:5432/prod".into())
Expand Down
Loading
Loading