Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 32 additions & 25 deletions src/reloaded-code-agents/src/path/resolver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ use reloaded_code_core::path::{
PathResolver, RuleAction,
};
use reloaded_code_core::permissions::PermissionAction;
use soft_canonicalize::soft_canonicalize;
use reloaded_code_core::tool_context::ToolBuildContext;
use std::path::{Path, PathBuf};

/// Closed enum of resolver types used for file tools.
Expand Down Expand Up @@ -77,31 +77,25 @@ impl PathResolver for FileToolResolver {
///
/// # Arguments
///
/// - `config` - Permission config mapping tool names to [`PermissionRule`].
/// - `tool_name` - Name of the tool to look up in `config`.
/// - `workspace_root` - Workspace root used for relative-pattern resolution.
/// - `build_context` - [`ToolBuildContext`] containing the canonicalized workspace root
/// and optional permission ruleset.
/// - `config` - Permission config map used to look up tool-specific rules.
/// - `tool_name` - Name of the tool to look up in the context's permission config.
///
/// # Returns
///
/// The cheapest [`FileToolResolver`] variant satisfying the tool's permission config.
///
/// # Errors
/// - Returns [`ToolError::InvalidPath`] when the workspace root does not exist or cannot be canonicalized.
/// - Returns [`ToolError::PermissionDenied`] when the tool is disabled by configuration (`deny`).
/// - Returns [`ToolError::InvalidPath`] when shell expansion fails (e.g., unresolvable environment variable).
/// - Returns [`ToolError::InvalidPattern`] when a glob pattern is syntactically invalid.
pub fn build_resolver_for_tool(
build_context: &ToolBuildContext,
config: &IndexMap<String, PermissionRule>,
tool_name: &str,
workspace_root: &Path,
) -> Result<FileToolResolver, ToolError> {
let workspace_root = soft_canonicalize(workspace_root).map_err(|e| {
ToolError::InvalidPath(format!(
"failed to resolve workspace root '{}': {}",
workspace_root.display(),
e
))
})?;
let workspace_root = build_context.workspace_root().to_path_buf();

let Some(rule) = config.get(tool_name) else {
// Nothing specified: default to workspace only.
Expand Down Expand Up @@ -188,6 +182,7 @@ fn build_glob_policy(
#[cfg(test)]
mod tests {
use super::*;
use reloaded_code_core::ToolBuildContext;
use soft_canonicalize::soft_canonicalize;

type TestResult = Result<(), ToolError>;
Expand All @@ -202,7 +197,8 @@ mod tests {
let temp = tempfile::TempDir::new().unwrap();

let config = IndexMap::new();
let resolver = build_resolver_for_tool(&config, "read", temp.path())?;
let ctx = ToolBuildContext::new(temp.path(), None).unwrap();
let resolver = build_resolver_for_tool(&ctx, &config, "read")?;

let FileToolResolver::Allowed(inner) = &resolver else {
panic!("expected Allowed, got {resolver:?}");
Expand Down Expand Up @@ -230,7 +226,8 @@ mod tests {
PermissionRule::Action(PermissionAction::Allow),
);

let resolver = build_resolver_for_tool(&config, "read", temp.path())?;
let ctx = ToolBuildContext::new(temp.path(), None).unwrap();
let resolver = build_resolver_for_tool(&ctx, &config, "read")?;

let FileToolResolver::Allowed(inner) = &resolver else {
panic!("expected Allowed, got {resolver:?}");
Expand All @@ -257,7 +254,8 @@ mod tests {
let mut config = IndexMap::new();
config.insert("read".to_string(), PermissionRule::Pattern(patterns));

let resolver = build_resolver_for_tool(&config, "read", temp.path())?;
let ctx = ToolBuildContext::new(temp.path(), None).unwrap();
let resolver = build_resolver_for_tool(&ctx, &config, "read")?;

// Any absolute path is allowed, even outside the workspace.
assert!(resolver.is_path_allowed(Path::new("/etc/passwd")));
Expand All @@ -279,7 +277,8 @@ mod tests {
let mut config = IndexMap::new();
config.insert("read".to_string(), PermissionRule::Pattern(patterns));

let resolver = build_resolver_for_tool(&config, "read", temp.path())?;
let ctx = ToolBuildContext::new(temp.path(), None).unwrap();
let resolver = build_resolver_for_tool(&ctx, &config, "read")?;

let FileToolResolver::Allowed(inner) = &resolver else {
panic!("expected Allowed, got {resolver:?}");
Expand Down Expand Up @@ -318,7 +317,8 @@ mod tests {
let mut config = IndexMap::new();
config.insert("read".to_string(), PermissionRule::Pattern(patterns));

let resolver = build_resolver_for_tool(&config, "read", temp.path())?;
let ctx = ToolBuildContext::new(temp.path(), None).unwrap();
let resolver = build_resolver_for_tool(&ctx, &config, "read")?;

assert!(
matches!(resolver, FileToolResolver::Glob(_)),
Expand Down Expand Up @@ -358,7 +358,8 @@ mod tests {
let mut config = IndexMap::new();
config.insert("read".to_string(), PermissionRule::Pattern(patterns));

let resolver = build_resolver_for_tool(&config, "read", temp.path())?;
let ctx = ToolBuildContext::new(temp.path(), None).unwrap();
let resolver = build_resolver_for_tool(&ctx, &config, "read")?;

assert!(matches!(resolver, FileToolResolver::Glob(_)));

Expand Down Expand Up @@ -395,7 +396,8 @@ mod tests {
let mut config = IndexMap::new();
config.insert("read".to_string(), PermissionRule::Pattern(patterns));

let resolver = build_resolver_for_tool(&config, "read", temp.path())?;
let ctx = ToolBuildContext::new(temp.path(), None).unwrap();
let resolver = build_resolver_for_tool(&ctx, &config, "read")?;

assert!(matches!(resolver, FileToolResolver::Glob(_)));

Expand All @@ -419,7 +421,8 @@ mod tests {
let mut config = IndexMap::new();
config.insert("read".to_string(), PermissionRule::Pattern(patterns));

let resolver = build_resolver_for_tool(&config, "read", temp.path())?;
let ctx = ToolBuildContext::new(temp.path(), None).unwrap();
let resolver = build_resolver_for_tool(&ctx, &config, "read")?;

assert!(
matches!(resolver, FileToolResolver::Glob(_)),
Expand Down Expand Up @@ -462,7 +465,8 @@ mod tests {
let mut config = IndexMap::new();
config.insert("read".to_string(), PermissionRule::Pattern(patterns));

let resolver = build_resolver_for_tool(&config, "read", temp.path())?;
let ctx = ToolBuildContext::new(temp.path(), None).unwrap();
let resolver = build_resolver_for_tool(&ctx, &config, "read")?;

assert!(matches!(resolver, FileToolResolver::Glob(_)));

Expand All @@ -486,7 +490,8 @@ mod tests {
let mut config = IndexMap::new();
config.insert("read".to_string(), PermissionRule::Pattern(patterns));

let result = build_resolver_for_tool(&config, "read", temp.path());
let ctx = ToolBuildContext::new(temp.path(), None).unwrap();
let result = build_resolver_for_tool(&ctx, &config, "read");
assert!(
result.is_err(),
"unresolvable shell variable should produce an error"
Expand Down Expand Up @@ -514,7 +519,8 @@ mod tests {
let mut config = IndexMap::new();
config.insert("read".to_string(), PermissionRule::Pattern(patterns));

let resolver = build_resolver_for_tool(&config, "read", temp.path())?;
let ctx = ToolBuildContext::new(temp.path(), None).unwrap();
let resolver = build_resolver_for_tool(&ctx, &config, "read")?;
let root = soft_canonicalize(temp.path())?;

let resolved = resolver.resolve("src/lib.rs")?;
Expand Down Expand Up @@ -547,7 +553,8 @@ mod tests {
let mut config = IndexMap::new();
config.insert("read".to_string(), PermissionRule::Pattern(patterns));

let resolver = build_resolver_for_tool(&config, "read", temp.path())?;
let ctx = ToolBuildContext::new(temp.path(), None).unwrap();
let resolver = build_resolver_for_tool(&ctx, &config, "read")?;
let workspace_root = soft_canonicalize(temp.path())?;
let resolved = resolver.resolve(".")?;

Expand Down
2 changes: 2 additions & 0 deletions src/reloaded-code-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ pub mod path;
pub mod permissions;
pub mod permissions_ext;
pub mod system_prompt;
pub mod tool_context;
pub mod tool_metadata;
pub mod tools;
pub mod util;
Expand All @@ -30,6 +31,7 @@ pub use error::{ToolError, ToolResult};
pub use output::ToolOutput;
pub use path::{AbsolutePathResolver, AllowedGlobResolver, AllowedPathResolver, PathResolver};
pub use system_prompt::SystemPromptBuilder;
pub use tool_context::ToolBuildContext;
pub use workspace::resolve_workspace_root;

// Re-export tools (always available, sync or async based on runtime feature)
Expand Down
51 changes: 51 additions & 0 deletions src/reloaded-code-core/src/tool_context/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
//! Tool build context module.
//!
//! Provides `ToolBuildContext` for passing common build-time information
//! when constructing tools. Create one instance before the tool construction
//! loop and pass it to each tool resolver.

use std::path::{Path, PathBuf};

use crate::permissions::Ruleset;
use soft_canonicalize::soft_canonicalize;

/// Context passed when building any tool.
///
/// Create one instance before the tool construction loop and pass it to
/// each tool resolver. The context holds the canonicalized workspace root
/// and optional permission ruleset.
///
/// # Canonicalization
///
/// `workspace_root` is canonicalized once at construction time using
/// `soft_canonicalize`, enabling fail-fast error detection before the
/// tool loop.
#[derive(Debug, Clone)]
pub struct ToolBuildContext<'a> {
/// Canonicalized root directory of the workspace.
workspace_root: PathBuf,
/// Optional permission ruleset for tool access control.
pub permission: Option<&'a Ruleset>,
}

impl<'a> ToolBuildContext<'a> {
/// Creates a new `ToolBuildContext` with a canonicalized workspace root.
///
/// # Errors
/// Returns an error if `workspace_root` cannot be canonicalized.
pub fn new(
workspace_root: impl AsRef<Path>,
permission: Option<&'a Ruleset>,
) -> Result<Self, std::io::Error> {
Ok(Self {
workspace_root: soft_canonicalize(workspace_root)?,
permission,
})
}

/// Returns the canonicalized workspace root.
#[must_use]
pub fn workspace_root(&self) -> &Path {
&self.workspace_root
}
}
31 changes: 25 additions & 6 deletions src/reloaded-code-serdesai/src/agent_runtime/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,15 @@ use reloaded_code_agents::{
ToolCatalogEntry, ToolCatalogKind, build_resolver_for_tool,
};
use reloaded_code_core::permissions::Ruleset;
use reloaded_code_core::tool_context::ToolBuildContext;
use reloaded_code_core::tool_metadata::{
edit as edit_meta, glob as glob_meta, grep as grep_meta, read as read_meta,
webfetch as webfetch_meta, write as write_meta,
};
use reloaded_code_core::tools::{
GlobSettings, GrepFormattingSettings, GrepSettings, ReadSettings, WebFetchSettings,
};
use reloaded_code_core::{CredentialLookup, models::ModelCatalog};
use reloaded_code_core::{CredentialLookup, ToolError, models::ModelCatalog};
use serdes_ai::AgentBuilder;
use serdes_ai_models::BoxedModel;
use std::path::Path;
Expand Down Expand Up @@ -153,6 +154,17 @@ where
}

/// Attaches the standard runtime tools and prompt contexts without finalizing the builder.
///
/// # Errors
///
/// Returns [`AgentBuildError::UnsupportedToolKind`] when the runtime catalog contains an
/// unrecognized [`ToolCatalogKind`] variant (this function only handles standard tools).
///
/// Returns [`AgentBuildError::ToolSettingsValidation`] when resolver creation or settings
/// building fails for any tool, including:
/// - [`ToolError::InvalidPath`] if the workspace root cannot be canonicalized
/// - [`ToolError::PermissionDenied`] if a tool is explicitly disabled in the permission config
/// - [`ToolError::InvalidPattern`] if a glob permission pattern is syntactically malformed
pub(super) fn attach_standard_tools<'a, C>(
mut builder: AgentBuilder<(), String>,
prepared: &PreparedBuild<'a>,
Expand All @@ -177,6 +189,13 @@ where
builder = builder.top_p(top_p);
}

// Create build context once before the tool construction loop.
let build_context = ToolBuildContext::new(workspace_root, prepared.permission.as_deref())
.map_err(|e| AgentBuildError::ToolSettingsValidation {
tool: "workspace_root",
source: ToolError::InvalidPath(e.to_string()),
})?;

// Use pre-built permission ruleset from PreparedBuild for non-file tools.
let permission = prepared.permission.clone();
let permission_config = &prepared.permission_config;
Expand All @@ -185,35 +204,35 @@ where
match entry.kind {
ToolCatalogKind::Read => {
let resolver =
build_resolver_for_tool(permission_config, read_meta::NAME, workspace_root)
build_resolver_for_tool(&build_context, permission_config, read_meta::NAME)
.with_tool(read_meta::NAME)?;
let settings = build_read_settings(&prepared.tool_settings.read)?;
builder =
builder.tool(prompt_builder.track(ReadTool::with_settings(resolver, settings)));
}
ToolCatalogKind::Write => {
let resolver =
build_resolver_for_tool(permission_config, write_meta::NAME, workspace_root)
build_resolver_for_tool(&build_context, permission_config, write_meta::NAME)
.with_tool(write_meta::NAME)?;
builder = builder.tool(prompt_builder.track(WriteTool::new(resolver)));
}
ToolCatalogKind::Edit => {
let resolver =
build_resolver_for_tool(permission_config, edit_meta::NAME, workspace_root)
build_resolver_for_tool(&build_context, permission_config, edit_meta::NAME)
.with_tool(edit_meta::NAME)?;
builder = builder.tool(prompt_builder.track(EditTool::new(resolver)));
}
ToolCatalogKind::Glob => {
let resolver =
build_resolver_for_tool(permission_config, glob_meta::NAME, workspace_root)
build_resolver_for_tool(&build_context, permission_config, glob_meta::NAME)
.with_tool(glob_meta::NAME)?;
let settings = build_glob_settings(&prepared.tool_settings.glob)?;
builder =
builder.tool(prompt_builder.track(GlobTool::with_settings(resolver, settings)));
}
ToolCatalogKind::Grep => {
let resolver =
build_resolver_for_tool(permission_config, grep_meta::NAME, workspace_root)
build_resolver_for_tool(&build_context, permission_config, grep_meta::NAME)
.with_tool(grep_meta::NAME)?;
let (search_settings, formatting_settings) =
build_grep_settings(&prepared.tool_settings.grep)?;
Expand Down
Loading