Skip to content
Merged
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ Fix root cause. Unsure: read more code; if stuck, ask w/ short options. Unrecogn
| 012-eval | LLM evaluation harness, dataset format, scoring |
| 012-maintenance | Pre-release maintenance checklist |
| 013-python-package | Python bindings, PyPI wheels, platform matrix |
| 014-scripted-tool-orchestration | Compose ToolDef+callback pairs into OrchestratorTool via bash scripts |

### Documentation

Expand Down
7 changes: 7 additions & 0 deletions crates/bashkit/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,9 @@ logging = ["tracing"]
# Phase 2 will add gix dependency for remote operations
# Usage: cargo build --features git
git = []
# Enable ScriptedTool: compose ToolDef+callback pairs into a single Tool
# Usage: cargo build --features scripted_tool
scripted_tool = []
# Enable python/python3 builtins via embedded Monty interpreter
# Monty is a git dep (not yet on crates.io) — feature unavailable from registry
python = ["dep:monty"]
Expand All @@ -101,6 +104,10 @@ required-features = ["http_client"]
name = "git_workflow"
required-features = ["git"]

[[example]]
name = "scripted_tool"
required-features = ["scripted_tool"]

[[example]]
name = "python_scripts"
required-features = ["python"]
229 changes: 229 additions & 0 deletions crates/bashkit/examples/scripted_tool.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
//! Scripted Tool Example
//!
//! Demonstrates composing multiple API-like tools (ToolDef + closures) into a
//! single ScriptedTool that an LLM agent can call with bash scripts.
//!
//! Run with: cargo run --example scripted_tool --features scripted_tool
//!
//! This example simulates an e-commerce API with tools for users, orders, and
//! inventory. The ScriptedTool lets an agent compose these in one call.

use bashkit::{ScriptedTool, Tool, ToolDef, ToolRequest};

