Skip to content

Commit 87b40b2

Browse files
committed
feat: add built-in exec tool, compact skill prompts, and channel tool policy
- Add BuiltinExecTool for sandboxed shell execution with timeout and PATH augmentation - Switch skill prompt format to compact entries (name + description + location) with on-demand SKILL.md reading - Enforce tool policy per channel, filtering tools before LLM sees them - Add location field to skills for compact prompt references - Add db migration v18 for skill location column
1 parent 0b55692 commit 87b40b2

File tree

15 files changed

+496
-47
lines changed

15 files changed

+496
-47
lines changed

src/agent/runner.rs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -102,12 +102,14 @@ pub async fn run_agent_turn(state: &ApiState, config: AgentRunConfig) -> crate::
102102
));
103103

104104
// Fetch available tools
105+
let exec_tool = Arc::new(crate::tools::BuiltinExecTool::default());
105106
let tools = {
106107
let executor = crate::tools::executor::ToolExecutor::new(
107108
Arc::clone(&synapse),
108109
state.plugin_manager.clone(),
109110
)
110-
.with_memory_tools(Arc::clone(&memory_tools));
111+
.with_memory_tools(Arc::clone(&memory_tools))
112+
.with_exec_tool(Arc::clone(&exec_tool));
111113
executor.list_tools().await.ok()
112114
};
113115

@@ -222,7 +224,8 @@ pub async fn run_agent_turn(state: &ApiState, config: AgentRunConfig) -> crate::
222224
Arc::clone(&synapse),
223225
state.plugin_manager.clone(),
224226
)
225-
.with_memory_tools(Arc::clone(&memory_tools)),
227+
.with_memory_tools(Arc::clone(&memory_tools))
228+
.with_exec_tool(Arc::clone(&exec_tool)),
226229
);
227230

228231
// Headless: skip interactive tools, run the rest

src/api/skills.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -389,6 +389,7 @@ async fn install_local(
389389
},
390390
content: req.content,
391391
source: SkillSource::Local,
392+
location: None,
392393
};
393394

394395
let priority = req.priority.unwrap_or_default();

src/api/webhooks/telegram/process.rs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -464,11 +464,13 @@ pub async fn process_telegram_message(
464464

465465
let response = {
466466
// Fetch available tools from Synapse MCP and plugins
467+
let exec_tool = Arc::new(crate::tools::BuiltinExecTool::default());
467468
let tools = {
468469
let mut executor = crate::tools::executor::ToolExecutor::new(
469470
Arc::clone(synapse),
470471
state.plugin_manager.clone(),
471-
);
472+
)
473+
.with_exec_tool(Arc::clone(&exec_tool));
472474
if let Some(ref ct) = state.cron_tools {
473475
executor = executor.with_cron_tools(Arc::clone(ct));
474476
}
@@ -484,7 +486,8 @@ pub async fn process_telegram_message(
484486
let mut executor = crate::tools::executor::ToolExecutor::new(
485487
Arc::clone(synapse),
486488
state.plugin_manager.clone(),
487-
);
489+
)
490+
.with_exec_tool(exec_tool);
488491
if let Some(ref ct) = state.cron_tools {
489492
executor = executor.with_cron_tools(Arc::clone(ct));
490493
}

src/api/websocket.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -572,7 +572,8 @@ async fn handle_chat_message(
572572
let executor = crate::tools::executor::ToolExecutor::new(
573573
Arc::clone(&synapse),
574574
state.plugin_manager.clone(),
575-
);
575+
)
576+
.with_exec_tool(Arc::new(crate::tools::BuiltinExecTool::default()));
576577
let result = executor
577578
.execute(tool_name, arguments)
578579
.await

src/daemon.rs

Lines changed: 56 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1213,7 +1213,7 @@ impl Daemon {
12131213
shutdown_rx: &mut mpsc::Receiver<()>,
12141214
plugin_manager: crate::api::plugins::SharedPluginManager,
12151215
) -> Result<()> {
1216-
// Available for future tool filtering by channel policy
1216+
// Voice uses Full profile — tool_policy checked at handler level
12171217
let _ = &tool_policy;
12181218

12191219
let wake_word =
@@ -1539,8 +1539,7 @@ async fn handle_channel_messages<C: Channel + Send + 'static>(
15391539
plugin_manager: crate::api::plugins::SharedPluginManager,
15401540
telegram_config: Option<crate::config::TelegramConfig>,
15411541
) {
1542-
// Available for future tool filtering by channel policy
1543-
let _ = &tool_policy;
1542+
let exec_tool = Arc::new(crate::tools::BuiltinExecTool::default());
15441543

15451544
tracing::info!(channel = channel_name, "channel handler started");
15461545

@@ -1789,13 +1788,25 @@ async fn handle_channel_messages<C: Channel + Send + 'static>(
17891788
}
17901789
}
17911790

1792-
// Fetch available tools from Synapse MCP and plugins
1791+
// Fetch available tools from Synapse MCP and plugins, filtered by policy
17931792
let tools = {
17941793
let executor = crate::tools::executor::ToolExecutor::new(
17951794
Arc::clone(&synapse),
17961795
plugin_manager.clone(),
1797-
);
1798-
executor.list_tools().await.ok()
1796+
)
1797+
.with_exec_tool(Arc::clone(&exec_tool));
1798+
executor.list_tools().await.ok().map(|tools| {
1799+
let filtered: Vec<_> = tools
1800+
.into_iter()
1801+
.filter(|t| {
1802+
tool_policy
1803+
.is_allowed(channel_name, &normalize_tool_name(&t.function.name))
1804+
})
1805+
.collect();
1806+
let names: Vec<&str> = filtered.iter().map(|t| t.function.name.as_str()).collect();
1807+
tracing::info!(channel = channel_name, tools = ?names, "tools available for LLM");
1808+
filtered
1809+
})
17991810
};
18001811

