mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-10 03:30:59 +00:00
- Rename 20 crate directories lib/crates/arc-* → fabro-* - Update all Cargo.toml: crate names, dep paths, feature flags, bin name - Rename arc_server module → fabro_server in fabro-llm - ArcError → FabroError across 30+ files - ARC_VERSION/ARC_GIT_SHA/ARC_BUILD_DATE → FABRO_* constants - All use/qualified paths: arc_agent:: → fabro_agent::, etc. (~1500 occurrences) - Env vars ARC_* → FABRO_* in string literals and shell scripts - String literals: X-Arc-Demo, arc-bot, arc@local, arc-web, arc-mcp, etc. - Path strings: .arc/ → .fabro/, arc.toml → fabro.toml, refs/arc/ → refs/fabro/ - arc-api.yaml → fabro-api.yaml (OpenAPI spec) - skills/arc-create-workflow → fabro-create-workflow - trycmd fixtures: $ arc → $ fabro - Inline snapshots (insta) updated - CI, Docker, install.sh, scripts, CLAUDE.md, AGENTS.md - TypeScript app: env vars, headers, JWT issuer - Docs: page slugs, git refs, config paths, sandbox names, repo URLs - Repo references: brynary/arc → fabro-sh/fabro Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
232 lines
8.6 KiB
Rust
232 lines
8.6 KiB
Rust
use std::collections::HashMap;
|
|
use std::sync::Arc;
|
|
use std::time::Duration;
|
|
|
|
use fabro_mcp::config::McpServerConfig;
|
|
|
|
/// Callback invoked before each tool execution. Return `Ok(())` to allow,
|
|
/// `Err(message)` to deny with the given message.
|
|
pub type ToolApprovalFn = Arc<dyn Fn(&str, &serde_json::Value) -> Result<(), String> + Send + Sync>;
|
|
|
|
/// Decision returned by a [`ToolHookCallback`] before a tool executes.
|
|
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
|
pub enum ToolHookDecision {
|
|
/// Allow the tool call to proceed.
|
|
#[default]
|
|
Proceed,
|
|
/// Block the tool call with the given reason.
|
|
Block { reason: String },
|
|
}
|
|
|
|
/// Async callback trait invoked around tool execution.
|
|
#[async_trait::async_trait]
|
|
pub trait ToolHookCallback: Send + Sync {
|
|
/// Called before a tool executes. Return [`ToolHookDecision::Proceed`] to
|
|
/// allow or [`ToolHookDecision::Block`] to deny.
|
|
async fn pre_tool_use(
|
|
&self,
|
|
tool_name: &str,
|
|
tool_input: &serde_json::Value,
|
|
) -> ToolHookDecision;
|
|
|
|
/// Called after a tool executes successfully.
|
|
async fn post_tool_use(&self, tool_name: &str, tool_call_id: &str, tool_output: &str);
|
|
|
|
/// Called after a tool execution fails.
|
|
async fn post_tool_use_failure(&self, tool_name: &str, tool_call_id: &str, error: &str);
|
|
}
|
|
|
|
/// Adapter that wraps a [`ToolApprovalFn`] and implements [`ToolHookCallback`].
|
|
pub struct ToolApprovalAdapter(pub ToolApprovalFn);
|
|
|
|
#[async_trait::async_trait]
|
|
impl ToolHookCallback for ToolApprovalAdapter {
|
|
async fn pre_tool_use(
|
|
&self,
|
|
tool_name: &str,
|
|
tool_input: &serde_json::Value,
|
|
) -> ToolHookDecision {
|
|
match (self.0)(tool_name, tool_input) {
|
|
Ok(()) => ToolHookDecision::Proceed,
|
|
Err(reason) => ToolHookDecision::Block { reason },
|
|
}
|
|
}
|
|
|
|
async fn post_tool_use(&self, _tool_name: &str, _tool_call_id: &str, _tool_output: &str) {}
|
|
|
|
async fn post_tool_use_failure(&self, _tool_name: &str, _tool_call_id: &str, _error: &str) {}
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
pub struct SessionConfig {
|
|
pub max_turns: usize,
|
|
pub max_tool_rounds_per_input: usize,
|
|
pub default_command_timeout_ms: u64,
|
|
pub max_command_timeout_ms: u64,
|
|
pub reasoning_effort: Option<String>,
|
|
pub tool_output_limits: HashMap<String, usize>,
|
|
pub tool_line_limits: HashMap<String, usize>,
|
|
/// Override the provider's default max_tokens when set.
|
|
/// Node-level attribute takes priority over the model catalog default.
|
|
pub max_tokens: Option<i64>,
|
|
pub enable_loop_detection: bool,
|
|
pub loop_detection_window: usize,
|
|
pub max_subagent_depth: usize,
|
|
pub git_root: Option<String>,
|
|
pub user_instructions: Option<String>,
|
|
/// Async hook callbacks invoked around tool execution.
|
|
pub tool_hooks: Option<Arc<dyn ToolHookCallback>>,
|
|
pub enable_context_compaction: bool,
|
|
pub compaction_threshold_percent: usize,
|
|
pub compaction_preserve_turns: usize,
|
|
/// Skill directories. `None` = use convention defaults, `Some(dirs)` = use these instead.
|
|
pub skill_dirs: Option<Vec<String>>,
|
|
/// MCP server configurations to connect to on session startup.
|
|
pub mcp_servers: Vec<McpServerConfig>,
|
|
/// Wall-clock timeout for the entire `process_input` call.
|
|
/// When set, the session's cancel token is triggered after this duration.
|
|
pub wall_clock_timeout: Option<Duration>,
|
|
}
|
|
|
|
impl std::fmt::Debug for SessionConfig {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
f.debug_struct("SessionConfig")
|
|
.field("max_turns", &self.max_turns)
|
|
.field("max_tool_rounds_per_input", &self.max_tool_rounds_per_input)
|
|
.field(
|
|
"default_command_timeout_ms",
|
|
&self.default_command_timeout_ms,
|
|
)
|
|
.field("max_command_timeout_ms", &self.max_command_timeout_ms)
|
|
.field("max_tokens", &self.max_tokens)
|
|
.field("reasoning_effort", &self.reasoning_effort)
|
|
.field("tool_output_limits", &self.tool_output_limits)
|
|
.field("tool_line_limits", &self.tool_line_limits)
|
|
.field("enable_loop_detection", &self.enable_loop_detection)
|
|
.field("loop_detection_window", &self.loop_detection_window)
|
|
.field("max_subagent_depth", &self.max_subagent_depth)
|
|
.field("git_root", &self.git_root)
|
|
.field("user_instructions", &self.user_instructions)
|
|
.field(
|
|
"tool_hooks",
|
|
&self.tool_hooks.as_ref().map(|_| "<callback>"),
|
|
)
|
|
.field("enable_context_compaction", &self.enable_context_compaction)
|
|
.field(
|
|
"compaction_threshold_percent",
|
|
&self.compaction_threshold_percent,
|
|
)
|
|
.field("compaction_preserve_turns", &self.compaction_preserve_turns)
|
|
.field("skill_dirs", &self.skill_dirs)
|
|
.field("mcp_servers", &self.mcp_servers.len())
|
|
.field("wall_clock_timeout", &self.wall_clock_timeout)
|
|
.finish()
|
|
}
|
|
}
|
|
|
|
impl Default for SessionConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
max_turns: 0,
|
|
max_tool_rounds_per_input: 200,
|
|
default_command_timeout_ms: 10_000,
|
|
max_command_timeout_ms: 600_000,
|
|
max_tokens: None,
|
|
reasoning_effort: None,
|
|
tool_output_limits: HashMap::new(),
|
|
tool_line_limits: HashMap::new(),
|
|
enable_loop_detection: true,
|
|
loop_detection_window: 10,
|
|
max_subagent_depth: 1,
|
|
git_root: None,
|
|
user_instructions: None,
|
|
tool_hooks: None,
|
|
enable_context_compaction: true,
|
|
compaction_threshold_percent: 80,
|
|
compaction_preserve_turns: 6,
|
|
skill_dirs: None,
|
|
mcp_servers: Vec::new(),
|
|
wall_clock_timeout: None,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn default_config_values() {
|
|
let config = SessionConfig::default();
|
|
assert_eq!(config.max_turns, 0);
|
|
assert_eq!(config.max_tool_rounds_per_input, 200);
|
|
assert_eq!(config.default_command_timeout_ms, 10_000);
|
|
assert_eq!(config.max_command_timeout_ms, 600_000);
|
|
assert!(config.reasoning_effort.is_none());
|
|
assert!(config.tool_output_limits.is_empty());
|
|
assert!(config.tool_line_limits.is_empty());
|
|
assert!(config.enable_loop_detection);
|
|
assert_eq!(config.loop_detection_window, 10);
|
|
assert_eq!(config.max_subagent_depth, 1);
|
|
assert!(config.user_instructions.is_none());
|
|
assert!(config.mcp_servers.is_empty());
|
|
assert!(config.wall_clock_timeout.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn default_config_has_compaction_enabled() {
|
|
let config = SessionConfig::default();
|
|
assert!(config.enable_context_compaction);
|
|
assert_eq!(config.compaction_threshold_percent, 80);
|
|
assert_eq!(config.compaction_preserve_turns, 6);
|
|
}
|
|
|
|
#[test]
|
|
fn config_with_custom_values() {
|
|
let config = SessionConfig {
|
|
max_turns: 50,
|
|
reasoning_effort: Some("high".into()),
|
|
..Default::default()
|
|
};
|
|
assert_eq!(config.max_turns, 50);
|
|
assert_eq!(config.reasoning_effort, Some("high".into()));
|
|
assert_eq!(config.max_tool_rounds_per_input, 200);
|
|
}
|
|
|
|
#[test]
|
|
fn tool_hook_decision_default_is_proceed() {
|
|
assert_eq!(ToolHookDecision::default(), ToolHookDecision::Proceed);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn tool_approval_adapter_allows() {
|
|
let approval: ToolApprovalFn = Arc::new(|_name, _args| Ok(()));
|
|
let adapter = ToolApprovalAdapter(approval);
|
|
let decision = adapter.pre_tool_use("shell", &serde_json::json!({})).await;
|
|
assert_eq!(decision, ToolHookDecision::Proceed);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn tool_approval_adapter_blocks() {
|
|
let approval: ToolApprovalFn = Arc::new(|_name, _args| Err("denied".to_string()));
|
|
let adapter = ToolApprovalAdapter(approval);
|
|
let decision = adapter.pre_tool_use("shell", &serde_json::json!({})).await;
|
|
assert_eq!(
|
|
decision,
|
|
ToolHookDecision::Block {
|
|
reason: "denied".to_string()
|
|
}
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn tool_approval_adapter_post_is_noop() {
|
|
let approval: ToolApprovalFn = Arc::new(|_name, _args| Ok(()));
|
|
let adapter = ToolApprovalAdapter(approval);
|
|
// These should not panic
|
|
adapter.post_tool_use("shell", "call_1", "output").await;
|
|
adapter
|
|
.post_tool_use_failure("shell", "call_1", "error")
|
|
.await;
|
|
}
|
|
}
|