#[tokio::main]
async fn main() -> anyhow::Result<()> {
println!("=== Scripted Tool Demo ===\n");

// Build the orchestrator with tool definitions + closures
let mut tool = ScriptedTool::builder("ecommerce_api")
.short_description("E-commerce API orchestrator with user, order, and inventory tools")
.tool(
ToolDef::new("get_user", "Fetch user by ID")
.with_schema(serde_json::json!({
"type": "object",
"properties": {
"id": {"type": "integer", "description": "User ID"}
},
"required": ["id"]
})),
|args| {
let id = args.param_i64("id").ok_or("missing --id")?;

let users = [
(1, "Alice", "alice@example.com", "premium"),
(2, "Bob", "bob@example.com", "basic"),
(3, "Charlie", "charlie@example.com", "premium"),
];

match users.iter().find(|(uid, ..)| *uid == id) {
Some((uid, name, email, tier)) => Ok(format!(
"{{\"id\":{uid},\"name\":\"{name}\",\"email\":\"{email}\",\"tier\":\"{tier}\"}}\n"
)),
None => Err(format!("user {} not found", id)),
}
},
)
.tool(
ToolDef::new("list_orders", "List orders for a user")
.with_schema(serde_json::json!({
"type": "object",
"properties": {
"user_id": {"type": "integer", "description": "User ID"}
},
"required": ["user_id"]
})),
|args| {
let uid = args.param_i64("user_id").ok_or("missing --user_id")?;

let orders = match uid {
1 => r#"[{"order_id":101,"item":"Laptop","qty":1,"price":999.99},{"order_id":102,"item":"Mouse","qty":2,"price":29.99}]"#,
2 => r#"[{"order_id":201,"item":"Keyboard","qty":1,"price":79.99}]"#,
3 => r#"[]"#,
_ => return Err(format!("no orders for user {}", uid)),
};

Ok(format!("{orders}\n"))
},
)
.tool(
ToolDef::new("get_inventory", "Check inventory for an item")
.with_schema(serde_json::json!({
"type": "object",
"properties": {
"item": {"type": "string", "description": "Item name"}
},
"required": ["item"]
})),
|args| {
let item = args.param_str("item").ok_or("missing --item")?;

let stock = match item.to_lowercase().as_str() {
"laptop" => 15,
"mouse" => 142,
"keyboard" => 67,
_ => 0,
};

Ok(format!(
"{{\"item\":\"{}\",\"in_stock\":{}}}\n",
item, stock
))
},
)
.tool(
ToolDef::new("create_discount", "Create a discount code")
.with_schema(serde_json::json!({
"type": "object",
"properties": {
"user_id": {"type": "integer", "description": "User ID"},
"percent": {"type": "integer", "description": "Discount percentage"}
},
"required": ["user_id", "percent"]
})),
|args| {
let uid = args.param_i64("user_id").ok_or("missing --user_id")?;
let pct = args.param_i64("percent").ok_or("missing --percent")?;
Ok(format!(
"{{\"code\":\"SAVE{pct}-U{uid}\",\"percent\":{pct},\"user_id\":{uid}}}\n"
))
},
)
.env("STORE_NAME", "Bashkit Shop")
.build();

// ---- Show what the LLM sees ----
println!("--- Tool name ---");
println!("{}\n", tool.name());

println!("--- System prompt (what goes in LLM system message) ---");
println!("{}", tool.system_prompt());

// ---- Demo 1: Simple single tool call ----
println!("--- Demo 1: Single tool call ---");
let resp = tool
.execute(ToolRequest {
commands: "get_user --id 1".to_string(),
})
.await;
println!("$ get_user --id 1");
println!("{}", resp.stdout);

// ---- Demo 2: Pipeline with jq ----
println!("--- Demo 2: Pipeline with jq ---");
let resp = tool
.execute(ToolRequest {
commands: "get_user --id 1 | jq -r '.name'".to_string(),
})
.await;
println!("$ get_user --id 1 | jq -r '.name'");
println!("{}", resp.stdout);

// ---- Demo 3: Multi-step orchestration ----
println!("--- Demo 3: Multi-step orchestration ---");
let script = r#"
user=$(get_user --id 1)
name=$(echo "$user" | jq -r '.name')
tier=$(echo "$user" | jq -r '.tier')
orders=$(list_orders --user_id 1)
total=$(echo "$orders" | jq '[.[].price] | add')
count=$(echo "$orders" | jq 'length')
echo "Customer: $name (tier: $tier)"
echo "Orders: $count, Estimated total: $total"
"#;
let resp = tool
.execute(ToolRequest {
commands: script.to_string(),
})
.await;
println!("$ <multi-step script>");
print!("{}", resp.stdout);
println!();

// ---- Demo 4: Loop + conditional ----
println!("--- Demo 4: Loop with conditional ---");
let script = r#"
for uid in 1 2 3; do
user=$(get_user --id $uid)
name=$(echo "$user" | jq -r '.name')
tier=$(echo "$user" | jq -r '.tier')
if [ "$tier" = "premium" ]; then
echo "$name is premium - creating discount"
create_discount --user_id $uid --percent 20 | jq -r '.code'
else
echo "$name is $tier - no discount"
fi
done
"#;
let resp = tool
.execute(ToolRequest {
commands: script.to_string(),
})
.await;
println!("$ <loop with conditional>");
print!("{}", resp.stdout);
println!();

// ---- Demo 5: Inventory check with error handling ----
println!("--- Demo 5: Error handling ---");
let script = r#"
for item in Laptop Mouse Keyboard Widget; do
result=$(get_inventory --item "$item")
stock=$(echo "$result" | jq '.in_stock')
if [ "$stock" -eq 0 ]; then
echo "$item: OUT OF STOCK"
else
echo "$item: $stock in stock"
fi
done
"#;
let resp = tool
.execute(ToolRequest {
commands: script.to_string(),
})
.await;
println!("$ <inventory check>");
print!("{}", resp.stdout);
println!();

// ---- Demo 6: Data aggregation ----
println!("--- Demo 6: Aggregate data across tools ---");
let script = r#"
echo "=== $STORE_NAME Report ==="
for uid in 1 2; do
name=$(get_user --id $uid | jq -r '.name')
orders=$(list_orders --user_id $uid)
count=$(echo "$orders" | jq 'length')
echo "$name: $count orders"
done
"#;
let resp = tool
.execute(ToolRequest {
commands: script.to_string(),
})
.await;
println!("$ <aggregate report>");
print!("{}", resp.stdout);

println!("\n=== Demo Complete ===");
Ok(())
}
7 changes: 7 additions & 0 deletions crates/bashkit/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -372,6 +372,10 @@ mod logging_impl;
mod network;
/// Parser module - exposed for fuzzing and testing
pub mod parser;
/// Scripted tool: compose ToolDef+callback pairs into a single Tool via bash scripts.
/// Requires the `scripted_tool` feature.
#[cfg(feature = "scripted_tool")]
pub mod scripted_tool;
/// Tool contract for LLM integration
pub mod tool;

Expand All @@ -388,6 +392,9 @@ pub use limits::{ExecutionCounters, ExecutionLimits, LimitExceeded};
pub use network::NetworkAllowlist;
pub use tool::{BashTool, BashToolBuilder, Tool, ToolRequest, ToolResponse, ToolStatus, VERSION};

#[cfg(feature = "scripted_tool")]
pub use scripted_tool::{ScriptedTool, ScriptedToolBuilder, ToolArgs, ToolCallback, ToolDef};

#[cfg(feature = "http_client")]
pub use network::HttpClient;

Expand Down
Loading
Loading