Compare commits

...

5 Commits

Author SHA1 Message Date
Jeremy Rose
55fe41e243 Merge branch 'main' into nornagon/self-knowledge 2025-09-04 13:10:51 -07:00
Jeremy Rose
d36118a8fd fix tests 2025-09-03 13:51:48 -07:00
Jeremy Rose
6e946d1707 Merge remote-tracking branch 'origin/main' into nornagon/self-knowledge
# Conflicts:
#	codex-rs/core/src/lib.rs
2025-09-03 11:13:58 -07:00
Jeremy Rose
c24b0fcc78 generalize to all docs 2025-09-01 20:20:33 -07:00
Jeremy Rose
2ad649684f wip 2025-09-01 17:38:11 -07:00
7 changed files with 480 additions and 13 deletions

36
codex-rs/Cargo.lock generated
View File

@@ -728,6 +728,7 @@ dependencies = [
"rand 0.9.2",
"regex-lite",
"reqwest",
"rust-embed",
"seccompiler",
"serde",
"serde_bytes",
@@ -4024,6 +4025,41 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "rust-embed"
version = "8.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "025908b8682a26ba8d12f6f2d66b987584a4a87bc024abc5bbc12553a8cd178a"
dependencies = [
"rust-embed-impl",
"rust-embed-utils",
"walkdir",
]
[[package]]
name = "rust-embed-impl"
version = "8.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6065f1a4392b71819ec1ea1df1120673418bf386f50de1d6f54204d836d4349c"
dependencies = [
"proc-macro2",
"quote",
"rust-embed-utils",
"syn 2.0.104",
"walkdir",
]
[[package]]
name = "rust-embed-utils"
version = "8.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6cc0c81648b20b70c491ff8cce00c1c3b223bb8ed2b5d41f0e54c6c4c0a3594"
dependencies = [
"globset",
"sha2",
"walkdir",
]
[[package]]
name = "rustc-demangle"
version = "0.1.25"

View File

@@ -58,6 +58,7 @@ tree-sitter-bash = "0.25.0"
uuid = { version = "1", features = ["serde", "v4"] }
whoami = "1.6.1"
wildmatch = "2.4.0"
rust-embed = { version = "8.5.0", features = ["include-exclude"] }
[target.'cfg(target_os = "linux")'.dependencies]

View File

@@ -42,6 +42,9 @@ use crate::client::ModelClient;
use crate::client_common::Prompt;
use crate::client_common::ResponseEvent;
use crate::config::Config;
use crate::config_edit_tool::handle_get_config;
use crate::config_edit_tool::handle_set_config;
use crate::config_edit_tool::handle_show_codex_docs;
use crate::config_types::ShellEnvironmentPolicy;
use crate::conversation_history::ConversationHistory;
use crate::conversation_manager::InitialHistory;
@@ -2127,6 +2130,9 @@ async fn handle_function_call(
.await
}
"update_plan" => handle_update_plan(sess, arguments, sub_id, call_id).await,
"get_config" => handle_get_config(sess, arguments, sub_id, call_id).await,
"set_config" => handle_set_config(sess, arguments, sub_id, call_id).await,
"show_codex_docs" => handle_show_codex_docs(sess, arguments, sub_id, call_id).await,
EXEC_COMMAND_TOOL_NAME => {
// TODO(mbolin): Sandbox check.
let exec_params = match serde_json::from_str::<ExecCommandParams>(&arguments) {

View File

@@ -0,0 +1,347 @@
use crate::codex::Session;
use crate::config::find_codex_home;
use crate::openai_tools::JsonSchema;
use crate::openai_tools::OpenAiTool;
use crate::openai_tools::ResponsesApiTool;
use crate::protocol::ReviewDecision;
use codex_apply_patch::ApplyPatchAction;
use codex_apply_patch::MaybeApplyPatchVerified;
use codex_apply_patch::maybe_parse_apply_patch_verified;
use codex_protocol::models::FunctionCallOutputPayload;
use codex_protocol::models::ResponseInputItem;
use rust_embed::RustEmbed;
use serde::Deserialize;
use std::collections::BTreeMap;
use std::fs;
use std::path::Path;
use std::path::PathBuf;
const CONFIG_TOML_FILE: &str = "config.toml";
// Embed docs at compile time.
// README from the repo root
const README_MD: &str = include_str!("../../../README.md");
// Entire docs directory from the repo root (only *.md files are embedded)
#[derive(RustEmbed)]
#[folder = "../../docs"]
#[include = "**/*.md"]
struct EmbeddedDocs;
fn push_separator(buf: &mut String) {
buf.push_str("\n\n---\n\n");
}
fn build_all_codex_docs() -> String {
let mut out = String::new();
out.push_str("# Codex Documentation\n\n");
out.push_str("<!-- Source: README.md -->\n\n");
out.push_str(README_MD);
// Add markdown files from ../docs recursively (embedded at compile time)
let mut paths: Vec<String> = EmbeddedDocs::iter()
.map(|p| p.as_ref().to_string())
.collect();
paths.sort();
for path in paths.into_iter() {
if let Some(file) = EmbeddedDocs::get(&path) {
push_separator(&mut out);
out.push_str(&format!("<!-- Source: {path} -->\n\n"));
out.push_str(&String::from_utf8_lossy(&file.data));
}
}
out
}
/// get_config() — fetches the current config.toml.
pub(crate) fn create_get_config_tool() -> OpenAiTool {
OpenAiTool::Function(ResponsesApiTool {
name: "get_config".to_string(),
description: "Gets the current ~/.codex/config.toml. If the user asks about their configuration or wants to review it, call this tool and use the result to answer or summarize as needed.".to_string(),
strict: false,
parameters: JsonSchema::Object {
properties: BTreeMap::new(),
required: Some(vec![]),
additional_properties: Some(false),
},
})
}
/// set_config(new_config: string) — writes the provided TOML to config.toml.
#[derive(Debug, Deserialize)]
struct SetConfigArgs {
new_config: String,
}
pub(crate) fn create_set_config_tool() -> OpenAiTool {
let mut properties = BTreeMap::new();
properties.insert(
"new_config".to_string(),
JsonSchema::String {
description: Some("Full TOML contents to write to ~/.codex/config.toml".to_string()),
},
);
OpenAiTool::Function(ResponsesApiTool {
name: "set_config".to_string(),
description: "Overwrites ~/.codex/config.toml with the provided TOML string. If the user requests configuration changes, construct the full desired TOML and call this tool. The value is validated and a diff will be shown for user approval before writing.".to_string(),
strict: false,
parameters: JsonSchema::Object {
properties,
required: Some(vec!["new_config".to_string()]),
additional_properties: Some(false),
},
})
}
/// show_codex_docs() — returns Codex documentation.
pub(crate) fn create_show_codex_docs_tool() -> OpenAiTool {
OpenAiTool::Function(ResponsesApiTool {
name: "show_codex_docs".to_string(),
description: "Returns Codex documentation, including the repo README and all user docs under docs/. Use this when you need information about configuration, setup, features, or usage.".to_string(),
strict: false,
parameters: JsonSchema::Object {
properties: BTreeMap::new(),
required: Some(vec![]),
additional_properties: Some(false),
},
})
}
fn resolve_config_path() -> std::io::Result<PathBuf> {
let mut p = find_codex_home()?;
p.push(CONFIG_TOML_FILE);
Ok(p)
}
pub(crate) async fn handle_get_config(
_session: &Session,
_arguments: String,
_sub_id: String,
call_id: String,
) -> ResponseInputItem {
let content = match resolve_config_path().and_then(fs::read_to_string) {
Ok(s) => s,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
Err(e) => {
return ResponseInputItem::FunctionCallOutput {
call_id,
output: FunctionCallOutputPayload {
content: format!("failed to read config: {e}"),
success: Some(false),
},
};
}
};
ResponseInputItem::FunctionCallOutput {
call_id,
output: FunctionCallOutputPayload {
content,
success: Some(true),
},
}
}
pub(crate) async fn handle_set_config(
session: &Session,
arguments: String,
sub_id: String,
call_id: String,
) -> ResponseInputItem {
let args: SetConfigArgs = match serde_json::from_str(&arguments) {
Ok(a) => a,
Err(e) => {
return ResponseInputItem::FunctionCallOutput {
call_id,
output: FunctionCallOutputPayload {
content: format!("failed to parse function arguments: {e}"),
success: None,
},
};
}
};
// Validate TOML and ensure it can be materialized into a runtime Config.
let cfg_toml: crate::config::ConfigToml = match toml::from_str(&args.new_config) {
Ok(v) => v,
Err(e) => {
return ResponseInputItem::FunctionCallOutput {
call_id,
output: FunctionCallOutputPayload {
content: format!("invalid TOML: {e}"),
success: Some(false),
},
};
}
};
let codex_home = match find_codex_home() {
Ok(p) => p,
Err(e) => {
return ResponseInputItem::FunctionCallOutput {
call_id,
output: FunctionCallOutputPayload {
content: format!("failed to resolve codex_home: {e}"),
success: Some(false),
},
};
}
};
if let Err(e) = crate::config::Config::load_from_base_config_with_overrides(
cfg_toml.clone(),
crate::config::ConfigOverrides::default(),
codex_home.clone(),
) {
return ResponseInputItem::FunctionCallOutput {
call_id,
output: FunctionCallOutputPayload {
content: format!("invalid config: {e}"),
success: Some(false),
},
};
}
let path = match resolve_config_path() {
Ok(p) => p,
Err(e) => {
return ResponseInputItem::FunctionCallOutput {
call_id,
output: FunctionCallOutputPayload {
content: format!("failed to resolve config path: {e}"),
success: Some(false),
},
};
}
};
// Build a synthetic patch showing the proposed change and ask for patch approval.
let current = match std::fs::read_to_string(&path) {
Ok(s) => Some(s),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
Err(e) => {
return ResponseInputItem::FunctionCallOutput {
call_id,
output: FunctionCallOutputPayload {
content: format!("failed to read existing config: {e}"),
success: Some(false),
},
};
}
};
let make_lines = |s: &str| {
let mut v: Vec<&str> = s.split('\n').collect();
if v.last().is_some_and(|l| l.is_empty()) {
v.pop();
}
v.into_iter()
.map(|l| l.to_string())
.collect::<Vec<String>>()
};
let patch_body = if let Some(curr) = &current {
let mut body = format!("*** Update File: {}\n@@\n", path.display());
for line in make_lines(curr) {
body.push_str(&format!("-{line}\n"));
}
for line in make_lines(&args.new_config) {
body.push_str(&format!("+{line}\n"));
}
body
} else {
let mut body = format!("*** Add File: {}\n", path.display());
for line in make_lines(&args.new_config) {
body.push_str(&format!("+{line}\n"));
}
body
};
let patch_text = format!("*** Begin Patch\n{patch_body}*** End Patch");
let argv = vec!["apply_patch".to_string(), patch_text];
let cwd = path
.parent()
.map(Path::to_path_buf)
.unwrap_or_else(|| PathBuf::from("/"));
let action: ApplyPatchAction = match maybe_parse_apply_patch_verified(&argv, &cwd) {
MaybeApplyPatchVerified::Body(action) => action,
MaybeApplyPatchVerified::CorrectnessError(e) => {
return ResponseInputItem::FunctionCallOutput {
call_id,
output: FunctionCallOutputPayload {
content: format!("failed to compute patch diff: {e}"),
success: Some(false),
},
};
}
_ => {
return ResponseInputItem::FunctionCallOutput {
call_id,
output: FunctionCallOutputPayload {
content: "failed to construct patch diff".to_string(),
success: Some(false),
},
};
}
};
let rx = session
.request_patch_approval(
sub_id.clone(),
call_id.clone(),
&action,
Some("Update Codex configuration file".to_string()),
None,
)
.await;
match rx.await.unwrap_or_default() {
ReviewDecision::Approved | ReviewDecision::ApprovedForSession => { /* proceed */ }
ReviewDecision::Denied | ReviewDecision::Abort => {
return ResponseInputItem::FunctionCallOutput {
call_id,
output: FunctionCallOutputPayload {
content: "set_config rejected by user".to_string(),
success: None,
},
};
}
}
if let Some(parent) = path.parent()
&& let Err(e) = fs::create_dir_all(parent)
{
return ResponseInputItem::FunctionCallOutput {
call_id,
output: FunctionCallOutputPayload {
content: format!("failed to create config directory: {e}"),
success: Some(false),
},
};
}
match fs::write(&path, args.new_config.as_bytes()) {
Ok(_) => ResponseInputItem::FunctionCallOutput {
call_id,
output: FunctionCallOutputPayload {
content: format!("wrote {}", path.display()),
success: Some(true),
},
},
Err(e) => ResponseInputItem::FunctionCallOutput {
call_id,
output: FunctionCallOutputPayload {
content: format!("failed to write config: {e}"),
success: Some(false),
},
},
}
}
pub(crate) async fn handle_show_codex_docs(
_session: &Session,
_arguments: String,
_sub_id: String,
call_id: String,
) -> ResponseInputItem {
let content = build_all_codex_docs();
ResponseInputItem::FunctionCallOutput {
call_id,
output: FunctionCallOutputPayload {
content,
success: Some(true),
},
}
}

View File

@@ -44,6 +44,7 @@ mod conversation_manager;
mod event_mapping;
pub use conversation_manager::ConversationManager;
pub use conversation_manager::NewConversation;
mod config_edit_tool;
// Re-export common auth types for workspace consumers
pub use auth::AuthManager;
pub use auth::CodexAuth;

View File

@@ -5,6 +5,9 @@ use serde_json::json;
use std::collections::BTreeMap;
use std::collections::HashMap;
use crate::config_edit_tool::create_get_config_tool;
use crate::config_edit_tool::create_set_config_tool;
use crate::config_edit_tool::create_show_codex_docs_tool;
use crate::model_family::ModelFamily;
use crate::plan_tool::PLAN_TOOL;
use crate::protocol::AskForApproval;
@@ -572,6 +575,10 @@ pub(crate) fn get_openai_tools(
if config.web_search_request {
tools.push(OpenAiTool::WebSearch {});
}
// Always include internal config tools.
tools.push(create_get_config_tool());
tools.push(create_set_config_tool());
tools.push(create_show_codex_docs_tool());
// Include the view_image tool so the agent can attach images to context.
if config.include_view_image_tool {
@@ -647,7 +654,15 @@ mod tests {
assert_eq_tool_names(
&tools,
&["local_shell", "update_plan", "web_search", "view_image"],
&[
"local_shell",
"update_plan",
"web_search",
"get_config",
"set_config",
"show_codex_docs",
"view_image",
],
);
}
@@ -668,7 +683,15 @@ mod tests {
assert_eq_tool_names(
&tools,
&["shell", "update_plan", "web_search", "view_image"],
&[
"shell",
"update_plan",
"web_search",
"get_config",
"set_config",
"show_codex_docs",
"view_image",
],
);
}
@@ -728,13 +751,16 @@ mod tests {
&[
"shell",
"web_search",
"get_config",
"set_config",
"show_codex_docs",
"view_image",
"test_server/do_something_cool",
],
);
assert_eq!(
tools[3],
tools[6],
OpenAiTool::Function(ResponsesApiTool {
name: "test_server/do_something_cool".to_string(),
parameters: JsonSchema::Object {
@@ -841,11 +867,14 @@ mod tests {
]);
let tools = get_openai_tools(&config, Some(tools_map));
// Expect shell first, followed by MCP tools sorted by fully-qualified name.
// Expect shell first, followed by built-in config tools, then MCP tools sorted by fully-qualified name.
assert_eq_tool_names(
&tools,
&[
"shell",
"get_config",
"set_config",
"show_codex_docs",
"view_image",
"test_server/cool",
"test_server/do",
@@ -893,11 +922,19 @@ mod tests {
assert_eq_tool_names(
&tools,
&["shell", "web_search", "view_image", "dash/search"],
&[
"shell",
"web_search",
"get_config",
"set_config",
"show_codex_docs",
"view_image",
"dash/search",
],
);
assert_eq!(
tools[3],
tools[6],
OpenAiTool::Function(ResponsesApiTool {
name: "dash/search".to_string(),
parameters: JsonSchema::Object {
@@ -953,10 +990,18 @@ mod tests {
assert_eq_tool_names(
&tools,
&["shell", "web_search", "view_image", "dash/paginate"],
&[
"shell",
"web_search",
"get_config",
"set_config",
"show_codex_docs",
"view_image",
"dash/paginate",
],
);
assert_eq!(
tools[3],
tools[6],
OpenAiTool::Function(ResponsesApiTool {
name: "dash/paginate".to_string(),
parameters: JsonSchema::Object {
@@ -1008,9 +1053,21 @@ mod tests {
)])),
);
assert_eq_tool_names(&tools, &["shell", "web_search", "view_image", "dash/tags"]);
assert_eq_tool_names(
&tools,
&[
"shell",
"web_search",
"get_config",
"set_config",
"show_codex_docs",
"view_image",
"dash/tags",
],
);
assert_eq!(
tools[3],
tools[6],
OpenAiTool::Function(ResponsesApiTool {
name: "dash/tags".to_string(),
parameters: JsonSchema::Object {
@@ -1065,9 +1122,20 @@ mod tests {
)])),
);
assert_eq_tool_names(&tools, &["shell", "web_search", "view_image", "dash/value"]);
assert_eq_tool_names(
&tools,
&[
"shell",
"web_search",
"get_config",
"set_config",
"show_codex_docs",
"view_image",
"dash/value",
],
);
assert_eq!(
tools[3],
tools[6],
OpenAiTool::Function(ResponsesApiTool {
name: "dash/value".to_string(),
parameters: JsonSchema::Object {

View File

@@ -191,7 +191,15 @@ async fn prompt_tools_are_consistent_across_requests() {
let expected_instructions: &str = include_str!("../../prompt.md");
// our internal implementation is responsible for keeping tools in sync
// with the OpenAI schema, so we just verify the tool presence here
let expected_tools_names: &[&str] = &["shell", "update_plan", "apply_patch", "view_image"];
let expected_tools_names: &[&str] = &[
"shell",
"update_plan",
"apply_patch",
"get_config",
"set_config",
"show_codex_docs",
"view_image",
];
let body0 = requests[0].body_json::<serde_json::Value>().unwrap();
assert_eq!(
body0["instructions"],