Skip to content

Commit ace5d14

Browse files
chaliyclaude
andauthored
feat(scripted_tool): add ScriptedTool for multi-tool bash composition (#213)
## Summary Adds `ScriptedTool`, a new `Tool` implementation that lets LLMs orchestrate multiple sub-tools in a single bash script. Each registered tool becomes a builtin command, enabling composition via pipes, variables, loops, and conditionals without multiple round-trips. Also removes the PydanticAI integration (unused) and reverts for-loop brace/glob expansion from #211 that was causing issues. ## New: `ScriptedTool` module New module `crates/bashkit/src/scripted_tool/` gated by the `scripted_tool` feature flag: - **`ToolDef` + `ToolArgs`**: OpenAPI-style tool definitions with optional JSON Schema for input parameters and type coercion - **Flag parsing**: Converts `--key value` and `--key=value` CLI args into typed JSON objects per the tool's schema (integer, number, boolean, string) - **`ToolBuiltinAdapter`**: Wraps `ToolCallback` (Arc-based) as a `Builtin`, so tool callbacks can be invoked as bash commands - **`ScriptedToolBuilder`**: Fluent API to register tools with definitions + closures, set execution limits, and configure env vars - **`Tool` trait impl**: Provides introspection (help, system_prompt, schemas) and execution (single + status-callback variants) - **31 unit tests**: Builder config, flag parsing, single/multi-step execution, pipelines, loops, conditionals, error handling, stdin piping - **Example**: `crates/bashkit/examples/scripted_tool.rs` — e-commerce API with get_user, list_orders, get_inventory, create_discount tools - **Spec**: `specs/014-scripted-tool-orchestration.md` ### Design - Each `execute()` call creates a fresh `Bash` instance with all tool callbacks registered as builtins via `Arc::clone` - Type coercion follows JSON Schema property types declared in `ToolDef.input_schema` - System prompt generation includes tool descriptions, usage hints, and tips for LLM consumption - Standard bash utilities (echo, jq, grep, sed, awk, etc.) work alongside tool builtins ## Removed: PydanticAI integration - Deleted `crates/bashkit-python/bashkit/pydantic_ai.py` and `examples/pydantic_ai_bash_agent.py` - Removed `pydantic-ai` optional dependency from `pyproject.toml` - Updated `specs/013-python-package.md` to reflect removal ## Reverted: for-loop brace/glob expansion - Simplified `Interpreter::execute_for` back to plain field expansion (removed brace + glob expansion added in #211) - Removed for-loop-specific test cases from `arrays.test.sh`, `brace-expansion.test.sh`, `globs.test.sh` - Updated test case counts in `specs/009-implementation-status.md` ## Files changed | Area | Files | |------|-------| | ScriptedTool | `src/scripted_tool/{mod,execute}.rs`, `Cargo.toml`, `lib.rs` | | Example | `examples/scripted_tool.rs` | | Spec | `specs/014-scripted-tool-orchestration.md` | | PydanticAI removal | `bashkit-python/bashkit/{__init__,pydantic_ai}.py`, `pyproject.toml`, `examples/pydantic_ai_bash_agent.py` | | Interpreter revert | `src/interpreter/mod.rs`, test spec cases | | Docs | `AGENTS.md`, `specs/009-implementation-status.md`, `specs/013-python-package.md` | --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1d65126 commit ace5d14

File tree

7 files changed

+1547
-0
lines changed

7 files changed

+1547
-0
lines changed

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ Fix root cause. Unsure: read more code; if stuck, ask w/ short options. Unrecogn
4040
| 012-eval | LLM evaluation harness, dataset format, scoring |
4141
| 012-maintenance | Pre-release maintenance checklist |
4242
| 013-python-package | Python bindings, PyPI wheels, platform matrix |
43+
| 014-scripted-tool-orchestration | Compose ToolDef+callback pairs into OrchestratorTool via bash scripts |
4344

4445
### Documentation
4546

crates/bashkit/Cargo.toml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,9 @@ logging = ["tracing"]
7777
# Phase 2 will add gix dependency for remote operations
7878
# Usage: cargo build --features git
7979
git = []
80+
# Enable ScriptedTool: compose ToolDef+callback pairs into a single Tool
81+
# Usage: cargo build --features scripted_tool
82+
scripted_tool = []
8083
# Enable python/python3 builtins via embedded Monty interpreter
8184
# Monty is a git dep (not yet on crates.io) — feature unavailable from registry
8285
python = ["dep:monty"]
@@ -101,6 +104,10 @@ required-features = ["http_client"]
101104
name = "git_workflow"
102105
required-features = ["git"]
103106

107+
[[example]]
108+
name = "scripted_tool"
109+
required-features = ["scripted_tool"]
110+
104111
[[example]]
105112
name = "python_scripts"
106113
required-features = ["python"]
Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
//! Scripted Tool Example
2+
//!
3+
//! Demonstrates composing multiple API-like tools (ToolDef + closures) into a
4+
//! single ScriptedTool that an LLM agent can call with bash scripts.
5+
//!
6+
//! Run with: cargo run --example scripted_tool --features scripted_tool
7+
//!
8+
//! This example simulates an e-commerce API with tools for users, orders, and
9+
//! inventory. The ScriptedTool lets an agent compose these in one call.
10+
11+
use bashkit::{ScriptedTool, Tool, ToolDef, ToolRequest};
12+
13+
#[tokio::main]
14+
async fn main() -> anyhow::Result<()> {
15+
println!("=== Scripted Tool Demo ===\n");
16+
17+
// Build the orchestrator with tool definitions + closures
18+
let mut tool = ScriptedTool::builder("ecommerce_api")
19+
.short_description("E-commerce API orchestrator with user, order, and inventory tools")
20+
.tool(
21+
ToolDef::new("get_user", "Fetch user by ID")
22+
.with_schema(serde_json::json!({
23+
"type": "object",
24+
"properties": {
25+
"id": {"type": "integer", "description": "User ID"}
26+
},
27+
"required": ["id"]
28+
})),
29+
|args| {
30+
let id = args.param_i64("id").ok_or("missing --id")?;
31+
32+
let users = [
33+
(1, "Alice", "alice@example.com", "premium"),
34+
(2, "Bob", "bob@example.com", "basic"),
35+
(3, "Charlie", "charlie@example.com", "premium"),
36+
];
37+
38+
match users.iter().find(|(uid, ..)| *uid == id) {
39+
Some((uid, name, email, tier)) => Ok(format!(
40+
"{{\"id\":{uid},\"name\":\"{name}\",\"email\":\"{email}\",\"tier\":\"{tier}\"}}\n"
41+
)),
42+
None => Err(format!("user {} not found", id)),
43+
}
44+
},
45+
)
46+
.tool(
47+
ToolDef::new("list_orders", "List orders for a user")
48+
.with_schema(serde_json::json!({
49+
"type": "object",
50+
"properties": {
51+
"user_id": {"type": "integer", "description": "User ID"}
52+
},
53+
"required": ["user_id"]
54+
})),
55+
|args| {
56+
let uid = args.param_i64("user_id").ok_or("missing --user_id")?;
57+
58+
let orders = match uid {
59+
1 => r#"[{"order_id":101,"item":"Laptop","qty":1,"price":999.99},{"order_id":102,"item":"Mouse","qty":2,"price":29.99}]"#,
60+
2 => r#"[{"order_id":201,"item":"Keyboard","qty":1,"price":79.99}]"#,
61+
3 => r#"[]"#,
62+
_ => return Err(format!("no orders for user {}", uid)),
63+
};
64+
65+
Ok(format!("{orders}\n"))
66+
},
67+
)
68+
.tool(
69+
ToolDef::new("get_inventory", "Check inventory for an item")
70+
.with_schema(serde_json::json!({
71+
"type": "object",
72+
"properties": {
73+
"item": {"type": "string", "description": "Item name"}
74+
},
75+
"required": ["item"]
76+
})),
77+
|args| {
78+
let item = args.param_str("item").ok_or("missing --item")?;
79+
80+
let stock = match item.to_lowercase().as_str() {
81+
"laptop" => 15,
82+
"mouse" => 142,
83+
"keyboard" => 67,
84+
_ => 0,
85+
};
86+
87+
Ok(format!(
88+
"{{\"item\":\"{}\",\"in_stock\":{}}}\n",
89+
item, stock
90+
))
91+
},
92+
)
93+
.tool(
94+
ToolDef::new("create_discount", "Create a discount code")
95+
.with_schema(serde_json::json!({
96+
"type": "object",
97+
"properties": {
98+
"user_id": {"type": "integer", "description": "User ID"},
99+
"percent": {"type": "integer", "description": "Discount percentage"}
100+
},
101+
"required": ["user_id", "percent"]
102+
})),
103+
|args| {
104+
let uid = args.param_i64("user_id").ok_or("missing --user_id")?;
105+
let pct = args.param_i64("percent").ok_or("missing --percent")?;
106+
Ok(format!(
107+
"{{\"code\":\"SAVE{pct}-U{uid}\",\"percent\":{pct},\"user_id\":{uid}}}\n"
108+
))
109+
},
110+
)
111+
.env("STORE_NAME", "Bashkit Shop")
112+
.build();
113+
114+
// ---- Show what the LLM sees ----
115+
println!("--- Tool name ---");
116+
println!("{}\n", tool.name());
117+
118+
println!("--- System prompt (what goes in LLM system message) ---");
119+
println!("{}", tool.system_prompt());
120+
121+
// ---- Demo 1: Simple single tool call ----
122+
println!("--- Demo 1: Single tool call ---");
123+
let resp = tool
124+
.execute(ToolRequest {
125+
commands: "get_user --id 1".to_string(),
126+
})
127+
.await;
128+
println!("$ get_user --id 1");
129+
println!("{}", resp.stdout);
130+
131+
// ---- Demo 2: Pipeline with jq ----
132+
println!("--- Demo 2: Pipeline with jq ---");
133+
let resp = tool
134+
.execute(ToolRequest {
135+
commands: "get_user --id 1 | jq -r '.name'".to_string(),
136+
})
137+
.await;
138+
println!("$ get_user --id 1 | jq -r '.name'");
139+
println!("{}", resp.stdout);
140+
141+
// ---- Demo 3: Multi-step orchestration ----
142+
println!("--- Demo 3: Multi-step orchestration ---");
143+
let script = r#"
144+
user=$(get_user --id 1)
145+
name=$(echo "$user" | jq -r '.name')
146+
tier=$(echo "$user" | jq -r '.tier')
147+
orders=$(list_orders --user_id 1)
148+
total=$(echo "$orders" | jq '[.[].price] | add')
149+
count=$(echo "$orders" | jq 'length')
150+
echo "Customer: $name (tier: $tier)"
151+
echo "Orders: $count, Estimated total: $total"
152+
"#;
153+
let resp = tool
154+
.execute(ToolRequest {
155+
commands: script.to_string(),
156+
})
157+
.await;
158+
println!("$ <multi-step script>");
159+
print!("{}", resp.stdout);
160+
println!();
161+
162+
// ---- Demo 4: Loop + conditional ----
163+
println!("--- Demo 4: Loop with conditional ---");
164+
let script = r#"
165+
for uid in 1 2 3; do
166+
user=$(get_user --id $uid)
167+
name=$(echo "$user" | jq -r '.name')
168+
tier=$(echo "$user" | jq -r '.tier')
169+
if [ "$tier" = "premium" ]; then
170+
echo "$name is premium - creating discount"
171+
create_discount --user_id $uid --percent 20 | jq -r '.code'
172+
else
173+
echo "$name is $tier - no discount"
174+
fi
175+
done
176+
"#;
177+
let resp = tool
178+
.execute(ToolRequest {
179+
commands: script.to_string(),
180+
})
181+
.await;
182+
println!("$ <loop with conditional>");
183+
print!("{}", resp.stdout);
184+
println!();
185+
186+
// ---- Demo 5: Inventory check with error handling ----
187+
println!("--- Demo 5: Error handling ---");
188+
let script = r#"
189+
for item in Laptop Mouse Keyboard Widget; do
190+
result=$(get_inventory --item "$item")
191+
stock=$(echo "$result" | jq '.in_stock')
192+
if [ "$stock" -eq 0 ]; then
193+
echo "$item: OUT OF STOCK"
194+
else
195+
echo "$item: $stock in stock"
196+
fi
197+
done
198+
"#;
199+
let resp = tool
200+
.execute(ToolRequest {
201+
commands: script.to_string(),
202+
})
203+
.await;
204+
println!("$ <inventory check>");
205+
print!("{}", resp.stdout);
206+
println!();
207+
208+
// ---- Demo 6: Data aggregation ----
209+
println!("--- Demo 6: Aggregate data across tools ---");
210+
let script = r#"
211+
echo "=== $STORE_NAME Report ==="
212+
for uid in 1 2; do
213+
name=$(get_user --id $uid | jq -r '.name')
214+
orders=$(list_orders --user_id $uid)
215+
count=$(echo "$orders" | jq 'length')
216+
echo "$name: $count orders"
217+
done
218+
"#;
219+
let resp = tool
220+
.execute(ToolRequest {
221+
commands: script.to_string(),
222+
})
223+
.await;
224+
println!("$ <aggregate report>");
225+
print!("{}", resp.stdout);
226+
227+
println!("\n=== Demo Complete ===");
228+
Ok(())
229+
}

crates/bashkit/src/lib.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -372,6 +372,10 @@ mod logging_impl;
372372
mod network;
373373
/// Parser module - exposed for fuzzing and testing
374374
pub mod parser;
375+
/// Scripted tool: compose ToolDef+callback pairs into a single Tool via bash scripts.
376+
/// Requires the `scripted_tool` feature.
377+
#[cfg(feature = "scripted_tool")]
378+
pub mod scripted_tool;
375379
/// Tool contract for LLM integration
376380
pub mod tool;
377381

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

395+
#[cfg(feature = "scripted_tool")]
396+
pub use scripted_tool::{ScriptedTool, ScriptedToolBuilder, ToolArgs, ToolCallback, ToolDef};
397+
391398
#[cfg(feature = "http_client")]
392399
pub use network::HttpClient;
393400

0 commit comments

Comments
 (0)