feat: track plugins mcps/apps and add plugin info to user_instructions (#13433)

### first half of changes, followed by #13510

Track plugin capabilities as derived summaries on `PluginLoadOutcome`
for enabled plugins with at least one skill/app/mcp.

Also add `Plugins` section to `user_instructions` injected on session
start. These introduce the plugins concept and list enabled plugins, but
do NOT currently include paths to enabled plugins or details on what
apps/mcps the plugins contain (current plan is to inject this on
@-mention). that can be adjusted in a follow up and based on evals.

### tests
Added/updated tests, confirmed locally that new `Plugins` section +
currently enabled plugins show up in `user_instructions`.
This commit is contained in:
sayan-oai
2026-03-04 19:46:13 -08:00
committed by GitHub
parent be5e8fbd37
commit d44398905b
7 changed files with 270 additions and 31 deletions

View File

@@ -21,6 +21,8 @@ use crate::config_loader::default_project_root_markers;
use crate::config_loader::merge_toml_values;
use crate::config_loader::project_root_markers_from_config;
use crate::features::Feature;
use crate::plugins::PluginCapabilitySummary;
use crate::plugins::render_plugins_section;
use crate::skills::SkillMetadata;
use crate::skills::render_skills_section;
use codex_app_server_protocol::ConfigLayerSource;
@@ -81,6 +83,7 @@ fn render_js_repl_instructions(config: &Config) -> Option<String> {
pub(crate) async fn get_user_instructions(
config: &Config,
skills: Option<&[SkillMetadata]>,
plugins: Option<&[PluginCapabilitySummary]>,
) -> Option<String> {
let project_docs = read_project_docs(config).await;
@@ -110,6 +113,13 @@ pub(crate) async fn get_user_instructions(
output.push_str(&js_repl_section);
}
if let Some(plugin_section) = plugins.and_then(render_plugins_section) {
if !output.is_empty() {
output.push_str("\n\n");
}
output.push_str(&plugin_section);
}
let skills_section = skills.and_then(render_skills_section);
if let Some(skills_section) = skills_section {
if !output.is_empty() {
@@ -387,7 +397,7 @@ mod tests {
async fn no_doc_file_returns_none() {
let tmp = tempfile::tempdir().expect("tempdir");
let res = get_user_instructions(&make_config(&tmp, 4096, None).await, None).await;
let res = get_user_instructions(&make_config(&tmp, 4096, None).await, None, None).await;
assert!(
res.is_none(),
"Expected None when AGENTS.md is absent and no system instructions provided"
@@ -401,7 +411,7 @@ mod tests {
let tmp = tempfile::tempdir().expect("tempdir");
fs::write(tmp.path().join("AGENTS.md"), "hello world").unwrap();
let res = get_user_instructions(&make_config(&tmp, 4096, None).await, None)
let res = get_user_instructions(&make_config(&tmp, 4096, None).await, None, None)
.await
.expect("doc expected");
@@ -420,7 +430,7 @@ mod tests {
let huge = "A".repeat(LIMIT * 2); // 2 KiB
fs::write(tmp.path().join("AGENTS.md"), &huge).unwrap();
let res = get_user_instructions(&make_config(&tmp, LIMIT, None).await, None)
let res = get_user_instructions(&make_config(&tmp, LIMIT, None).await, None, None)
.await
.expect("doc expected");
@@ -452,7 +462,7 @@ mod tests {
let mut cfg = make_config(&repo, 4096, None).await;
cfg.cwd = nested;
let res = get_user_instructions(&cfg, None)
let res = get_user_instructions(&cfg, None, None)
.await
.expect("doc expected");
assert_eq!(res, "root level doc");
@@ -464,7 +474,7 @@ mod tests {
let tmp = tempfile::tempdir().expect("tempdir");
fs::write(tmp.path().join("AGENTS.md"), "something").unwrap();
let res = get_user_instructions(&make_config(&tmp, 0, None).await, None).await;
let res = get_user_instructions(&make_config(&tmp, 0, None).await, None, None).await;
assert!(
res.is_none(),
"With limit 0 the function should return None"
@@ -479,7 +489,7 @@ mod tests {
.enable(Feature::JsRepl)
.expect("test config should allow js_repl");
let res = get_user_instructions(&cfg, None)
let res = get_user_instructions(&cfg, None, None)
.await
.expect("js_repl instructions expected");
let expected = "## JavaScript REPL (Node)\n- Use `js_repl` for Node-backed JavaScript with top-level await in a persistent kernel.\n- `js_repl` is a freeform/custom tool. Direct `js_repl` calls must send raw JavaScript tool input (optionally with first-line `// codex-js-repl: timeout_ms=15000`). Do not wrap code in JSON (for example `{\"code\":\"...\"}`), quotes, or markdown code fences.\n- Helpers: `codex.tmpDir`, `codex.tool(name, args?)`, and `codex.emitImage(imageLike)`.\n- `codex.tool` executes a normal tool call and resolves to the raw tool output object. Use it for shell and non-shell tools alike. Nested tool outputs stay inside JavaScript unless you emit them explicitly.\n- `codex.emitImage(...)` adds exactly one image to the outer `js_repl` function output. It accepts a direct image URL, a single `input_image` item, an object like `{ bytes, mimeType }`, or a raw tool response object with exactly one image and no text. It rejects mixed text-and-image content.\n- Example of sharing an in-memory Playwright screenshot: `await codex.emitImage({ bytes: await page.screenshot({ type: \"jpeg\", quality: 85 }), mimeType: \"image/jpeg\" })`.\n- Example of sharing a local image tool result: `await codex.emitImage(codex.tool(\"view_image\", { path: \"/absolute/path\" }))`.\n- Top-level bindings persist across cells. If you hit `SyntaxError: Identifier 'x' has already been declared`, reuse the binding, pick a new name, wrap in `{ ... }` for block scope, or reset the kernel with `js_repl_reset`.\n- Top-level static import declarations (for example `import x from \"pkg\"`) are currently unsupported in `js_repl`; use dynamic imports with `await import(\"pkg\")` instead.\n- Avoid direct access to `process.stdout` / `process.stderr` / `process.stdin`; it can corrupt the JSON line protocol. Use `console.log`, `codex.tool(...)`, and `codex.emitImage(...)`.";
@@ -498,7 +508,7 @@ mod tests {
.set(features)
.expect("test config should allow js_repl tool restrictions");
let res = get_user_instructions(&cfg, None)
let res = get_user_instructions(&cfg, None, None)
.await
.expect("js_repl instructions expected");
let expected = "## JavaScript REPL (Node)\n- Use `js_repl` for Node-backed JavaScript with top-level await in a persistent kernel.\n- `js_repl` is a freeform/custom tool. Direct `js_repl` calls must send raw JavaScript tool input (optionally with first-line `// codex-js-repl: timeout_ms=15000`). Do not wrap code in JSON (for example `{\"code\":\"...\"}`), quotes, or markdown code fences.\n- Helpers: `codex.tmpDir`, `codex.tool(name, args?)`, and `codex.emitImage(imageLike)`.\n- `codex.tool` executes a normal tool call and resolves to the raw tool output object. Use it for shell and non-shell tools alike. Nested tool outputs stay inside JavaScript unless you emit them explicitly.\n- `codex.emitImage(...)` adds exactly one image to the outer `js_repl` function output. It accepts a direct image URL, a single `input_image` item, an object like `{ bytes, mimeType }`, or a raw tool response object with exactly one image and no text. It rejects mixed text-and-image content.\n- Example of sharing an in-memory Playwright screenshot: `await codex.emitImage({ bytes: await page.screenshot({ type: \"jpeg\", quality: 85 }), mimeType: \"image/jpeg\" })`.\n- Example of sharing a local image tool result: `await codex.emitImage(codex.tool(\"view_image\", { path: \"/absolute/path\" }))`.\n- Top-level bindings persist across cells. If you hit `SyntaxError: Identifier 'x' has already been declared`, reuse the binding, pick a new name, wrap in `{ ... }` for block scope, or reset the kernel with `js_repl_reset`.\n- Top-level static import declarations (for example `import x from \"pkg\"`) are currently unsupported in `js_repl`; use dynamic imports with `await import(\"pkg\")` instead.\n- Do not call tools directly; use `js_repl` + `codex.tool(...)` for all tool calls, including shell commands.\n- MCP tools (if any) can also be called by name via `codex.tool(...)`.\n- Avoid direct access to `process.stdout` / `process.stderr` / `process.stdin`; it can corrupt the JSON line protocol. Use `console.log`, `codex.tool(...)`, and `codex.emitImage(...)`.";
@@ -517,7 +527,7 @@ mod tests {
.set(features)
.expect("test config should allow js_repl image detail settings");
let res = get_user_instructions(&cfg, None)
let res = get_user_instructions(&cfg, None, None)
.await
.expect("js_repl instructions expected");
let expected = "## JavaScript REPL (Node)\n- Use `js_repl` for Node-backed JavaScript with top-level await in a persistent kernel.\n- `js_repl` is a freeform/custom tool. Direct `js_repl` calls must send raw JavaScript tool input (optionally with first-line `// codex-js-repl: timeout_ms=15000`). Do not wrap code in JSON (for example `{\"code\":\"...\"}`), quotes, or markdown code fences.\n- Helpers: `codex.tmpDir`, `codex.tool(name, args?)`, and `codex.emitImage(imageLike)`.\n- `codex.tool` executes a normal tool call and resolves to the raw tool output object. Use it for shell and non-shell tools alike. Nested tool outputs stay inside JavaScript unless you emit them explicitly.\n- `codex.emitImage(...)` adds exactly one image to the outer `js_repl` function output. It accepts a direct image URL, a single `input_image` item, an object like `{ bytes, mimeType }`, or a raw tool response object with exactly one image and no text. It rejects mixed text-and-image content.\n- Example of sharing an in-memory Playwright screenshot: `await codex.emitImage({ bytes: await page.screenshot({ type: \"jpeg\", quality: 85 }), mimeType: \"image/jpeg\" })`.\n- Example of sharing a local image tool result: `await codex.emitImage(codex.tool(\"view_image\", { path: \"/absolute/path\" }))`.\n- When generating or converting images for `view_image` in `js_repl`, prefer JPEG at 85% quality unless lossless quality is strictly required; other formats can be used if the user requests them. This keeps uploads smaller and reduces the chance of hitting image size caps.\n- Top-level bindings persist across cells. If you hit `SyntaxError: Identifier 'x' has already been declared`, reuse the binding, pick a new name, wrap in `{ ... }` for block scope, or reset the kernel with `js_repl_reset`.\n- Top-level static import declarations (for example `import x from \"pkg\"`) are currently unsupported in `js_repl`; use dynamic imports with `await import(\"pkg\")` instead.\n- Avoid direct access to `process.stdout` / `process.stderr` / `process.stdin`; it can corrupt the JSON line protocol. Use `console.log`, `codex.tool(...)`, and `codex.emitImage(...)`.";
@@ -533,9 +543,13 @@ mod tests {
const INSTRUCTIONS: &str = "base instructions";
let res = get_user_instructions(&make_config(&tmp, 4096, Some(INSTRUCTIONS)).await, None)
.await
.expect("should produce a combined instruction string");
let res = get_user_instructions(
&make_config(&tmp, 4096, Some(INSTRUCTIONS)).await,
None,
None,
)
.await
.expect("should produce a combined instruction string");
let expected = format!("{INSTRUCTIONS}{PROJECT_DOC_SEPARATOR}{}", "proj doc");
@@ -550,8 +564,12 @@ mod tests {
const INSTRUCTIONS: &str = "some instructions";
let res =
get_user_instructions(&make_config(&tmp, 4096, Some(INSTRUCTIONS)).await, None).await;
let res = get_user_instructions(
&make_config(&tmp, 4096, Some(INSTRUCTIONS)).await,
None,
None,
)
.await;
assert_eq!(res, Some(INSTRUCTIONS.to_string()));
}
@@ -580,7 +598,7 @@ mod tests {
let mut cfg = make_config(&repo, 4096, None).await;
cfg.cwd = nested;
let res = get_user_instructions(&cfg, None)
let res = get_user_instructions(&cfg, None, None)
.await
.expect("doc expected");
assert_eq!(res, "root doc\n\ncrate doc");
@@ -609,7 +627,7 @@ mod tests {
assert_eq!(discovery[0], expected_parent);
assert_eq!(discovery[1], expected_child);
let res = get_user_instructions(&cfg, None)
let res = get_user_instructions(&cfg, None, None)
.await
.expect("doc expected");
assert_eq!(res, "parent doc\n\nchild doc");
@@ -624,7 +642,7 @@ mod tests {
let cfg = make_config(&tmp, 4096, None).await;
let res = get_user_instructions(&cfg, None)
let res = get_user_instructions(&cfg, None, None)
.await
.expect("local doc expected");
@@ -646,7 +664,7 @@ mod tests {
let cfg = make_config_with_fallback(&tmp, 4096, None, &["EXAMPLE.md"]).await;
let res = get_user_instructions(&cfg, None)
let res = get_user_instructions(&cfg, None, None)
.await
.expect("fallback doc expected");
@@ -662,7 +680,7 @@ mod tests {
let cfg = make_config_with_fallback(&tmp, 4096, None, &["EXAMPLE.md", ".example.md"]).await;
let res = get_user_instructions(&cfg, None)
let res = get_user_instructions(&cfg, None, None)
.await
.expect("AGENTS.md should win");
@@ -695,6 +713,7 @@ mod tests {
let res = get_user_instructions(
&cfg,
skills.errors.is_empty().then_some(skills.skills.as_slice()),
None,
)
.await
.expect("instructions expected");
@@ -722,6 +741,7 @@ mod tests {
let res = get_user_instructions(
&cfg,
skills.errors.is_empty().then_some(skills.skills.as_slice()),
None,
)
.await
.expect("instructions expected");
@@ -744,7 +764,7 @@ mod tests {
.enable(Feature::Apps)
.expect("test config should allow apps");
let res = get_user_instructions(&cfg, None).await;
let res = get_user_instructions(&cfg, None, None).await;
assert_eq!(res, None);
}
@@ -758,7 +778,7 @@ mod tests {
.enable(Feature::Apps)
.expect("test config should allow apps");
let res = get_user_instructions(&cfg, None)
let res = get_user_instructions(&cfg, None, None)
.await
.expect("instructions expected");
assert_eq!(res, "base doc");