mirror of
https://github.com/openai/codex.git
synced 2026-05-12 23:32:44 +00:00
Compare commits
15 Commits
xli-codex/
...
jif/hello-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ee94382ba5 | ||
|
|
762c8ae093 | ||
|
|
1378f42e04 | ||
|
|
aa9bb90611 | ||
|
|
1f2badfde4 | ||
|
|
27953de5b9 | ||
|
|
2260be0983 | ||
|
|
7dd7ddb053 | ||
|
|
22068b02b6 | ||
|
|
4892ea4e53 | ||
|
|
cffcb59f5b | ||
|
|
ee1b490c0e | ||
|
|
19dc3d202f | ||
|
|
a7b6245e52 | ||
|
|
1af814fb51 |
49
codex-rs/Cargo.lock
generated
49
codex-rs/Cargo.lock
generated
@@ -2444,12 +2444,14 @@ dependencies = [
|
||||
"codex-core-skills",
|
||||
"codex-exec-server",
|
||||
"codex-execpolicy",
|
||||
"codex-extension-api",
|
||||
"codex-features",
|
||||
"codex-feedback",
|
||||
"codex-git-utils",
|
||||
"codex-hooks",
|
||||
"codex-login",
|
||||
"codex-mcp",
|
||||
"codex-memories",
|
||||
"codex-memories-read",
|
||||
"codex-model-provider",
|
||||
"codex-model-provider-info",
|
||||
@@ -2780,6 +2782,19 @@ dependencies = [
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "codex-extension-api"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"codex-git-attribution",
|
||||
"codex-guardian",
|
||||
"codex-memories",
|
||||
"codex-multi-agent-v2",
|
||||
"codex-tools",
|
||||
"serde_json",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "codex-external-agent-migration"
|
||||
version = "0.0.0"
|
||||
@@ -2858,6 +2873,13 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "codex-git-attribution"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"codex-extension-api",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "codex-git-utils"
|
||||
version = "0.0.0"
|
||||
@@ -2882,6 +2904,13 @@ dependencies = [
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "codex-guardian"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"codex-extension-api",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "codex-hooks"
|
||||
version = "0.0.0"
|
||||
@@ -3065,6 +3094,19 @@ dependencies = [
|
||||
"wiremock",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "codex-memories"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"codex-extension-api",
|
||||
"codex-memories-read",
|
||||
"codex-utils-absolute-path",
|
||||
"rmcp",
|
||||
"serde_json",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "codex-memories-mcp"
|
||||
version = "0.0.0"
|
||||
@@ -3206,6 +3248,13 @@ dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "codex-multi-agent-v2"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"codex-extension-api",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "codex-network-proxy"
|
||||
version = "0.0.0"
|
||||
|
||||
@@ -45,6 +45,11 @@ members = [
|
||||
"exec-server",
|
||||
"execpolicy",
|
||||
"execpolicy-legacy",
|
||||
"ext/extension-api",
|
||||
"ext/guardian",
|
||||
"ext/git-attribution",
|
||||
"ext/memories",
|
||||
"ext/multi-agent-v2",
|
||||
"external-agent-migration",
|
||||
"external-agent-sessions",
|
||||
"keyring-store",
|
||||
@@ -159,10 +164,15 @@ codex-exec = { path = "exec" }
|
||||
codex-file-system = { path = "file-system" }
|
||||
codex-exec-server = { path = "exec-server" }
|
||||
codex-execpolicy = { path = "execpolicy" }
|
||||
codex-extension-api = { path = "ext/extension-api" }
|
||||
codex-external-agent-migration = { path = "external-agent-migration" }
|
||||
codex-external-agent-sessions = { path = "external-agent-sessions" }
|
||||
codex-experimental-api-macros = { path = "codex-experimental-api-macros" }
|
||||
codex-features = { path = "features" }
|
||||
codex-guardian = { path = "ext/guardian" }
|
||||
codex-git-attribution = { path = "ext/git-attribution" }
|
||||
codex-memories = { path = "ext/memories" }
|
||||
codex-multi-agent-v2 = { path = "ext/multi-agent-v2" }
|
||||
codex-feedback = { path = "feedback" }
|
||||
codex-install-context = { path = "install-context" }
|
||||
codex-file-search = { path = "file-search" }
|
||||
|
||||
6
codex-rs/ext/extension-api/BUILD.bazel
Normal file
6
codex-rs/ext/extension-api/BUILD.bazel
Normal file
@@ -0,0 +1,6 @@
|
||||
load("//:defs.bzl", "codex_rust_crate")
|
||||
|
||||
codex_rust_crate(
|
||||
name = "extension-api",
|
||||
crate_name = "codex_extension_api",
|
||||
)
|
||||
18
codex-rs/ext/extension-api/Cargo.toml
Normal file
18
codex-rs/ext/extension-api/Cargo.toml
Normal file
@@ -0,0 +1,18 @@
|
||||
[package]
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
name = "codex-extension-api"
|
||||
version.workspace = true
|
||||
|
||||
[lib]
|
||||
name = "codex_extension_api"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
codex-protocol = { workspace = true }
|
||||
codex-tools = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
68
codex-rs/ext/extension-api/examples/enabled_extensions.rs
Normal file
68
codex-rs/ext/extension-api/examples/enabled_extensions.rs
Normal file
@@ -0,0 +1,68 @@
|
||||
#[path = "enabled_extensions/shared_state_extension.rs"]
|
||||
mod shared_state_extension;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use codex_extension_api::ExtensionData;
|
||||
use codex_extension_api::ExtensionRegistryBuilder;
|
||||
use shared_state_extension::SharedStateExtension;
|
||||
use shared_state_extension::recorded_style_contributions;
|
||||
use shared_state_extension::recorded_usage_contributions;
|
||||
|
||||
fn main() {
|
||||
// 1. Build the extension value owned by the host.
|
||||
let extension = Arc::new(SharedStateExtension);
|
||||
|
||||
// 2. Install it into the registry for the context type this host exposes.
|
||||
let registry = ExtensionRegistryBuilder::<()>::new()
|
||||
.with_extension(extension)
|
||||
.build();
|
||||
|
||||
// 3. The host decides which stores are shared.
|
||||
let session_store = ExtensionData::new();
|
||||
let first_thread_store = ExtensionData::new();
|
||||
let second_thread_store = ExtensionData::new();
|
||||
|
||||
// 4. Reusing the same session store shares session state across threads.
|
||||
let first_thread_fragments = contribute_prompt(®istry, &session_store, &first_thread_store);
|
||||
contribute_prompt(®istry, &session_store, &first_thread_store);
|
||||
contribute_prompt(®istry, &session_store, &second_thread_store);
|
||||
|
||||
println!("first prompt fragments: {}", first_thread_fragments.len());
|
||||
println!(
|
||||
"session style contributions: {}",
|
||||
recorded_style_contributions(&session_store)
|
||||
);
|
||||
println!(
|
||||
"session usage contributions: {}",
|
||||
recorded_usage_contributions(&session_store)
|
||||
);
|
||||
println!(
|
||||
"first thread style contributions: {}",
|
||||
recorded_style_contributions(&first_thread_store)
|
||||
);
|
||||
println!(
|
||||
"first thread usage contributions: {}",
|
||||
recorded_usage_contributions(&first_thread_store)
|
||||
);
|
||||
println!(
|
||||
"second thread style contributions: {}",
|
||||
recorded_style_contributions(&second_thread_store)
|
||||
);
|
||||
println!(
|
||||
"second thread usage contributions: {}",
|
||||
recorded_usage_contributions(&second_thread_store)
|
||||
);
|
||||
}
|
||||
|
||||
fn contribute_prompt(
|
||||
registry: &codex_extension_api::ExtensionRegistry<()>,
|
||||
session_store: &ExtensionData,
|
||||
thread_store: &ExtensionData,
|
||||
) -> Vec<codex_extension_api::PromptFragment> {
|
||||
registry
|
||||
.context_contributors()
|
||||
.iter()
|
||||
.flat_map(|contributor| contributor.contribute(&(), session_store, thread_store))
|
||||
.collect()
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::AtomicU64;
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
use codex_extension_api::CodexExtension;
|
||||
use codex_extension_api::ContextContributor;
|
||||
use codex_extension_api::ExtensionData;
|
||||
use codex_extension_api::ExtensionRegistryBuilder;
|
||||
use codex_extension_api::PromptFragment;
|
||||
|
||||
/// Small tutorial extension that installs two prompt contributors.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct SharedStateExtension;
|
||||
|
||||
impl CodexExtension<()> for SharedStateExtension {
|
||||
fn install(self: Arc<Self>, registry: &mut ExtensionRegistryBuilder<()>) {
|
||||
registry.prompt_contributor(Arc::new(StyleContributor));
|
||||
registry.prompt_contributor(Arc::new(UsageContributor));
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct StyleContributor;
|
||||
|
||||
impl ContextContributor<()> for StyleContributor {
|
||||
fn contribute(
|
||||
&self,
|
||||
_context: &(),
|
||||
session_store: &ExtensionData,
|
||||
thread_store: &ExtensionData,
|
||||
) -> Vec<PromptFragment> {
|
||||
contribution_counts(session_store).record_style();
|
||||
contribution_counts(thread_store).record_style();
|
||||
|
||||
vec![PromptFragment::developer_policy(
|
||||
"Prefer short answers unless the user asks for detail.",
|
||||
)]
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct UsageContributor;
|
||||
|
||||
impl ContextContributor<()> for UsageContributor {
|
||||
fn contribute(
|
||||
&self,
|
||||
_context: &(),
|
||||
session_store: &ExtensionData,
|
||||
thread_store: &ExtensionData,
|
||||
) -> Vec<PromptFragment> {
|
||||
contribution_counts(session_store).record_usage();
|
||||
contribution_counts(thread_store).record_usage();
|
||||
|
||||
vec![PromptFragment::developer_capability(
|
||||
"This extension can contribute more than one prompt fragment.",
|
||||
)]
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns how many style contributions were recorded in `store`.
|
||||
pub fn recorded_style_contributions(store: &ExtensionData) -> u64 {
|
||||
store
|
||||
.get::<ContributionCounts>()
|
||||
.map(|counts| counts.style())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Returns how many usage contributions were recorded in `store`.
|
||||
pub fn recorded_usage_contributions(store: &ExtensionData) -> u64 {
|
||||
store
|
||||
.get::<ContributionCounts>()
|
||||
.map(|counts| counts.usage())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct ContributionCounts {
|
||||
style: AtomicU64,
|
||||
usage: AtomicU64,
|
||||
}
|
||||
|
||||
impl ContributionCounts {
|
||||
fn record_style(&self) {
|
||||
self.style.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
fn record_usage(&self) {
|
||||
self.usage.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
fn style(&self) -> u64 {
|
||||
self.style.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
fn usage(&self) -> u64 {
|
||||
self.usage.load(Ordering::Relaxed)
|
||||
}
|
||||
}
|
||||
|
||||
fn contribution_counts(store: &ExtensionData) -> Arc<ContributionCounts> {
|
||||
store.get_or_init::<ContributionCounts>(Default::default)
|
||||
}
|
||||
14
codex-rs/ext/extension-api/notes.md
Normal file
14
codex-rs/ext/extension-api/notes.md
Normal file
@@ -0,0 +1,14 @@
|
||||
Everything becomes a good contributor design, which contributors do we need?
|
||||
|
||||
git attribution Context
|
||||
memories Context + Tool + Output
|
||||
guardian Context + Request
|
||||
goal Tool + Runtime
|
||||
image generation Tool + Output
|
||||
skills Context + Turn
|
||||
personality Context
|
||||
plugins / apps / connectors Context + Turn
|
||||
shell snapshot Runtime
|
||||
web search Tool
|
||||
AGENTS.md Context (Runtime too only if you want eager refresh/cache behavior)
|
||||
future sandboxing probably Request + Runtime
|
||||
62
codex-rs/ext/extension-api/src/contributors.rs
Normal file
62
codex-rs/ext/extension-api/src/contributors.rs
Normal file
@@ -0,0 +1,62 @@
|
||||
use std::future::Future;
|
||||
|
||||
use codex_protocol::items::TurnItem;
|
||||
|
||||
use crate::ExtensionData;
|
||||
|
||||
mod prompt;
|
||||
mod tool;
|
||||
|
||||
pub use prompt::PromptFragment;
|
||||
pub use prompt::PromptSlot;
|
||||
pub use tool::ToolCallError;
|
||||
pub use tool::ToolContribution;
|
||||
pub use tool::ToolHandler;
|
||||
|
||||
/// Extension contribution that adds prompt fragments during prompt assembly.
|
||||
pub trait ContextContributor<C>: Send + Sync {
|
||||
fn contribute(
|
||||
&self,
|
||||
context: &C,
|
||||
session_store: &ExtensionData,
|
||||
thread_store: &ExtensionData,
|
||||
) -> Vec<PromptFragment>;
|
||||
}
|
||||
|
||||
/// Extension contribution that exposes native tools owned by a feature.
|
||||
pub trait ToolContributor<C>: Send + Sync {
|
||||
/// Returns the native tools visible for the supplied runtime context.
|
||||
fn tools(&self, context: &C, thread_store: &ExtensionData) -> Vec<ToolContribution<C>>;
|
||||
}
|
||||
|
||||
/// Future returned by one ordered turn-item contribution.
|
||||
pub type TurnItemContributionFuture<'a> =
|
||||
std::pin::Pin<Box<dyn Future<Output = Result<(), String>> + Send + 'a>>;
|
||||
|
||||
/// Ordered post-processing contribution for one parsed turn item.
|
||||
///
|
||||
/// Implementations may mutate the item before it is emitted and may use the
|
||||
/// explicitly exposed thread- and turn-lifetime stores when they need durable
|
||||
/// extension-private state.
|
||||
pub trait TurnItemContributor<C>: Send + Sync {
|
||||
fn contribute<'a>(
|
||||
&'a self,
|
||||
context: &'a C,
|
||||
thread_store: &'a ExtensionData,
|
||||
turn_store: &'a ExtensionData,
|
||||
item: &'a mut TurnItem,
|
||||
) -> TurnItemContributionFuture<'a>;
|
||||
}
|
||||
|
||||
// TODO: WIP (do not consider)
|
||||
/// Extension contribution that can claim approval requests for a runtime context.
|
||||
/// (ideally we can replace it by a session lifecycle thing or a request contributor?)
|
||||
pub trait ApprovalInterceptorContributor<C>: Send + Sync {
|
||||
/// Returns whether this contributor should intercept approvals in `context`.
|
||||
fn intercepts_approvals(
|
||||
&self,
|
||||
context: &C,
|
||||
thread_store: &ExtensionData,
|
||||
turn_store: &ExtensionData,
|
||||
) -> bool;
|
||||
}
|
||||
50
codex-rs/ext/extension-api/src/contributors/prompt.rs
Normal file
50
codex-rs/ext/extension-api/src/contributors/prompt.rs
Normal file
@@ -0,0 +1,50 @@
|
||||
// All this file should be replaced by the existing fragment implementation ofc
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub enum PromptSlot {
|
||||
DeveloperPolicy,
|
||||
DeveloperCapabilities,
|
||||
ContextualUser,
|
||||
SeparateDeveloper,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct PromptFragment {
|
||||
slot: PromptSlot,
|
||||
text: String,
|
||||
}
|
||||
|
||||
impl PromptFragment {
|
||||
/// Creates a prompt fragment for the given slot.
|
||||
pub fn new(slot: PromptSlot, text: impl Into<String>) -> Self {
|
||||
Self {
|
||||
slot,
|
||||
text: text.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a developer-policy prompt fragment.
|
||||
pub fn developer_policy(text: impl Into<String>) -> Self {
|
||||
Self::new(PromptSlot::DeveloperPolicy, text)
|
||||
}
|
||||
|
||||
/// Creates a developer-capabilities prompt fragment.
|
||||
pub fn developer_capability(text: impl Into<String>) -> Self {
|
||||
Self::new(PromptSlot::DeveloperCapabilities, text)
|
||||
}
|
||||
|
||||
/// Creates a separate top-level developer prompt fragment.
|
||||
pub fn separate_developer(text: impl Into<String>) -> Self {
|
||||
Self::new(PromptSlot::SeparateDeveloper, text)
|
||||
}
|
||||
|
||||
/// Returns the target prompt slot.
|
||||
pub fn slot(&self) -> PromptSlot {
|
||||
self.slot
|
||||
}
|
||||
|
||||
/// Returns the model-visible text.
|
||||
pub fn text(&self) -> &str {
|
||||
&self.text
|
||||
}
|
||||
}
|
||||
69
codex-rs/ext/extension-api/src/contributors/tool.rs
Normal file
69
codex-rs/ext/extension-api/src/contributors/tool.rs
Normal file
@@ -0,0 +1,69 @@
|
||||
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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
12
codex-rs/ext/extension-api/src/extension.rs
Normal file
12
codex-rs/ext/extension-api/src/extension.rs
Normal file
@@ -0,0 +1,12 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::ExtensionRegistryBuilder;
|
||||
|
||||
/// First-party extension that can install one or more typed runtime contributions.
|
||||
///
|
||||
/// Implementations should use [`Self::install`] only to register the concrete
|
||||
/// providers they own.
|
||||
pub trait CodexExtension<C>: Send + Sync {
|
||||
/// Registers this extension's concrete typed contributions.
|
||||
fn install(self: Arc<Self>, registry: &mut ExtensionRegistryBuilder<C>);
|
||||
}
|
||||
19
codex-rs/ext/extension-api/src/lib.rs
Normal file
19
codex-rs/ext/extension-api/src/lib.rs
Normal file
@@ -0,0 +1,19 @@
|
||||
mod contributors;
|
||||
mod extension;
|
||||
mod registry;
|
||||
mod state;
|
||||
|
||||
pub use contributors::ApprovalInterceptorContributor;
|
||||
pub use contributors::ContextContributor;
|
||||
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 contributors::TurnItemContributionFuture;
|
||||
pub use contributors::TurnItemContributor;
|
||||
pub use extension::CodexExtension;
|
||||
pub use registry::ExtensionRegistry;
|
||||
pub use registry::ExtensionRegistryBuilder;
|
||||
pub use state::ExtensionData;
|
||||
116
codex-rs/ext/extension-api/src/registry.rs
Normal file
116
codex-rs/ext/extension-api/src/registry.rs
Normal file
@@ -0,0 +1,116 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::ApprovalInterceptorContributor;
|
||||
use crate::CodexExtension;
|
||||
use crate::ContextContributor;
|
||||
use crate::ToolContributor;
|
||||
use crate::TurnItemContributor;
|
||||
|
||||
/// Mutable registry used while extensions install their typed contributions.
|
||||
pub struct ExtensionRegistryBuilder<C> {
|
||||
context_contributors: Vec<Arc<dyn ContextContributor<C>>>,
|
||||
tool_contributors: Vec<Arc<dyn ToolContributor<C>>>,
|
||||
turn_item_contributors: Vec<Arc<dyn TurnItemContributor<C>>>,
|
||||
approval_interceptor_contributors: Vec<Arc<dyn ApprovalInterceptorContributor<C>>>,
|
||||
}
|
||||
|
||||
impl<C> Default for ExtensionRegistryBuilder<C> {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
approval_interceptor_contributors: Vec::new(),
|
||||
context_contributors: Vec::new(),
|
||||
tool_contributors: Vec::new(),
|
||||
turn_item_contributors: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<C> ExtensionRegistryBuilder<C> {
|
||||
/// Creates an empty registry builder.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Installs one extension and returns the builder.
|
||||
#[must_use]
|
||||
pub fn with_extension<E>(mut self, extension: Arc<E>) -> Self
|
||||
where
|
||||
E: CodexExtension<C> + 'static,
|
||||
{
|
||||
self.install_extension(extension);
|
||||
self
|
||||
}
|
||||
|
||||
/// Installs one extension into the registry under construction.
|
||||
pub fn install_extension<E>(&mut self, extension: Arc<E>)
|
||||
where
|
||||
E: CodexExtension<C> + 'static,
|
||||
{
|
||||
extension.install(self);
|
||||
}
|
||||
|
||||
/// 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.
|
||||
pub fn prompt_contributor(&mut self, contributor: Arc<dyn ContextContributor<C>>) {
|
||||
self.context_contributors.push(contributor);
|
||||
}
|
||||
|
||||
/// Registers one native tool contributor.
|
||||
pub fn tool_contributor(&mut self, contributor: Arc<dyn ToolContributor<C>>) {
|
||||
self.tool_contributors.push(contributor);
|
||||
}
|
||||
|
||||
/// Registers one ordered turn-item contributor.
|
||||
pub fn turn_item_contributor(&mut self, contributor: Arc<dyn TurnItemContributor<C>>) {
|
||||
self.turn_item_contributors.push(contributor);
|
||||
}
|
||||
|
||||
/// Finishes construction and returns the immutable registry.
|
||||
pub fn build(self) -> ExtensionRegistry<C> {
|
||||
ExtensionRegistry {
|
||||
approval_interceptor_contributors: self.approval_interceptor_contributors,
|
||||
context_contributors: self.context_contributors,
|
||||
tool_contributors: self.tool_contributors,
|
||||
turn_item_contributors: self.turn_item_contributors,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Immutable typed registry produced after extensions are installed.
|
||||
pub struct ExtensionRegistry<C> {
|
||||
context_contributors: Vec<Arc<dyn ContextContributor<C>>>,
|
||||
tool_contributors: Vec<Arc<dyn ToolContributor<C>>>,
|
||||
turn_item_contributors: Vec<Arc<dyn TurnItemContributor<C>>>,
|
||||
approval_interceptor_contributors: Vec<Arc<dyn ApprovalInterceptorContributor<C>>>,
|
||||
}
|
||||
|
||||
impl<C> ExtensionRegistry<C> {
|
||||
/// 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 context_contributors(&self) -> &[Arc<dyn ContextContributor<C>>] {
|
||||
&self.context_contributors
|
||||
}
|
||||
|
||||
/// Returns the registered native tool contributors.
|
||||
pub fn tool_contributors(&self) -> &[Arc<dyn ToolContributor<C>>] {
|
||||
&self.tool_contributors
|
||||
}
|
||||
|
||||
/// Returns the registered ordered turn-item contributors.
|
||||
pub fn turn_item_contributors(&self) -> &[Arc<dyn TurnItemContributor<C>>] {
|
||||
&self.turn_item_contributors
|
||||
}
|
||||
}
|
||||
77
codex-rs/ext/extension-api/src/state.rs
Normal file
77
codex-rs/ext/extension-api/src/state.rs
Normal file
@@ -0,0 +1,77 @@
|
||||
use std::any::Any;
|
||||
use std::any::TypeId;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex;
|
||||
use std::sync::PoisonError;
|
||||
|
||||
type ErasedData = Arc<dyn Any + Send + Sync>;
|
||||
|
||||
/// Typed extension-owned data attached to one host object.
|
||||
#[derive(Default, Debug)]
|
||||
pub struct ExtensionData {
|
||||
entries: Mutex<HashMap<TypeId, ErasedData>>,
|
||||
}
|
||||
|
||||
impl ExtensionData {
|
||||
/// Creates an empty attachment map.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Returns the attached value of type `T`, if one exists.
|
||||
pub fn get<T>(&self) -> Option<Arc<T>>
|
||||
where
|
||||
T: Any + Send + Sync,
|
||||
{
|
||||
let value = self.entries().get(&TypeId::of::<T>())?.clone();
|
||||
Some(downcast_data(value))
|
||||
}
|
||||
|
||||
/// Returns the attached value of type `T`, inserting one from `init` when absent.
|
||||
///
|
||||
/// The initializer runs while this map is locked, so it should stay cheap;
|
||||
/// heavyweight lazy work belongs inside the attached value itself.
|
||||
pub fn get_or_init<T>(&self, init: impl FnOnce() -> T) -> Arc<T>
|
||||
where
|
||||
T: Any + Send + Sync,
|
||||
{
|
||||
let mut entries = self.entries();
|
||||
let value = entries
|
||||
.entry(TypeId::of::<T>())
|
||||
.or_insert_with(|| Arc::new(init()));
|
||||
downcast_data(Arc::clone(value))
|
||||
}
|
||||
|
||||
/// Stores `value` as the attachment of type `T`, returning any previous value.
|
||||
pub fn insert<T>(&self, value: T) -> Option<Arc<T>>
|
||||
where
|
||||
T: Any + Send + Sync,
|
||||
{
|
||||
self.entries()
|
||||
.insert(TypeId::of::<T>(), Arc::new(value))
|
||||
.map(downcast_data)
|
||||
}
|
||||
|
||||
/// Removes and returns the attached value of type `T`, if one exists.
|
||||
pub fn remove<T>(&self) -> Option<Arc<T>>
|
||||
where
|
||||
T: Any + Send + Sync,
|
||||
{
|
||||
self.entries().remove(&TypeId::of::<T>()).map(downcast_data)
|
||||
}
|
||||
|
||||
fn entries(&self) -> std::sync::MutexGuard<'_, HashMap<TypeId, ErasedData>> {
|
||||
self.entries.lock().unwrap_or_else(PoisonError::into_inner)
|
||||
}
|
||||
}
|
||||
|
||||
fn downcast_data<T>(value: ErasedData) -> Arc<T>
|
||||
where
|
||||
T: Any + Send + Sync,
|
||||
{
|
||||
let Ok(value) = value.downcast::<T>() else {
|
||||
unreachable!("typed extension data stored an incompatible value");
|
||||
};
|
||||
value
|
||||
}
|
||||
6
codex-rs/ext/git-attribution/BUILD.bazel
Normal file
6
codex-rs/ext/git-attribution/BUILD.bazel
Normal file
@@ -0,0 +1,6 @@
|
||||
load("//:defs.bzl", "codex_rust_crate")
|
||||
|
||||
codex_rust_crate(
|
||||
name = "git-attribution",
|
||||
crate_name = "codex_git_attribution",
|
||||
)
|
||||
15
codex-rs/ext/git-attribution/Cargo.toml
Normal file
15
codex-rs/ext/git-attribution/Cargo.toml
Normal file
@@ -0,0 +1,15 @@
|
||||
[package]
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
name = "codex-git-attribution"
|
||||
version.workspace = true
|
||||
|
||||
[lib]
|
||||
name = "codex_git_attribution"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
codex-extension-api = { workspace = true }
|
||||
72
codex-rs/ext/git-attribution/src/lib.rs
Normal file
72
codex-rs/ext/git-attribution/src/lib.rs
Normal file
@@ -0,0 +1,72 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use codex_extension_api::CodexExtension;
|
||||
use codex_extension_api::ContextContributor;
|
||||
use codex_extension_api::ExtensionRegistryBuilder;
|
||||
use codex_extension_api::PromptFragment;
|
||||
use codex_extension_api::Stores;
|
||||
|
||||
const DEFAULT_ATTRIBUTION_VALUE: &str = "Codex <noreply@openai.com>";
|
||||
|
||||
pub trait GitAttributionContext {
|
||||
fn commit_attribution(&self) -> Option<&str>;
|
||||
}
|
||||
|
||||
/// Prompt-only extension that contributes the configured git-attribution instruction.
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct GitAttributionExtension;
|
||||
|
||||
impl GitAttributionExtension {
|
||||
/// Creates an extension instance.
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
/// Returns the model-visible trailer instruction, if attribution is enabled.
|
||||
pub fn instruction<C: GitAttributionContext>(&self, context: &C) -> Option<String> {
|
||||
let trailer = build_commit_message_trailer(context.commit_attribution())?;
|
||||
Some(format!(
|
||||
"When you write or edit a git commit message, ensure the message ends with this trailer exactly once:\n{trailer}\n\nRules:\n- Keep existing trailers and append this trailer at the end if missing.\n- Do not duplicate this trailer if it already exists.\n- Keep one blank line between the commit body and trailer block."
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
impl<C: GitAttributionContext> ContextContributor<C> for GitAttributionExtension {
|
||||
fn contribute(&self, context: &C, _stores: &Stores<'_>) -> Vec<PromptFragment> {
|
||||
self.instruction(context)
|
||||
.map(PromptFragment::developer_capability)
|
||||
.into_iter()
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl<C: GitAttributionContext> CodexExtension<C> for GitAttributionExtension {
|
||||
fn install(self: Arc<Self>, registry: &mut ExtensionRegistryBuilder<C>) {
|
||||
registry.prompt_contributor(self);
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a shared git-attribution extension instance.
|
||||
pub fn extension() -> Arc<GitAttributionExtension> {
|
||||
Arc::new(GitAttributionExtension::new())
|
||||
}
|
||||
|
||||
// This is just a copy/paste
|
||||
fn build_commit_message_trailer(config_attribution: Option<&str>) -> Option<String> {
|
||||
let value = resolve_attribution_value(config_attribution)?;
|
||||
Some(format!("Co-authored-by: {value}"))
|
||||
}
|
||||
|
||||
fn resolve_attribution_value(config_attribution: Option<&str>) -> Option<String> {
|
||||
match config_attribution {
|
||||
Some(value) => {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(trimmed.to_string())
|
||||
}
|
||||
}
|
||||
None => Some(DEFAULT_ATTRIBUTION_VALUE.to_string()),
|
||||
}
|
||||
}
|
||||
6
codex-rs/ext/memories/BUILD.bazel
Normal file
6
codex-rs/ext/memories/BUILD.bazel
Normal file
@@ -0,0 +1,6 @@
|
||||
load("//:defs.bzl", "codex_rust_crate")
|
||||
|
||||
codex_rust_crate(
|
||||
name = "memories",
|
||||
crate_name = "codex_memories",
|
||||
)
|
||||
25
codex-rs/ext/memories/Cargo.toml
Normal file
25
codex-rs/ext/memories/Cargo.toml
Normal file
@@ -0,0 +1,25 @@
|
||||
[package]
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
name = "codex-memories"
|
||||
version.workspace = true
|
||||
|
||||
[lib]
|
||||
name = "codex_memories"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
codex-extension-api = { workspace = true }
|
||||
codex-memories-read = { workspace = true }
|
||||
codex-protocol = { workspace = true }
|
||||
codex-tools = { workspace = true }
|
||||
codex-utils-absolute-path = { workspace = true }
|
||||
codex-utils-stream-parser = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
tokio = { workspace = true, features = ["fs"] }
|
||||
tracing = { workspace = true }
|
||||
70
codex-rs/ext/memories/src/citation_output.rs
Normal file
70
codex-rs/ext/memories/src/citation_output.rs
Normal file
@@ -0,0 +1,70 @@
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::atomic::AtomicU64;
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
use codex_extension_api::OutputContributionFuture;
|
||||
use codex_extension_api::OutputContributor;
|
||||
use codex_extension_api::Stores;
|
||||
use codex_extension_api::scopes::Thread;
|
||||
use codex_extension_api::scopes::Turn;
|
||||
use codex_memories_read::citations::parse_memory_citation;
|
||||
use codex_protocol::items::AgentMessageContent;
|
||||
use codex_protocol::items::TurnItem;
|
||||
use codex_utils_stream_parser::strip_citations;
|
||||
|
||||
use crate::MemoriesExtension;
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct ThreadMemoriesState {
|
||||
turns_with_citations: AtomicU64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct TurnMemoriesState {
|
||||
had_citation: AtomicBool,
|
||||
}
|
||||
|
||||
impl<C> OutputContributor<C, TurnItem> for MemoriesExtension {
|
||||
fn contribute<'a>(
|
||||
&'a self,
|
||||
_context: &'a C,
|
||||
stores: &'a Stores<'a>,
|
||||
output: &'a mut TurnItem,
|
||||
) -> OutputContributionFuture<'a> {
|
||||
if let TurnItem::AgentMessage(agent_message) = output {
|
||||
let combined = agent_message
|
||||
.content
|
||||
.iter()
|
||||
.map(|entry| match entry {
|
||||
AgentMessageContent::Text { text } => text.as_str(),
|
||||
})
|
||||
.collect::<String>();
|
||||
let (visible_text, citations) = strip_citations(&combined);
|
||||
agent_message.content = vec![AgentMessageContent::Text { text: visible_text }];
|
||||
agent_message.memory_citation = parse_memory_citation(citations);
|
||||
|
||||
if agent_message.memory_citation.is_some()
|
||||
&& let Some(turns_with_citations) = record_citation_seen(stores)
|
||||
{
|
||||
tracing::info!(turns_with_citations, "memory citation seen in turn");
|
||||
}
|
||||
}
|
||||
|
||||
Box::pin(std::future::ready(Ok(())))
|
||||
}
|
||||
}
|
||||
|
||||
fn record_citation_seen(stores: &Stores<'_>) -> Option<u64> {
|
||||
let turn_state = stores.get_or_init::<Turn, TurnMemoriesState>(Default::default);
|
||||
if turn_state.had_citation.swap(true, Ordering::Relaxed) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let thread_stats = stores.get_or_init::<Thread, ThreadMemoriesState>(Default::default);
|
||||
Some(
|
||||
thread_stats
|
||||
.turns_with_citations
|
||||
.fetch_add(1, Ordering::Relaxed)
|
||||
+ 1,
|
||||
)
|
||||
}
|
||||
8
codex-rs/ext/memories/src/ctx.rs
Normal file
8
codex-rs/ext/memories/src/ctx.rs
Normal file
@@ -0,0 +1,8 @@
|
||||
/// Runtime facts needed to decide whether read-memory surfaces are visible.
|
||||
///
|
||||
/// Hosts should expose the current effective values for the thread being
|
||||
/// assembled. The extension owns the policy that combines those values.
|
||||
pub trait MemoriesContext {
|
||||
fn memory_tool_enabled(&self) -> bool;
|
||||
fn use_memories(&self) -> bool;
|
||||
}
|
||||
63
codex-rs/ext/memories/src/lib.rs
Normal file
63
codex-rs/ext/memories/src/lib.rs
Normal file
@@ -0,0 +1,63 @@
|
||||
mod citation_output;
|
||||
pub mod ctx;
|
||||
mod list_tool;
|
||||
mod prompt_contributor;
|
||||
mod tool_contributor;
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::ctx::MemoriesContext;
|
||||
use codex_extension_api::CodexExtension;
|
||||
use codex_extension_api::ExtensionRegistryBuilder;
|
||||
use codex_memories_read::build_memory_tool_developer_instructions;
|
||||
use codex_memories_read::memory_root;
|
||||
use codex_protocol::items::TurnItem;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use list_tool::ListMemoriesTool;
|
||||
|
||||
/// Extension that contributes memories read surfaces.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct MemoriesExtension {
|
||||
read_prompt: Option<String>,
|
||||
pub(crate) list_tool: Arc<ListMemoriesTool>, // This is just to have useful examples, it will disappear
|
||||
}
|
||||
|
||||
impl<C> CodexExtension<C> for MemoriesExtension
|
||||
where
|
||||
C: MemoriesContext + Send + Sync + 'static,
|
||||
{
|
||||
fn install(self: Arc<Self>, registry: &mut ExtensionRegistryBuilder<C>) {
|
||||
registry.tool_contributor(self.clone());
|
||||
registry.output_contributor::<TurnItem>(self.clone());
|
||||
registry.prompt_contributor(self);
|
||||
}
|
||||
}
|
||||
|
||||
impl MemoriesExtension {
|
||||
/// Creates a memories extension from the prompt text and backing directory
|
||||
/// it should expose.
|
||||
pub fn new(read_prompt: Option<String>, memories_root: impl Into<PathBuf>) -> Self {
|
||||
Self {
|
||||
read_prompt,
|
||||
list_tool: Arc::new(ListMemoriesTool::new(memories_root)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the rendered developer instruction for read access, if available.
|
||||
pub fn read_prompt(&self) -> Option<&str> {
|
||||
self.read_prompt.as_deref()
|
||||
}
|
||||
|
||||
// Just for example
|
||||
pub(crate) fn is_read_surface_enabled<C: MemoriesContext>(&self, context: &C) -> bool {
|
||||
context.memory_tool_enabled() && context.use_memories()
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn extension(codex_home: &AbsolutePathBuf) -> Arc<MemoriesExtension> {
|
||||
Arc::new(MemoriesExtension::new(
|
||||
build_memory_tool_developer_instructions(codex_home).await,
|
||||
memory_root(codex_home).to_path_buf(),
|
||||
))
|
||||
}
|
||||
323
codex-rs/ext/memories/src/list_tool.rs
Normal file
323
codex-rs/ext/memories/src/list_tool.rs
Normal file
@@ -0,0 +1,323 @@
|
||||
// This file must be replaced by tools from `codex-memories-mcp` once the extracted tools land. This is just a vibe-coded copy/paster for now.
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
19
codex-rs/ext/memories/src/prompt_contributor.rs
Normal file
19
codex-rs/ext/memories/src/prompt_contributor.rs
Normal file
@@ -0,0 +1,19 @@
|
||||
use codex_extension_api::ContextContributor;
|
||||
use codex_extension_api::PromptFragment;
|
||||
use codex_extension_api::Stores;
|
||||
|
||||
use crate::MemoriesExtension;
|
||||
use crate::ctx::MemoriesContext;
|
||||
|
||||
impl<C: MemoriesContext> ContextContributor<C> for MemoriesExtension {
|
||||
fn contribute(&self, context: &C, _stores: &Stores<'_>) -> Vec<PromptFragment> {
|
||||
if !self.is_read_surface_enabled(context) {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
self.read_prompt()
|
||||
.map(PromptFragment::developer_policy)
|
||||
.into_iter()
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
17
codex-rs/ext/memories/src/tool_contributor.rs
Normal file
17
codex-rs/ext/memories/src/tool_contributor.rs
Normal file
@@ -0,0 +1,17 @@
|
||||
use codex_extension_api::Stores;
|
||||
use codex_extension_api::ToolContribution;
|
||||
use codex_extension_api::ToolContributor;
|
||||
|
||||
use crate::MemoriesExtension;
|
||||
use crate::ctx::MemoriesContext;
|
||||
|
||||
impl<C: MemoriesContext + Send + Sync + 'static> ToolContributor<C> for MemoriesExtension {
|
||||
fn tools(&self, context: &C, _stores: &Stores<'_>) -> Vec<ToolContribution<C>> {
|
||||
if !self.is_read_surface_enabled(context) {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
// TODO(jif) add more tools ofc
|
||||
vec![self.list_tool.contribution()]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user