This commit is contained in:
jif-oai
2026-05-07 14:24:54 +01:00
parent 1af814fb51
commit a7b6245e52
12 changed files with 630 additions and 73 deletions

View File

@@ -12,12 +12,12 @@ path = "src/lib.rs"
workspace = true
[dependencies]
rmcp = { workspace = true, default-features = false, features = [
"schemars",
"server",
] }
codex-tools = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
[dev-dependencies]
codex-guardian = { workspace = true }
codex-git-attribution = { workspace = true }
codex-memories = { workspace = true }
codex-multi-agent-v2 = { workspace = true }

View File

@@ -2,23 +2,27 @@ use std::sync::Arc;
use codex_extension_api::ExtensionRegistryBuilder;
use codex_git_attribution as git_attribution;
use codex_git_attribution::GitAttributionContext;
use codex_memories::MemoriesContext;
use codex_guardian as guardian;
use codex_memories::MemoriesExtension;
use codex_multi_agent_v2 as multi_agent_v2;
use codex_multi_agent_v2::MultiAgentV2Context;
use codex_multi_agent_v2::UsageHintAudience;
fn main() {
let registry = ExtensionRegistryBuilder::<ctx::RuntimeContext>::new()
.with_extension(guardian::extension())
.with_extension(git_attribution::extension())
.with_extension(Arc::new(MemoriesExtension::new(Some(
.with_extension(Arc::new(MemoriesExtension::with_read_prompt(
"Please use FS access bla bla bla.".to_string(),
))))
std::env::temp_dir().join("codex-memories-example"),
)))
.with_extension(multi_agent_v2::extension())
.build();
let root_context = ctx::RuntimeContext {
automatic_review_enabled: true,
approval_policy_allows_automatic_review: true,
is_guardian_reviewer: false,
guardian_policy_prompt: Some("Guardian policy.".to_string()),
commit_attribution: None,
memory_tool_enabled: true,
use_memories: true,
@@ -39,34 +43,44 @@ fn main() {
.flat_map(|contributor| contributor.contribute(&root_context))
.collect::<Vec<_>>();
// Get tools (MCP here but this should shift to handlers)
// Get native tools
let tools = registry
.mcp_tool_contributors()
.tool_contributors()
.iter()
.flat_map(|contributor| contributor.tools(&root_context))
.collect::<Vec<_>>();
let tools_without_memories = registry
.mcp_tool_contributors()
.tool_contributors()
.iter()
.flat_map(|contributor| contributor.tools(&memories_disabled_context))
.collect::<Vec<_>>();
let active_approval_interceptors = registry
.approval_interceptor_contributors()
.iter()
.filter(|contributor| contributor.intercepts_approvals(&root_context))
.count();
println!("prompt fragments: {}", prompt_fragments.len());
println!("mcp tools: {}", tools.len());
println!("approval interceptors: {active_approval_interceptors}");
println!("native tools: {}", tools.len());
println!(
"mcp tools when use_memories=false: {}",
"native tools when use_memories=false: {}",
tools_without_memories.len()
);
}
mod ctx {
use codex_git_attribution::GitAttributionContext;
use codex_guardian::GuardianContext;
use codex_memories::MemoriesContext;
use codex_multi_agent_v2::{MultiAgentV2Context, UsageHintAudience};
use codex_multi_agent_v2::MultiAgentV2Context;
use codex_multi_agent_v2::UsageHintAudience;
#[derive(Clone)]
pub struct RuntimeContext {
pub automatic_review_enabled: bool,
pub approval_policy_allows_automatic_review: bool,
pub is_guardian_reviewer: bool,
pub guardian_policy_prompt: Option<String>,
// Ideally this should be at the config layer instead
pub commit_attribution: Option<String>,
pub memory_tool_enabled: bool,
@@ -77,6 +91,24 @@ mod ctx {
pub subagent_usage_hint_text: Option<String>,
}
impl GuardianContext for RuntimeContext {
fn automatic_review_enabled(&self) -> bool {
self.automatic_review_enabled
}
fn approval_policy_allows_automatic_review(&self) -> bool {
self.approval_policy_allows_automatic_review
}
fn is_guardian_reviewer(&self) -> bool {
self.is_guardian_reviewer
}
fn guardian_policy_prompt(&self) -> Option<&str> {
self.guardian_policy_prompt.as_deref()
}
}
impl GitAttributionContext for RuntimeContext {
fn commit_attribution(&self) -> Option<&str> {
self.commit_attribution.as_deref()
@@ -110,4 +142,4 @@ mod ctx {
self.subagent_usage_hint_text.as_deref()
}
}
}
}

View File

@@ -6,11 +6,23 @@
//! supporting types nearby.
mod prompt;
mod tool;
pub use prompt::PromptFragment;
pub use prompt::PromptSlot;
pub use tool::ToolCallError;
pub use tool::ToolContribution;
pub use tool::ToolHandler;
use rmcp::model::Tool;
/// Extension contribution that can claim approval requests for a runtime context.
///
/// Implementations should make only the routing decision here. The host keeps
/// ownership of executing the chosen review flow and translating its result
/// back into the surrounding runtime.
pub trait ApprovalInterceptorContributor<C>: Send + Sync {
/// Returns whether this contributor should intercept approvals in `context`.
fn intercepts_approvals(&self, context: &C) -> bool;
}
/// Extension contribution that adds prompt fragments during prompt assembly.
///
@@ -22,15 +34,13 @@ pub trait PromptContributor<C>: Send + Sync {
fn contribute(&self, context: &C) -> Vec<PromptFragment>;
}
/// Extension contribution that exposes MCP tool definitions owned by a feature.
/// Extension contribution that exposes native tools owned by a feature.
///
/// Implementations should inspect only their feature-owned slice of the
/// current runtime context and return the tools exposed for that invocation.
/// The host remains responsible for mounting those tools and routing
/// execution.
///
/// This is intentionally MCP-shaped for now because the more general tool
/// abstraction has not been extracted yet.
pub trait McpToolContributor<C>: Send + Sync {
fn tools(&self, context: &C) -> Vec<Tool>;
/// The host remains responsible for mounting those tools and adapting calls
/// into its runtime.
pub trait ToolContributor<C>: Send + Sync {
/// Returns the native tools visible for the supplied runtime context.
fn tools(&self, context: &C) -> Vec<ToolContribution<C>>;
}

View File

@@ -0,0 +1,71 @@
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use codex_tools::ResponsesApiTool;
use serde_json::Value;
use thiserror::Error;
// TMP
#[derive(Clone)]
pub struct ToolContribution<C> {
spec: ResponsesApiTool,
handler: Arc<dyn ToolHandler<C>>,
supports_parallel_tool_calls: bool,
}
impl<C> ToolContribution<C> {
pub fn new(spec: ResponsesApiTool, handler: Arc<dyn ToolHandler<C>>) -> Self {
Self {
spec,
handler,
supports_parallel_tool_calls: false,
}
}
#[must_use]
pub fn allow_parallel_calls(mut self) -> Self {
self.supports_parallel_tool_calls = true;
self
}
pub fn spec(&self) -> &ResponsesApiTool {
&self.spec
}
pub fn supports_parallel_tool_calls(&self) -> bool {
self.supports_parallel_tool_calls
}
pub fn handler(&self) -> Arc<dyn ToolHandler<C>> {
Arc::clone(&self.handler)
}
}
//////// Just to make it compile ////////////////////////////////
pub trait ToolHandler<C>: Send + Sync {
/// Handles one JSON-encoded invocation for this tool.
fn handle<'a>(
&'a self,
context: &'a C,
arguments: Value,
) -> Pin<Box<dyn Future<Output = Result<Value, ToolCallError>> + Send + 'a>>;
}
/// Error returned by a contributed native tool handler.
#[derive(Clone, Debug, Error, PartialEq, Eq)]
#[error("{message}")]
pub struct ToolCallError {
message: String,
}
impl ToolCallError {
/// Creates a contributed-tool error with the supplied model-visible text.
pub fn new(message: impl Into<String>) -> Self {
Self {
message: message.into(),
}
}
}

View File

@@ -11,10 +11,14 @@ mod contributors;
mod extension;
mod registry;
pub use contributors::McpToolContributor;
pub use contributors::ApprovalInterceptorContributor;
pub use contributors::PromptContributor;
pub use contributors::PromptFragment;
pub use contributors::PromptSlot;
pub use contributors::ToolCallError;
pub use contributors::ToolContribution;
pub use contributors::ToolContributor;
pub use contributors::ToolHandler;
pub use extension::CodexExtension;
pub use registry::ExtensionRegistry;
pub use registry::ExtensionRegistryBuilder;

View File

@@ -1,20 +1,23 @@
use std::sync::Arc;
use crate::ApprovalInterceptorContributor;
use crate::CodexExtension;
use crate::McpToolContributor;
use crate::PromptContributor;
use crate::ToolContributor;
/// Mutable registry used while extensions install their typed contributions.
pub struct ExtensionRegistryBuilder<C> {
mcp_tool_contributors: Vec<Arc<dyn McpToolContributor<C>>>,
approval_interceptor_contributors: Vec<Arc<dyn ApprovalInterceptorContributor<C>>>,
prompt_contributors: Vec<Arc<dyn PromptContributor<C>>>,
tool_contributors: Vec<Arc<dyn ToolContributor<C>>>,
}
impl<C> Default for ExtensionRegistryBuilder<C> {
fn default() -> Self {
Self {
mcp_tool_contributors: Vec::new(),
approval_interceptor_contributors: Vec::new(),
prompt_contributors: Vec::new(),
tool_contributors: Vec::new(),
}
}
}
@@ -43,9 +46,12 @@ impl<C> ExtensionRegistryBuilder<C> {
extension.install(self);
}
/// Registers one MCP tool contributor.
pub fn mcp_tool_contributor(&mut self, contributor: Arc<dyn McpToolContributor<C>>) {
self.mcp_tool_contributors.push(contributor);
/// Registers one approval interceptor contributor.
pub fn approval_interceptor_contributor(
&mut self,
contributor: Arc<dyn ApprovalInterceptorContributor<C>>,
) {
self.approval_interceptor_contributors.push(contributor);
}
/// Registers one prompt contributor.
@@ -53,29 +59,43 @@ impl<C> ExtensionRegistryBuilder<C> {
self.prompt_contributors.push(contributor);
}
/// Registers one native tool contributor.
pub fn tool_contributor(&mut self, contributor: Arc<dyn ToolContributor<C>>) {
self.tool_contributors.push(contributor);
}
/// Finishes construction and returns the immutable registry.
pub fn build(self) -> ExtensionRegistry<C> {
ExtensionRegistry {
mcp_tool_contributors: self.mcp_tool_contributors,
approval_interceptor_contributors: self.approval_interceptor_contributors,
prompt_contributors: self.prompt_contributors,
tool_contributors: self.tool_contributors,
}
}
}
/// Immutable typed registry produced after extensions are installed.
pub struct ExtensionRegistry<C> {
mcp_tool_contributors: Vec<Arc<dyn McpToolContributor<C>>>,
approval_interceptor_contributors: Vec<Arc<dyn ApprovalInterceptorContributor<C>>>,
prompt_contributors: Vec<Arc<dyn PromptContributor<C>>>,
tool_contributors: Vec<Arc<dyn ToolContributor<C>>>,
}
impl<C> ExtensionRegistry<C> {
/// Returns the registered MCP tool contributors.
pub fn mcp_tool_contributors(&self) -> &[Arc<dyn McpToolContributor<C>>] {
&self.mcp_tool_contributors
/// Returns the registered approval interceptor contributors.
pub fn approval_interceptor_contributors(
&self,
) -> &[Arc<dyn ApprovalInterceptorContributor<C>>] {
&self.approval_interceptor_contributors
}
/// Returns the registered prompt contributors.
pub fn prompt_contributors(&self) -> &[Arc<dyn PromptContributor<C>>] {
&self.prompt_contributors
}
/// Returns the registered native tool contributors.
pub fn tool_contributors(&self) -> &[Arc<dyn ToolContributor<C>>] {
&self.tool_contributors
}
}

View File

@@ -0,0 +1,6 @@
load("//:defs.bzl", "codex_rust_crate")
codex_rust_crate(
name = "guardian",
crate_name = "codex_guardian",
)

View File

@@ -0,0 +1,15 @@
[package]
edition.workspace = true
license.workspace = true
name = "codex-guardian"
version.workspace = true
[lib]
name = "codex_guardian"
path = "src/lib.rs"
[lints]
workspace = true
[dependencies]
codex-extension-api = { workspace = true }

View File

@@ -0,0 +1,75 @@
//! Guardian routing contribution packaged as a Codex extension.
#![forbid(unsafe_code)]
use std::sync::Arc;
use codex_extension_api::ApprovalInterceptorContributor;
use codex_extension_api::CodexExtension;
use codex_extension_api::ExtensionRegistryBuilder;
use codex_extension_api::PromptContributor;
use codex_extension_api::PromptFragment;
/// Runtime facts needed to expose Guardian surfaces.
///
/// Hosts should provide the effective approval settings for the current turn,
/// whether the current session is the Guardian reviewer itself, and the prompt
/// text to show to that reviewer.
pub trait GuardianContext {
fn automatic_review_enabled(&self) -> bool;
fn approval_policy_allows_automatic_review(&self) -> bool;
fn is_guardian_reviewer(&self) -> bool;
fn guardian_policy_prompt(&self) -> Option<&str>;
}
/// Extension that contributes Guardian approval routing and reviewer policy.
#[derive(Clone, Copy, Debug, Default)]
pub struct GuardianExtension;
impl GuardianExtension {
/// Creates an extension instance.
pub fn new() -> Self {
Self
}
/// Returns whether Guardian should intercept approvals in this context.
pub fn should_intercept_approvals<C: GuardianContext>(&self, context: &C) -> bool {
context.automatic_review_enabled() && context.approval_policy_allows_automatic_review()
}
/// Returns the policy prompt shown only to Guardian reviewer sessions.
pub fn policy_prompt<'a, C: GuardianContext>(&self, context: &'a C) -> Option<&'a str> {
if context.is_guardian_reviewer() {
context.guardian_policy_prompt()
} else {
None
}
}
}
impl<C: GuardianContext> ApprovalInterceptorContributor<C> for GuardianExtension {
fn intercepts_approvals(&self, context: &C) -> bool {
self.should_intercept_approvals(context)
}
}
impl<C: GuardianContext> PromptContributor<C> for GuardianExtension {
fn contribute(&self, context: &C) -> Vec<PromptFragment> {
self.policy_prompt(context)
.map(PromptFragment::separate_developer)
.into_iter()
.collect()
}
}
impl<C: GuardianContext> CodexExtension<C> for GuardianExtension {
fn install(self: Arc<Self>, registry: &mut ExtensionRegistryBuilder<C>) {
registry.approval_interceptor_contributor(self.clone());
registry.prompt_contributor(self);
}
}
/// Creates a shared Guardian extension instance.
pub fn extension() -> Arc<GuardianExtension> {
Arc::new(GuardianExtension::new())
}

View File

@@ -14,9 +14,9 @@ workspace = true
[dependencies]
codex-extension-api = { workspace = true }
codex-memories-read = { workspace = true }
codex-tools = { workspace = true }
codex-utils-absolute-path = { workspace = true }
rmcp = { workspace = true, default-features = false, features = [
"schemars",
"server",
] }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
thiserror = { workspace = true }
tokio = { workspace = true, features = ["fs"] }

View File

@@ -2,19 +2,21 @@
#![forbid(unsafe_code)]
mod list_tool;
use std::path::PathBuf;
use std::sync::Arc;
use codex_extension_api::CodexExtension;
use codex_extension_api::ExtensionRegistryBuilder;
use codex_extension_api::McpToolContributor;
use codex_extension_api::PromptContributor;
use codex_extension_api::PromptFragment;
use codex_extension_api::ToolContribution;
use codex_extension_api::ToolContributor;
use codex_memories_read::build_memory_tool_developer_instructions;
use codex_memories_read::memory_root;
use codex_utils_absolute_path::AbsolutePathBuf;
use rmcp::model::Tool;
use rmcp::model::ToolAnnotations;
use serde_json::Map;
use serde_json::Value;
use list_tool::ListMemoriesTool;
/// Runtime facts needed to decide whether read-memory surfaces are visible.
///
@@ -25,23 +27,37 @@ pub trait MemoriesContext {
fn use_memories(&self) -> bool;
}
/// Extension that contributes memories MCP tools plus their read-path guidance.
/// Extension that contributes memories read surfaces.
#[derive(Clone, Debug)]
pub struct MemoriesExtension {
read_prompt: Option<String>,
list_tool: Arc<ListMemoriesTool>,
}
impl MemoriesExtension {
/// Creates an extension from a pre-rendered read prompt.
pub fn new(read_prompt: Option<String>) -> Self {
Self { read_prompt }
fn new(read_prompt: Option<String>, memories_root: impl Into<PathBuf>) -> Self {
Self {
read_prompt,
list_tool: Arc::new(ListMemoriesTool::new(memories_root)),
}
}
/// Creates an extension that contributes native tools but no prompt fragment.
pub fn tools_only(memories_root: impl Into<PathBuf>) -> Self {
Self::new(None, memories_root)
}
/// Creates an extension with one pre-rendered read prompt and native tools.
pub fn with_read_prompt(read_prompt: String, memories_root: impl Into<PathBuf>) -> Self {
Self::new(Some(read_prompt), memories_root)
}
/// Creates an extension using the live memories read prompt for this Codex home.
pub async fn from_codex_home(codex_home: &AbsolutePathBuf) -> Self {
Self {
read_prompt: build_memory_tool_developer_instructions(codex_home).await,
}
Self::new(
build_memory_tool_developer_instructions(codex_home).await,
memory_root(codex_home).to_path_buf(),
)
}
/// Returns the rendered developer instruction for read access, if available.
@@ -54,17 +70,13 @@ impl MemoriesExtension {
}
}
impl<C: MemoriesContext> McpToolContributor<C> for MemoriesExtension {
fn tools(&self, context: &C) -> Vec<Tool> {
impl<C: MemoriesContext + Send + Sync + 'static> ToolContributor<C> for MemoriesExtension {
fn tools(&self, context: &C) -> Vec<ToolContribution<C>> {
if !self.is_read_surface_enabled(context) {
return Vec::new();
}
vec![
simple_tool("list", "List memory entries."),
simple_tool("read", "Read one memory entry."),
simple_tool("search", "Search memory entries."),
]
vec![self.list_tool.contribution()]
}
}
@@ -81,9 +93,9 @@ impl<C: MemoriesContext> PromptContributor<C> for MemoriesExtension {
}
}
impl<C: MemoriesContext> CodexExtension<C> for MemoriesExtension {
impl<C: MemoriesContext + Send + Sync + 'static> CodexExtension<C> for MemoriesExtension {
fn install(self: Arc<Self>, registry: &mut ExtensionRegistryBuilder<C>) {
registry.mcp_tool_contributor(self.clone());
registry.tool_contributor(self.clone());
registry.prompt_contributor(self);
}
}
@@ -92,12 +104,3 @@ impl<C: MemoriesContext> CodexExtension<C> for MemoriesExtension {
pub async fn extension(codex_home: &AbsolutePathBuf) -> Arc<MemoriesExtension> {
Arc::new(MemoriesExtension::from_codex_home(codex_home).await)
}
fn simple_tool(name: &'static str, description: &'static str) -> Tool {
Tool::new(name, description, simple_input_schema())
.annotate(ToolAnnotations::new().read_only(true))
}
fn simple_input_schema() -> Map<String, Value> {
Map::from_iter([("type".to_string(), Value::String("object".to_string()))])
}

View File

@@ -0,0 +1,321 @@
use std::borrow::Cow;
use std::future::Future;
use std::path::Component;
use std::path::Path;
use std::path::PathBuf;
use std::pin::Pin;
use std::sync::Arc;
use codex_extension_api::ToolCallError;
use codex_extension_api::ToolContribution;
use codex_extension_api::ToolHandler;
use codex_tools::JsonSchema;
use codex_tools::ResponsesApiTool;
use serde::Deserialize;
use serde::Serialize;
use serde_json::Value;
const LIST_MEMORIES_TOOL_NAME: &str = "list_memories";
const DEFAULT_LIST_MAX_RESULTS: usize = 2_000;
const MAX_LIST_RESULTS: usize = 2_000;
#[derive(Debug)]
pub(super) struct ListMemoriesTool {
memories_root: PathBuf,
}
impl ListMemoriesTool {
pub(super) fn new(memories_root: impl Into<PathBuf>) -> Self {
Self {
memories_root: memories_root.into(),
}
}
pub(super) fn contribution<C>(self: &Arc<Self>) -> ToolContribution<C>
where
C: Send + Sync + 'static,
{
let handler: Arc<dyn ToolHandler<C>> = self.clone();
ToolContribution::new(create_list_memories_tool(), handler).allow_parallel_calls()
}
}
impl<C> ToolHandler<C> for ListMemoriesTool
where
C: Send + Sync,
{
fn handle<'a>(
&'a self,
_context: &'a C,
arguments: Value,
) -> Pin<Box<dyn Future<Output = Result<Value, ToolCallError>> + Send + 'a>> {
Box::pin(async move {
let args: ListMemoriesArgs = serde_json::from_value(arguments)
.map_err(|err| ToolCallError::new(format!("invalid arguments: {err}")))?;
tokio::fs::create_dir_all(&self.memories_root)
.await
.map_err(|err| {
ToolCallError::new(format!(
"failed to create memories root at {}: {err}",
self.memories_root.display()
))
})?;
let response = list_memories(&self.memories_root, args)
.await
.map_err(|err| ToolCallError::new(err.to_string()))?;
serde_json::to_value(response)
.map_err(|err| ToolCallError::new(format!("failed to serialize output: {err}")))
})
}
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct ListMemoriesArgs {
path: Option<String>,
cursor: Option<String>,
max_results: Option<usize>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
struct ListMemoriesResponse {
path: Option<String>,
entries: Vec<MemoryEntry>,
next_cursor: Option<String>,
truncated: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
struct MemoryEntry {
path: String,
entry_type: MemoryEntryType,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
enum MemoryEntryType {
File,
Directory,
}
#[derive(Debug, thiserror::Error)]
enum ListMemoriesError {
#[error("path '{path}' {reason}")]
InvalidPath { path: String, reason: String },
#[error("cursor '{cursor}' {reason}")]
InvalidCursor { cursor: String, reason: String },
#[error("path '{path}' was not found")]
NotFound { path: String },
#[error("I/O error while reading memories: {0}")]
Io(#[from] std::io::Error),
}
async fn list_memories(
memories_root: &Path,
args: ListMemoriesArgs,
) -> Result<ListMemoriesResponse, ListMemoriesError> {
let max_results = args
.max_results
.unwrap_or(DEFAULT_LIST_MAX_RESULTS)
.min(MAX_LIST_RESULTS);
let start = resolve_scoped_path(memories_root, args.path.as_deref()).await?;
let start_index = match args.cursor.as_deref() {
Some(cursor) => cursor
.parse::<usize>()
.map_err(|_| ListMemoriesError::InvalidCursor {
cursor: cursor.to_string(),
reason: "must be a non-negative integer".to_string(),
})?,
None => 0,
};
let Some(metadata) = metadata_or_none(&start).await? else {
return Err(ListMemoriesError::NotFound {
path: args.path.unwrap_or_default(),
});
};
reject_symlink(&display_relative_path(memories_root, &start), &metadata)?;
let mut entries = if metadata.is_file() {
vec![MemoryEntry {
path: display_relative_path(memories_root, &start),
entry_type: MemoryEntryType::File,
}]
} else if metadata.is_dir() {
let mut entries = Vec::new();
for path in read_sorted_dir_paths(&start).await? {
if is_hidden_path(&path) {
continue;
}
let Some(metadata) = metadata_or_none(&path).await? else {
continue;
};
if metadata.file_type().is_symlink() {
continue;
}
let entry_type = if metadata.is_dir() {
MemoryEntryType::Directory
} else if metadata.is_file() {
MemoryEntryType::File
} else {
continue;
};
entries.push(MemoryEntry {
path: display_relative_path(memories_root, &path),
entry_type,
});
}
entries
} else {
Vec::new()
};
if start_index > entries.len() {
return Err(ListMemoriesError::InvalidCursor {
cursor: start_index.to_string(),
reason: "exceeds result count".to_string(),
});
}
let end_index = start_index.saturating_add(max_results).min(entries.len());
let next_cursor = (end_index < entries.len()).then(|| end_index.to_string());
let truncated = next_cursor.is_some();
Ok(ListMemoriesResponse {
path: args.path,
entries: entries.drain(start_index..end_index).collect(),
next_cursor,
truncated,
})
}
async fn resolve_scoped_path(
memories_root: &Path,
relative_path: Option<&str>,
) -> Result<PathBuf, ListMemoriesError> {
let Some(relative_path) = relative_path else {
return Ok(memories_root.to_path_buf());
};
let relative = Path::new(relative_path);
if relative.components().any(|component| {
matches!(
component,
Component::ParentDir | Component::RootDir | Component::Prefix(_)
)
}) {
return Err(ListMemoriesError::InvalidPath {
path: relative_path.to_string(),
reason: "must stay within the memories root".to_string(),
});
}
if relative.components().any(is_hidden_component) {
return Err(ListMemoriesError::NotFound {
path: relative_path.to_string(),
});
}
let components = relative.components().collect::<Vec<_>>();
let mut scoped_path = memories_root.to_path_buf();
for (index, component) in components.iter().enumerate() {
scoped_path.push(component.as_os_str());
let Some(metadata) = metadata_or_none(&scoped_path).await? else {
for remaining_component in components.iter().skip(index + 1) {
scoped_path.push(remaining_component.as_os_str());
}
return Ok(scoped_path);
};
reject_symlink(
&display_relative_path(memories_root, &scoped_path),
&metadata,
)?;
if index + 1 < components.len() && !metadata.is_dir() {
return Err(ListMemoriesError::InvalidPath {
path: relative_path.to_string(),
reason: "traverses through a non-directory path component".to_string(),
});
}
}
Ok(scoped_path)
}
async fn metadata_or_none(path: &Path) -> Result<Option<std::fs::Metadata>, ListMemoriesError> {
match tokio::fs::symlink_metadata(path).await {
Ok(metadata) => Ok(Some(metadata)),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(err) => Err(err.into()),
}
}
fn reject_symlink(
relative_path: &str,
metadata: &std::fs::Metadata,
) -> Result<(), ListMemoriesError> {
if metadata.file_type().is_symlink() {
return Err(ListMemoriesError::InvalidPath {
path: relative_path.to_string(),
reason: "must not be a symlink".to_string(),
});
}
Ok(())
}
fn display_relative_path(root: &Path, path: &Path) -> String {
path.strip_prefix(root)
.map_or(Cow::Borrowed(path), Cow::Borrowed)
.to_string_lossy()
.replace(std::path::MAIN_SEPARATOR, "/")
}
fn is_hidden_path(path: &Path) -> bool {
path.file_name()
.is_some_and(|name| name.to_string_lossy().starts_with('.'))
}
fn is_hidden_component(component: Component<'_>) -> bool {
matches!(component, Component::Normal(name) if name.to_string_lossy().starts_with('.'))
}
async fn read_sorted_dir_paths(path: &Path) -> Result<Vec<PathBuf>, std::io::Error> {
let mut entries = tokio::fs::read_dir(path).await?;
let mut paths = Vec::new();
while let Some(entry) = entries.next_entry().await? {
paths.push(entry.path());
}
paths.sort();
Ok(paths)
}
fn create_list_memories_tool() -> ResponsesApiTool {
let properties = std::collections::BTreeMap::from([
(
"path".to_string(),
JsonSchema::string(Some(
"Optional relative path to list inside the Codex memories store.".to_string(),
)),
),
(
"cursor".to_string(),
JsonSchema::string(Some(
"Optional cursor returned by a previous list_memories call.".to_string(),
)),
),
(
"max_results".to_string(),
JsonSchema::integer(Some(
"Optional maximum number of entries to return.".to_string(),
)),
),
]);
ResponsesApiTool {
name: LIST_MEMORIES_TOOL_NAME.to_string(),
description:
"List immediate files and directories under a path in the Codex memories store."
.to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::object(properties, /*required*/ None, Some(false.into())),
output_schema: None,
}
}