Skip to content

Commit 00e978d

Browse files
committed
Fix batch tool resolution loops and update docs parity
1 parent 8851177 commit 00e978d

4 files changed

Lines changed: 165 additions & 8 deletions

File tree

crates/tandem-core/src/engine_loop.rs

Lines changed: 85 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -506,7 +506,12 @@ impl EngineLoop {
506506
continue;
507507
}
508508
}
509-
let signature = tool_signature(&tool_key, &args);
509+
let signature = if tool_key == "batch" {
510+
batch_tool_signature(&args)
511+
.unwrap_or_else(|| tool_signature(&tool_key, &args))
512+
} else {
513+
tool_signature(&tool_key, &args)
514+
};
510515
if is_shell_tool_name(&tool_key)
511516
&& shell_mismatch_signatures.contains(&signature)
512517
{
@@ -517,7 +522,9 @@ impl EngineLoop {
517522
continue;
518523
}
519524
let mut signature_count = 1usize;
520-
if is_read_only_tool(&tool_key) {
525+
if is_read_only_tool(&tool_key)
526+
|| (tool_key == "batch" && is_read_only_batch_call(&args))
527+
{
521528
let count = readonly_signature_counts
522529
.entry(signature.clone())
523530
.and_modify(|v| *v = v.saturating_add(1))
@@ -1358,11 +1365,69 @@ fn is_read_only_tool(tool_name: &str) -> bool {
13581365
)
13591366
}
13601367

1368+
fn is_batch_wrapper_tool_name(name: &str) -> bool {
1369+
matches!(
1370+
normalize_tool_name(name).as_str(),
1371+
"default_api" | "default" | "api" | "function" | "functions" | "tool" | "tools"
1372+
)
1373+
}
1374+
1375+
fn extract_batch_calls(args: &Value) -> Vec<(String, Value)> {
1376+
let calls = args
1377+
.get("tool_calls")
1378+
.and_then(|v| v.as_array())
1379+
.cloned()
1380+
.unwrap_or_default();
1381+
calls
1382+
.into_iter()
1383+
.filter_map(|call| {
1384+
let obj = call.as_object()?;
1385+
let tool_raw = obj
1386+
.get("tool")
1387+
.and_then(|v| v.as_str())
1388+
.map(str::trim)
1389+
.filter(|s| !s.is_empty());
1390+
let name_raw = obj
1391+
.get("name")
1392+
.and_then(|v| v.as_str())
1393+
.map(str::trim)
1394+
.filter(|s| !s.is_empty());
1395+
let effective = match (tool_raw, name_raw) {
1396+
(Some(t), Some(n)) if is_batch_wrapper_tool_name(t) => n,
1397+
(Some(t), _) => t,
1398+
(None, Some(n)) => n,
1399+
(None, None) => return None,
1400+
};
1401+
let normalized = normalize_tool_name(effective);
1402+
let call_args = obj.get("args").cloned().unwrap_or_else(|| json!({}));
1403+
Some((normalized, call_args))
1404+
})
1405+
.collect()
1406+
}
1407+
1408+
fn is_read_only_batch_call(args: &Value) -> bool {
1409+
let calls = extract_batch_calls(args);
1410+
!calls.is_empty() && calls.iter().all(|(tool, _)| is_read_only_tool(tool))
1411+
}
1412+
1413+
fn batch_tool_signature(args: &Value) -> Option<String> {
1414+
let calls = extract_batch_calls(args);
1415+
if calls.is_empty() {
1416+
return None;
1417+
}
1418+
let parts = calls
1419+
.into_iter()
1420+
.map(|(tool, call_args)| tool_signature(&tool, &call_args))
1421+
.collect::<Vec<_>>();
1422+
Some(format!("batch:{}", parts.join("|")))
1423+
}
1424+
13611425
fn tool_budget_for(tool_name: &str) -> usize {
13621426
match normalize_tool_name(tool_name).as_str() {
13631427
"glob" => 4,
13641428
"read" => 8,
13651429
"websearch" => 3,
1430+
"batch" => 4,
13661431
"grep" | "search" | "codesearch" => 6,
13671432
_ => 10,
13681433
}
@@ -3589,6 +3654,24 @@ Call: todowrite(task_id=3, status="in_progress")
35893654
assert_eq!(normalize_tool_name("functions.shell"), "bash");
35903655
}
35913656

3657+
#[test]
3658+
fn batch_helpers_use_name_when_tool_is_wrapper() {
3659+
let args = json!({
3660+
"tool_calls":[
3661+
{"tool":"default_api","name":"read","args":{"path":"CONCEPT.md"}},
3662+
{"tool":"default_api:glob","args":{"pattern":"*.md"}}
3663+
]
3664+
});
3665+
let calls = extract_batch_calls(&args);
3666+
assert_eq!(calls.len(), 2);
3667+
assert_eq!(calls[0].0, "read");
3668+
assert_eq!(calls[1].0, "glob");
3669+
assert!(is_read_only_batch_call(&args));
3670+
let sig = batch_tool_signature(&args).unwrap_or_default();
3671+
assert!(sig.contains("read:"));
3672+
assert!(sig.contains("glob:"));
3673+
}
3674+
35923675
#[test]
35933676
fn runtime_prompt_includes_execution_environment_block() {
35943677
let prompt = tandem_runtime_system_prompt(&HostRuntimeContext {

crates/tandem-tools/src/lib.rs

Lines changed: 61 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,39 @@ fn resolve_registered_tool(
165165
None
166166
}
167167

168+
fn is_batch_wrapper_tool_name(name: &str) -> bool {
169+
matches!(
170+
canonical_tool_name(name).as_str(),
171+
"default_api" | "default" | "api" | "function" | "functions" | "tool" | "tools"
172+
)
173+
}
174+
175+
fn resolve_batch_call_tool_name(call: &Value) -> Option<String> {
176+
let tool = call
177+
.get("tool")
178+
.and_then(|v| v.as_str())
179+
.map(str::trim)
180+
.filter(|s| !s.is_empty());
181+
let name = call
182+
.get("name")
183+
.and_then(|v| v.as_str())
184+
.map(str::trim)
185+
.filter(|s| !s.is_empty());
186+
187+
match (tool, name) {
188+
(Some(t), Some(n)) => {
189+
if is_batch_wrapper_tool_name(t) {
190+
Some(n.to_string())
191+
} else {
192+
Some(t.to_string())
193+
}
194+
}
195+
(Some(t), None) => Some(t.to_string()),
196+
(None, Some(n)) => Some(n.to_string()),
197+
(None, None) => None,
198+
}
199+
}
200+
168201
impl Default for ToolRegistry {
169202
fn default() -> Self {
170203
Self::new()
@@ -2420,16 +2453,24 @@ impl Tool for BatchTool {
24202453
let registry = ToolRegistry::new();
24212454
let mut outputs = Vec::new();
24222455
for call in calls.iter().take(20) {
2423-
let tool = call
2424-
.get("tool")
2425-
.or_else(|| call.get("name"))
2426-
.and_then(|v| v.as_str())
2427-
.unwrap_or("");
2456+
let Some(tool) = resolve_batch_call_tool_name(call) else {
2457+
continue;
2458+
};
24282459
if tool.is_empty() || tool == "batch" {
24292460
continue;
24302461
}
24312462
let call_args = call.get("args").cloned().unwrap_or_else(|| json!({}));
2432-
let result = registry.execute(tool, call_args).await?;
2463+
let mut result = registry.execute(&tool, call_args.clone()).await?;
2464+
if result.output.starts_with("Unknown tool:") {
2465+
if let Some(fallback_name) = call
2466+
.get("name")
2467+
.and_then(|v| v.as_str())
2468+
.map(str::trim)
2469+
.filter(|s| !s.is_empty() && *s != tool)
2470+
{
2471+
result = registry.execute(fallback_name, call_args).await?;
2472+
}
2473+
}
24332474
outputs.push(json!({
24342475
"tool": tool,
24352476
"output": result.output,
@@ -2834,6 +2875,20 @@ mod tests {
28342875
.expect("batch should return ToolResult");
28352876
assert!(!result.output.contains("Unknown tool: default_api:read"));
28362877
}
2878+
2879+
#[tokio::test]
2880+
async fn batch_prefers_name_when_tool_is_default_api_wrapper() {
2881+
let tool = BatchTool;
2882+
let result = tool
2883+
.execute(json!({
2884+
"tool_calls":[
2885+
{"tool":"default_api","name":"read","args":{"path":"Cargo.toml"}}
2886+
]
2887+
}))
2888+
.await
2889+
.expect("batch should return ToolResult");
2890+
assert!(!result.output.contains("Unknown tool: default_api"));
2891+
}
28372892
}
28382893

28392894
async fn find_symbol_references(symbol: &str) -> String {

guide/src/content/docs/reference/engine-commands.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,19 @@ tandem-engine serve [OPTIONS]
4545
- `--web-ui`: Enable embedded web admin UI (env: `TANDEM_WEB_UI`).
4646
- `--web-ui-prefix <PATH>`: Path prefix for embedded web admin UI (default: `/admin`, env: `TANDEM_WEB_UI_PREFIX`).
4747

48+
## `status`
49+
50+
Checks engine health by calling `GET /global/health` on a target host/port.
51+
52+
```bash
53+
tandem-engine status [OPTIONS]
54+
```
55+
56+
**Options:**
57+
58+
- `--hostname <HOSTNAME>` / `--host <HOSTNAME>`: Hostname or IP to check (default: `127.0.0.1`, env: `TANDEM_ENGINE_HOST`).
59+
- `--port <PORT>`: Port to check (default: `39731`, env: `TANDEM_ENGINE_PORT`).
60+
4861
## `run`
4962

5063
Execute a single prompt and exit. Useful for quick CLI queries or scripting.

guide/src/content/docs/reference/tools.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,12 @@ The Tandem Engine tool registry currently exposes the following tools.
2222
- **`websearch`**: Search the web (powered by Exa.ai).
2323
- Input: `query` (string), `limit` (integer)
2424
- **`codesearch`**: Semantic code search (if configured).
25+
- **`memory_list`**: List persisted memory entries for a scope/tier.
26+
- Input: optional scope + filter arguments (e.g., `session_id`, `project_id`, `tier`, `limit`)
2527
- **`memory_search`**: Search persisted memory by query and scope.
2628
- Input: `query` plus one or more scopes (e.g., session/workspace).
29+
- **`memory_store`**: Persist memory content for session/project/global retrieval.
30+
- Input: `content` plus scope/tier arguments (e.g., `session_id`, `project_id`, `tier`)
2731

2832
## Web
2933

@@ -41,6 +45,8 @@ The Tandem Engine tool registry currently exposes the following tools.
4145
- Aliases: `todowrite`, `update_todo_list`
4246
- **`task`**: Update the current task status.
4347
- **`question`**: Ask a structured question to the user and wait for input.
48+
- **`spawn_agent`**: Spawn an agent-team worker instance (runtime/policy gated).
49+
- Input: mission/spawn payload (e.g., `missionID`, `role`, `templateID`, `source`)
4450

4551
## Specialized
4652

0 commit comments

Comments
 (0)