mirror of
https://github.com/openai/codex.git
synced 2026-11-13 14:46:14 +00:00
feat: memories ext (#22498)
First memories extension implementation Based on memories-mcp tools
This commit is contained in:
1 parent
34bb85519f
commit
8ba6749932
12 files changed
+1503
No files matched your search
@@ -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)))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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()),
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user