18011812
let use_streaming = channel
@@ -1827,7 +1838,8 @@ async fn handle_channel_messages<C: Channel + Send + 'static>(
18271838
let executor = crate::tools::executor::ToolExecutor::new(
18281839
Arc::clone(&synapse),
18291840
plugin_manager.clone(),
1830-
);
1841+
)
1842+
.with_exec_tool(Arc::clone(&exec_tool));
18311843
let mut loop_detector = crate::tools::loop_detection::LoopDetector::default();
18321844

18331845
for _turn in 0..10 {
@@ -1941,6 +1953,11 @@ async fn handle_channel_messages<C: Channel + Send + 'static>(
19411953

19421954
let mut should_break = false;
19431955
for tc in &tool_calls {
1956+
tracing::info!(
1957+
tool = %tc.function.name,
1958+
args_len = tc.function.arguments.len(),
1959+
"executing tool call"
1960+
);
19441961
let result = executor
19451962
.execute(&tc.function.name, &tc.function.arguments)
19461963
.await
@@ -2220,9 +2237,11 @@ async fn handle_voice_command(
22202237
};
22212238

22222239
// Fetch available tools from Synapse MCP and plugins
2240+
let exec_tool = Arc::new(crate::tools::BuiltinExecTool::default());
22232241
let tools = {
22242242
let executor =
2225-
crate::tools::executor::ToolExecutor::new(Arc::clone(synapse), plugin_manager.clone());
2243+
crate::tools::executor::ToolExecutor::new(Arc::clone(synapse), plugin_manager.clone())
2244+
.with_exec_tool(Arc::clone(&exec_tool));
22262245
executor.list_tools().await.ok()
22272246
};
22282247

@@ -2232,7 +2251,8 @@ async fn handle_voice_command(
22322251
];
22332252
let mut final_text = String::new();
22342253
let executor =
2235-
crate::tools::executor::ToolExecutor::new(Arc::clone(synapse), plugin_manager.clone());
2254+
crate::tools::executor::ToolExecutor::new(Arc::clone(synapse), plugin_manager.clone())
2255+
.with_exec_tool(exec_tool);
22362256

22372257
for _turn in 0..10 {
22382258
let request = synapse_client::ChatRequest {
@@ -2345,6 +2365,17 @@ fn extract_command(transcript: &str, wake_word: &str) -> String {
23452365
)
23462366
}
23472367

2368+
/// Map LLM tool names to policy category names
2369+
fn normalize_tool_name(name: &str) -> String {
2370+
match name {
2371+
"Bash" => "shell".to_string(),
2372+
"Read" | "Glob" | "Grep" | "WebFetch" | "ListDir" => "read_file".to_string(),
2373+
"Write" | "Edit" => "write_file".to_string(),
2374+
"WebSearch" => "web_search".to_string(),
2375+
other => other.to_string(),
2376+
}
2377+
}
2378+
23482379
#[cfg(test)]
23492380
mod tests {
23502381
use super::*;
@@ -2357,4 +2388,20 @@ mod tests {
23572388
);
23582389
assert_eq!(extract_command("Hey Orin", "hey orin"), "");
23592390
}
2391+
2392+
#[test]
2393+
fn test_normalize_tool_name() {
2394+
assert_eq!(normalize_tool_name("Bash"), "shell");
2395+
assert_eq!(normalize_tool_name("Read"), "read_file");
2396+
assert_eq!(normalize_tool_name("Write"), "write_file");
2397+
assert_eq!(normalize_tool_name("Edit"), "write_file");
2398+
assert_eq!(normalize_tool_name("WebSearch"), "web_search");
2399+
assert_eq!(normalize_tool_name("WebFetch"), "read_file");
2400+
assert_eq!(normalize_tool_name("Glob"), "read_file");
2401+
assert_eq!(normalize_tool_name("Grep"), "read_file");
2402+
assert_eq!(normalize_tool_name("ListDir"), "read_file");
2403+
// Unknown tools pass through
2404+
assert_eq!(normalize_tool_name("memory_store"), "memory_store");
2405+
assert_eq!(normalize_tool_name("cron_list"), "cron_list");
2406+
}
23602407
}

src/db/schema.rs

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ use rusqlite::Connection;
55
use crate::Result;
66

77
/// Current schema version
8-
pub const SCHEMA_VERSION: i32 = 17;
8+
pub const SCHEMA_VERSION: i32 = 18;
99

1010
/// Initialize the database schema
1111
///
@@ -68,6 +68,9 @@ pub fn init(conn: &Connection) -> Result<()> {
6868
if version < 17 {
6969
migrate_v17(conn)?;
7070
}
71+
if version < 18 {
72+
migrate_v18(conn)?;
73+
}
7174

7275
Ok(())
7376
}
@@ -590,6 +593,20 @@ fn migrate_v17(conn: &Connection) -> Result<()> {
590593
Ok(())
591594
}
592595

596+
fn migrate_v18(conn: &Connection) -> Result<()> {
597+
conn.execute_batch(
598+
r"
599+
-- Skill file location for compact prompt mode
600+
ALTER TABLE installed_skills ADD COLUMN location TEXT;
601+
602+
PRAGMA user_version = 18;
603+
",
604+
)?;
605+
606+
tracing::info!("migrated to schema v18 (skill location)");
607+
Ok(())
608+
}
609+
593610
#[cfg(test)]
594611
mod tests {
595612
use super::*;

src/db/skill.rs

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -98,11 +98,11 @@ impl SkillRepo {
9898
disable_model_invocation, emoji, requires_env, command_name, user_id,
9999
os, requires_bins, requires_any_bins, primary_env,
100100
command_dispatch_tool, api_key, skill_env,
101-
install_specs, requires_config
101+
install_specs, requires_config, location
102102
) VALUES (
103103
?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, 1, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19,
104104
?20, ?21, ?22, ?23, ?24, NULL, '{}',
105-
?25, ?26
105+
?25, ?26, ?27
106106
)
107107
",
108108
rusqlite::params![
@@ -132,6 +132,7 @@ impl SkillRepo {
132132
command_dispatch_tool,
133133
install_specs_json,
134134
requires_config_json,
135+
skill.location,
135136
],
136137
)?;
137138

@@ -194,7 +195,7 @@ impl SkillRepo {
194195
emoji, requires_env, command_name, user_id,
195196
os, requires_bins, requires_any_bins, primary_env,
196197
command_dispatch_tool, api_key, skill_env,
197-
install_specs, requires_config
198+
install_specs, requires_config, location
198199
";
199200

200201
/// Get an installed skill by ID
@@ -432,8 +433,9 @@ impl SkillRepo {
432433
os = ?8, requires_bins = ?9, requires_any_bins = ?10,
433434
primary_env = ?11, command_dispatch_tool = ?12,
434435
install_specs = ?13, requires_config = ?14,
436+
location = ?15,
435437
updated_at = datetime('now')
436-
WHERE id = ?15
438+
WHERE id = ?16
437439
",
438440
rusqlite::params![
439441
skill.content,
@@ -450,6 +452,7 @@ impl SkillRepo {
450452
command_dispatch_tool,
451453
install_specs_json,
452454
requires_config_json,
455+
skill.location,
453456
existing.skill.id,
454457
],
455458
)?;
@@ -463,6 +466,7 @@ impl SkillRepo {
463466
metadata: skill.metadata.clone(),
464467
content: skill.content.clone(),
465468
source: SkillSource::Bundled,
469+
location: skill.location.clone(),
466470
},
467471
installed_at: existing.installed_at,
468472
enabled: existing.enabled,
@@ -651,6 +655,8 @@ impl SkillRepo {
651655
let requires_config_json: String = row
652656
.get::<_, Option<String>>(29)?
653657
.unwrap_or_else(|| "[]".to_string());
658+
// v18 column
659+
let location: Option<String> = row.get(30).unwrap_or(None);
654660

655661
let skill_env: HashMap<String, String> =
656662
serde_json::from_str(&skill_env_json).unwrap_or_default();
@@ -698,6 +704,7 @@ impl SkillRepo {
698704
},
699705
content,
700706
source,
707+
location,
701708
},
702709
installed_at,
703710
enabled,
@@ -742,6 +749,7 @@ mod tests {
742749
},
743750
content: "# Test Skill\n\nThis is a test.".to_string(),
744751
source: SkillSource::Local,
752+
location: None,
745753
}
746754
}
747755

0 commit comments

Comments
 (0)