feat: memories ext (#22498)

First memories extension implementation
Based on memories-mcp tools
This commit is contained in:
jif-oai authored and GitHub committed 2026-05-13 17:14:31 +02:00
1 parent 34bb85519f
commit 8ba6749932
12 files changed
+1503

No files matched your search

+69
View File
@@ -0,0 +1,69 @@
use codex_extension_api::ExtensionToolExecutor;
use codex_extension_api::ExtensionToolFuture;
use codex_extension_api::JsonToolOutput;
use codex_extension_api::ToolCall;
use codex_extension_api::ToolName;
use codex_extension_api::ToolSpec;
use schemars::JsonSchema;
use serde::Deserialize;
use serde_json::json;
use crate::backend::DEFAULT_LIST_MAX_RESULTS;
use crate::backend::ListMemoriesRequest;
use crate::backend::ListMemoriesResponse;
use crate::backend::MAX_LIST_RESULTS;
use crate::backend::MemoriesBackend;
use crate::local::LocalMemoriesBackend;
use super::LIST_TOOL_NAME;
use super::backend_error_to_function_call;
use super::clamp_max_results;
use super::function_tool;
use super::parse_args;
#[derive(Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
struct ListArgs {
path: Option<String>,
cursor: Option<String>,
#[schemars(range(min = 1))]
max_results: Option<usize>,
}
#[derive(Clone)]
pub(super) struct ListTool {
pub(super) backend: LocalMemoriesBackend,
}
impl ExtensionToolExecutor for ListTool {
fn tool_name(&self) -> ToolName {
ToolName::plain(LIST_TOOL_NAME)
}
fn spec(&self) -> Option<ToolSpec> {
Some(function_tool::<ListArgs, ListMemoriesResponse>(
LIST_TOOL_NAME,
"List immediate files and directories under a path in the Codex memories store.",
))
}
fn handle(&self, call: ToolCall) -> ExtensionToolFuture<'_> {
let backend = self.backend.clone();
Box::pin(async move {
let args: ListArgs = parse_args(&call)?;
let response = backend
.list(ListMemoriesRequest {
path: args.path,
cursor: args.cursor,
max_results: clamp_max_results(
args.max_results,
DEFAULT_LIST_MAX_RESULTS,
MAX_LIST_RESULTS,
),
})
.await
.map_err(backend_error_to_function_call)?;
Ok(JsonToolOutput::new(json!(response)))
})
}
}
+79
View File
@@ -0,0 +1,79 @@
use std::sync::Arc;
use codex_extension_api::ExtensionToolExecutor;
use codex_extension_api::FunctionCallError;
use codex_extension_api::ResponsesApiTool;
use codex_extension_api::ToolCall;
use codex_extension_api::ToolSpec;
use codex_extension_api::parse_tool_input_schema;
use schemars::JsonSchema;
use serde::Deserialize;
use serde_json::Value;
use crate::backend::MemoriesBackendError;
use crate::local::LocalMemoriesBackend;
use crate::schema;
mod list;
mod read;
mod search;
const LIST_TOOL_NAME: &str = "memory_list";
const READ_TOOL_NAME: &str = "memory_read";
const SEARCH_TOOL_NAME: &str = "memory_search";
pub(crate) fn memory_tools(backend: LocalMemoriesBackend) -> Vec<Arc<dyn ExtensionToolExecutor>> {
vec![
Arc::new(list::ListTool {
backend: backend.clone(),
}),
Arc::new(read::ReadTool {
backend: backend.clone(),
}),
Arc::new(search::SearchTool { backend }),
]
}
fn function_tool<I: JsonSchema, O: JsonSchema>(name: &str, description: &str) -> ToolSpec {
ToolSpec::Function(ResponsesApiTool {
name: name.to_string(),
description: description.to_string(),
strict: false,
defer_loading: None,
parameters: parse_tool_input_schema(&schema::input_schema_for::<I>())
.unwrap_or_else(|err| panic!("generated input schema for {name} should parse: {err}")),
output_schema: Some(schema::output_schema_for::<O>()),
})
}
fn parse_args<T: for<'de> Deserialize<'de>>(call: &ToolCall) -> Result<T, FunctionCallError> {
let arguments = call.function_arguments()?;
let value = if arguments.trim().is_empty() {
Value::Object(serde_json::Map::new())
} else {
serde_json::from_str(arguments)
.map_err(|err| FunctionCallError::RespondToModel(err.to_string()))?
};
serde_json::from_value(value).map_err(|err| FunctionCallError::RespondToModel(err.to_string()))
}
fn clamp_max_results(requested: Option<usize>, default: usize, max: usize) -> usize {
requested.unwrap_or(default).clamp(1, max)
}
fn backend_error_to_function_call(err: MemoriesBackendError) -> FunctionCallError {
match err {
MemoriesBackendError::InvalidPath { .. }
| MemoriesBackendError::InvalidCursor { .. }
| MemoriesBackendError::NotFound { .. }
| MemoriesBackendError::InvalidLineOffset
| MemoriesBackendError::InvalidMaxLines
| MemoriesBackendError::LineOffsetExceedsFileLength
| MemoriesBackendError::NotFile { .. }
| MemoriesBackendError::EmptyQuery
| MemoriesBackendError::InvalidMatchWindow => {
FunctionCallError::RespondToModel(err.to_string())
}
MemoriesBackendError::Io(_) => FunctionCallError::Fatal(err.to_string()),
}
}
+120
View File
@@ -0,0 +1,120 @@
use codex_extension_api::ExtensionToolExecutor;
use codex_extension_api::ExtensionToolFuture;
use codex_extension_api::JsonToolOutput;
use codex_extension_api::ToolCall;
use codex_extension_api::ToolName;
use codex_extension_api::ToolSpec;
use schemars::JsonSchema;
use serde::Deserialize;
use serde_json::json;
use crate::backend::DEFAULT_READ_MAX_TOKENS;
use crate::backend::MemoriesBackend;
use crate::backend::ReadMemoryRequest;
use crate::backend::ReadMemoryResponse;
use crate::local::LocalMemoriesBackend;
use super::READ_TOOL_NAME;
use super::backend_error_to_function_call;
use super::function_tool;
use super::parse_args;
#[derive(Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
struct ReadArgs {
path: String,
#[schemars(range(min = 1))]
line_offset: Option<usize>,
#[schemars(range(min = 1))]
max_lines: Option<usize>,
}
#[derive(Clone)]
pub(super) struct ReadTool {
pub(super) backend: LocalMemoriesBackend,
}
impl ExtensionToolExecutor for ReadTool {
fn tool_name(&self) -> ToolName {
ToolName::plain(READ_TOOL_NAME)
}
fn spec(&self) -> Option<ToolSpec> {
Some(function_tool::<ReadArgs, ReadMemoryResponse>(
READ_TOOL_NAME,
"Read a Codex memory file by relative path, optionally starting at a 1-indexed line offset and limiting the number of lines returned.",
))
}
fn handle(&self, call: ToolCall) -> ExtensionToolFuture<'_> {
let backend = self.backend.clone();
Box::pin(async move {
let args: ReadArgs = parse_args(&call)?;
let response = backend
.read(ReadMemoryRequest {
path: args.path,
line_offset: args.line_offset.unwrap_or(1),
max_lines: args.max_lines,
max_tokens: DEFAULT_READ_MAX_TOKENS,
})
.await
.map_err(backend_error_to_function_call)?;
Ok(JsonToolOutput::new(json!(response)))
})
}
}
#[cfg(test)]
mod tests {
use codex_extension_api::ToolPayload;
use codex_tools::ToolOutput;
use pretty_assertions::assert_eq;
use serde_json::json;
use super::*;
#[tokio::test]
async fn read_tool_reads_memory_file() {
let tempdir = tempfile::tempdir().expect("tempdir");
let memory_root = tempdir.path().join("memories");
tokio::fs::create_dir_all(&memory_root)
.await
.expect("create memories dir");
tokio::fs::write(
memory_root.join("MEMORY.md"),
"first line\nsecond needle line\nthird line\n",
)
.await
.expect("write memory");
let tool = ReadTool {
backend: LocalMemoriesBackend::from_memory_root(&memory_root),
};
let payload = ToolPayload::Function {
arguments: json!({
"path": "MEMORY.md",
"line_offset": 2,
"max_lines": 1
})
.to_string(),
};
let output = tool
.handle(ToolCall {
call_id: "call-1".to_string(),
tool_name: ToolName::plain(READ_TOOL_NAME),
payload: payload.clone(),
})
.await
.expect("read should succeed");
assert_eq!(
output.post_tool_use_response("call-1", &payload),
Some(json!({
"path": "MEMORY.md",
"content": "second needle line\n",
"start_line_number": 2,
"truncated": true
}))
);
}
}
+160
View File
@@ -0,0 +1,160 @@
use codex_extension_api::ExtensionToolExecutor;
use codex_extension_api::ExtensionToolFuture;
use codex_extension_api::JsonToolOutput;
use codex_extension_api::ToolCall;
use codex_extension_api::ToolName;
use codex_extension_api::ToolSpec;
use schemars::JsonSchema;
use serde::Deserialize;
use serde_json::json;
use crate::backend::DEFAULT_SEARCH_MAX_RESULTS;
use crate::backend::MAX_SEARCH_RESULTS;
use crate::backend::MemoriesBackend;
use crate::backend::SearchMatchMode;
use crate::backend::SearchMemoriesRequest;
use crate::backend::SearchMemoriesResponse;
use crate::local::LocalMemoriesBackend;
use super::SEARCH_TOOL_NAME;
use super::backend_error_to_function_call;
use super::clamp_max_results;
use super::function_tool;
use super::parse_args;
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
struct SearchArgs {
#[schemars(length(min = 1))]
queries: Vec<String>,
match_mode: Option<SearchMatchMode>,
path: Option<String>,
cursor: Option<String>,
#[schemars(range(min = 0))]
context_lines: Option<usize>,
case_sensitive: Option<bool>,
normalized: Option<bool>,
#[schemars(range(min = 1))]
max_results: Option<usize>,
}
#[derive(Clone)]
pub(super) struct SearchTool {
pub(super) backend: LocalMemoriesBackend,
}
impl ExtensionToolExecutor for SearchTool {
fn tool_name(&self) -> ToolName {
ToolName::plain(SEARCH_TOOL_NAME)
}
fn spec(&self) -> Option<ToolSpec> {
Some(function_tool::<SearchArgs, SearchMemoriesResponse>(
SEARCH_TOOL_NAME,
"Search Codex memory files for substring matches, optionally normalizing separators or requiring all query substrings on the same line or within a line window.",
))
}
fn handle(&self, call: ToolCall) -> ExtensionToolFuture<'_> {
let backend = self.backend.clone();
Box::pin(async move {
let args: SearchArgs = parse_args(&call)?;
let response = backend
.search(args.into_request())
.await
.map_err(backend_error_to_function_call)?;
Ok(JsonToolOutput::new(json!(response)))
})
}
}
impl SearchArgs {
fn into_request(self) -> SearchMemoriesRequest {
SearchMemoriesRequest {
queries: self.queries,
match_mode: self.match_mode.unwrap_or(SearchMatchMode::Any),
path: self.path,
cursor: self.cursor,
context_lines: self.context_lines.unwrap_or(0),
case_sensitive: self.case_sensitive.unwrap_or(true),
normalized: self.normalized.unwrap_or(false),
max_results: clamp_max_results(
self.max_results,
DEFAULT_SEARCH_MAX_RESULTS,
MAX_SEARCH_RESULTS,
),
}
}
}
#[cfg(test)]
mod tests {
use pretty_assertions::assert_eq;
use serde_json::json;
use super::*;
#[test]
fn search_args_accept_multiple_queries() {
let args: SearchArgs = serde_json::from_value(json!({
"queries": ["alpha", "needle"],
"case_sensitive": false
}))
.expect("multi-query args should parse");
let request = args.into_request();
assert_eq!(
request,
SearchMemoriesRequest {
queries: vec!["alpha".to_string(), "needle".to_string()],
match_mode: SearchMatchMode::Any,
path: None,
cursor: None,
context_lines: 0,
case_sensitive: false,
normalized: false,
max_results: DEFAULT_SEARCH_MAX_RESULTS,
}
);
}
#[test]
fn search_args_accept_windowed_all_match_mode() {
let args: SearchArgs = serde_json::from_value(json!({
"queries": ["alpha", "needle"],
"match_mode": {
"type": "all_within_lines",
"line_count": 3
}
}))
.expect("windowed all args should parse");
let request = args.into_request();
assert_eq!(
request,
SearchMemoriesRequest {
queries: vec!["alpha".to_string(), "needle".to_string()],
match_mode: SearchMatchMode::AllWithinLines { line_count: 3 },
path: None,
cursor: None,
context_lines: 0,
case_sensitive: true,
normalized: false,
max_results: DEFAULT_SEARCH_MAX_RESULTS,
}
);
}
#[test]
fn search_args_reject_legacy_single_query() {
let err = serde_json::from_value::<SearchArgs>(json!({
"query": "needle",
}))
.expect_err("legacy query field should be rejected");
assert!(err.to_string().contains("unknown field"));
assert!(err.to_string().contains("query"));
}
}