Compare commits

...

4 Commits

Author SHA1 Message Date
Channing Conger
70eddad6b0 dynamic tool calls: add param exposeToContext to optionally hide tool (#14501)
This extends dynamic_tool_calls to allow us to hide a tool from the
model context but still use it as part of the general tool calling
runtime (for ex from js_repl/code_mode)
2026-03-14 01:58:43 -07:00
sayan-oai
e389091042 make defaultPrompt an array, keep backcompat (#14649)
make plugins' `defaultPrompt` an array, but keep backcompat for strings.

the array is limited by app-server to 3 entries of up to 128 chars
(drops extra entries, `None`s-out ones that are too long) without
erroring if those invariants are violating.

added tests, tested locally.
2026-03-14 06:13:51 +00:00
sayan-oai
8ca358a13c Refresh Python SDK generated types (#14646)
## Summary
- regenerate `sdk/python` protocol-derived artifacts on latest
`origin/main`
- update `notification_registry.py` to match the regenerated
notification set
- fix the stale SDK test expectation for `GranularAskForApproval`

## Validation
- `cd sdk/python && python scripts/update_sdk_artifacts.py
generate-types`
- `cd sdk/python && python -m pytest`
2026-03-14 05:50:33 +00:00
Eric Traut
ae0a6510e1 Enforce errors on overriding built-in model providers (#12024)
We receive bug reports from users who attempt to override one of the
three built-in model providers (openai, ollama, or lmstuio). Currently,
these overrides are silently ignored. This PR makes it an error to
override them.

## Summary
- add validation for `model_providers` so `openai`, `ollama`, and
`lmstudio` keys now produce clear configuration errors instead of being
silently ignored
2026-03-13 22:10:13 -06:00
28 changed files with 1525 additions and 2484 deletions

View File

@@ -506,6 +506,9 @@
},
"DynamicToolSpec": {
"properties": {
"deferLoading": {
"type": "boolean"
},
"description": {
"type": "string"
},

View File

@@ -7050,6 +7050,9 @@
},
"DynamicToolSpec": {
"properties": {
"deferLoading": {
"type": "boolean"
},
"description": {
"type": "string"
},
@@ -9292,8 +9295,12 @@
]
},
"defaultPrompt": {
"description": "Starter prompts for the plugin. Capped at 3 entries with a maximum of 128 characters per entry.",
"items": {
"type": "string"
},
"type": [
"string",
"array",
"null"
]
},

View File

@@ -3651,6 +3651,9 @@
},
"DynamicToolSpec": {
"properties": {
"deferLoading": {
"type": "boolean"
},
"description": {
"type": "string"
},
@@ -6037,8 +6040,12 @@
]
},
"defaultPrompt": {
"description": "Starter prompts for the plugin. Capped at 3 entries with a maximum of 128 characters per entry.",
"items": {
"type": "string"
},
"type": [
"string",
"array",
"null"
]
},

View File

@@ -51,8 +51,12 @@
]
},
"defaultPrompt": {
"description": "Starter prompts for the plugin. Capped at 3 entries with a maximum of 128 characters per entry.",
"items": {
"type": "string"
},
"type": [
"string",
"array",
"null"
]
},

View File

@@ -125,8 +125,12 @@
]
},
"defaultPrompt": {
"description": "Starter prompts for the plugin. Capped at 3 entries with a maximum of 128 characters per entry.",
"items": {
"type": "string"
},
"type": [
"string",
"array",
"null"
]
},

View File

@@ -61,6 +61,9 @@
},
"DynamicToolSpec": {
"properties": {
"deferLoading": {
"type": "boolean"
},
"description": {
"type": "string"
},

View File

@@ -3,4 +3,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { JsonValue } from "../serde_json/JsonValue";
export type DynamicToolSpec = { name: string, description: string, inputSchema: JsonValue, };
export type DynamicToolSpec = { name: string, description: string, inputSchema: JsonValue, deferLoading?: boolean, };

View File

@@ -3,4 +3,9 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { AbsolutePathBuf } from "../AbsolutePathBuf";
export type PluginInterface = { displayName: string | null, shortDescription: string | null, longDescription: string | null, developerName: string | null, category: string | null, capabilities: Array<string>, websiteUrl: string | null, privacyPolicyUrl: string | null, termsOfServiceUrl: string | null, defaultPrompt: string | null, brandColor: string | null, composerIcon: AbsolutePathBuf | null, logo: AbsolutePathBuf | null, screenshots: Array<AbsolutePathBuf>, };
export type PluginInterface = { displayName: string | null, shortDescription: string | null, longDescription: string | null, developerName: string | null, category: string | null, capabilities: Array<string>, websiteUrl: string | null, privacyPolicyUrl: string | null, termsOfServiceUrl: string | null,
/**
* Starter prompts for the plugin. Capped at 3 entries with a maximum of
* 128 characters per entry.
*/
defaultPrompt: Array<string> | null, brandColor: string | null, composerIcon: AbsolutePathBuf | null, logo: AbsolutePathBuf | null, screenshots: Array<AbsolutePathBuf>, };

View File

@@ -535,13 +535,48 @@ pub struct ToolsV2 {
pub view_image: Option<bool>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[derive(Serialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct DynamicToolSpec {
pub name: String,
pub description: String,
pub input_schema: JsonValue,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub defer_loading: bool,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct DynamicToolSpecDe {
name: String,
description: String,
input_schema: JsonValue,
defer_loading: Option<bool>,
expose_to_context: Option<bool>,
}
impl<'de> Deserialize<'de> for DynamicToolSpec {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let DynamicToolSpecDe {
name,
description,
input_schema,
defer_loading,
expose_to_context,
} = DynamicToolSpecDe::deserialize(deserializer)?;
Ok(Self {
name,
description,
input_schema,
defer_loading: defer_loading
.unwrap_or_else(|| expose_to_context.map(|visible| !visible).unwrap_or(false)),
})
}
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, ExperimentalApi)]
@@ -3355,7 +3390,9 @@ pub struct PluginInterface {
pub website_url: Option<String>,
pub privacy_policy_url: Option<String>,
pub terms_of_service_url: Option<String>,
pub default_prompt: Option<String>,
/// Starter prompts for the plugin. Capped at 3 entries with a maximum of
/// 128 characters per entry.
pub default_prompt: Option<Vec<String>>,
pub brand_color: Option<String>,
pub composer_icon: Option<AbsolutePathBuf>,
pub logo: Option<AbsolutePathBuf>,
@@ -7653,6 +7690,55 @@ mod tests {
);
}
#[test]
fn dynamic_tool_spec_deserializes_defer_loading() {
let value = json!({
"name": "lookup_ticket",
"description": "Fetch a ticket",
"inputSchema": {
"type": "object",
"properties": {
"id": { "type": "string" }
}
},
"deferLoading": true,
});
let actual: DynamicToolSpec = serde_json::from_value(value).expect("deserialize");
assert_eq!(
actual,
DynamicToolSpec {
name: "lookup_ticket".to_string(),
description: "Fetch a ticket".to_string(),
input_schema: json!({
"type": "object",
"properties": {
"id": { "type": "string" }
}
}),
defer_loading: true,
}
);
}
#[test]
fn dynamic_tool_spec_legacy_expose_to_context_inverts_to_defer_loading() {
let value = json!({
"name": "lookup_ticket",
"description": "Fetch a ticket",
"inputSchema": {
"type": "object",
"properties": {}
},
"exposeToContext": false,
});
let actual: DynamicToolSpec = serde_json::from_value(value).expect("deserialize");
assert!(actual.defer_loading);
}
#[test]
fn thread_start_params_preserve_explicit_null_service_tier() {
let params: ThreadStartParams = serde_json::from_value(json!({ "serviceTier": null }))

View File

@@ -205,6 +205,7 @@ Start a fresh thread when you need a new Codex conversation.
{
"name": "lookup_ticket",
"description": "Fetch a ticket by id",
"deferLoading": true,
"inputSchema": {
"type": "object",
"properties": {
@@ -991,6 +992,8 @@ If the session approval policy uses `Granular` with `request_permissions: false`
`dynamicTools` on `thread/start` and the corresponding `item/tool/call` request/response flow are experimental APIs. To enable them, set `initialize.params.capabilities.experimentalApi = true`.
Each dynamic tool may set `deferLoading`. When omitted, it defaults to `false`. Set it to `true` to keep the tool registered and callable by runtime features such as `js_repl`, while excluding it from the model-facing tool list sent on ordinary turns.
When a dynamic tool is invoked during a turn, the server sends an `item/tool/call` JSON-RPC request to the client:
```json

View File

@@ -2007,6 +2007,7 @@ impl CodexMessageProcessor {
name: tool.name,
description: tool.description,
input_schema: tool.input_schema,
defer_loading: tool.defer_loading,
})
.collect()
};
@@ -8185,6 +8186,7 @@ mod tests {
name: "my_tool".to_string(),
description: "test".to_string(),
input_schema: json!({"type": "null"}),
defer_loading: false,
}];
let err = validate_dynamic_tools(&tools).expect_err("invalid schema");
assert!(err.contains("my_tool"), "unexpected error: {err}");
@@ -8197,6 +8199,7 @@ mod tests {
description: "test".to_string(),
// Missing `type` is common; core sanitizes these to a supported schema.
input_schema: json!({"properties": {}}),
defer_loading: false,
}];
validate_dynamic_tools(&tools).expect("valid schema");
}

View File

@@ -61,6 +61,7 @@ async fn thread_start_injects_dynamic_tools_into_model_requests() -> Result<()>
name: "demo_tool".to_string(),
description: "Demo dynamic tool".to_string(),
input_schema: input_schema.clone(),
defer_loading: false,
};
// Thread start injects dynamic tools into the thread's tool registry.
@@ -118,6 +119,78 @@ async fn thread_start_injects_dynamic_tools_into_model_requests() -> Result<()>
Ok(())
}
#[tokio::test]
async fn thread_start_keeps_hidden_dynamic_tools_out_of_model_requests() -> Result<()> {
let responses = vec![create_final_assistant_message_sse_response("Done")?];
let server = create_mock_responses_server_sequence_unchecked(responses).await;
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path(), &server.uri())?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let dynamic_tool = DynamicToolSpec {
name: "hidden_tool".to_string(),
description: "Hidden dynamic tool".to_string(),
input_schema: json!({
"type": "object",
"properties": {
"city": { "type": "string" }
},
"required": ["city"],
"additionalProperties": false,
}),
defer_loading: true,
};
let thread_req = mcp
.send_thread_start_request(ThreadStartParams {
dynamic_tools: Some(vec![dynamic_tool.clone()]),
..Default::default()
})
.await?;
let thread_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(thread_req)),
)
.await??;
let ThreadStartResponse { thread, .. } = to_response::<ThreadStartResponse>(thread_resp)?;
let turn_req = mcp
.send_turn_start_request(TurnStartParams {
thread_id: thread.id,
input: vec![V2UserInput::Text {
text: "Hello".to_string(),
text_elements: Vec::new(),
}],
..Default::default()
})
.await?;
let turn_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(turn_req)),
)
.await??;
let _turn: TurnStartResponse = to_response::<TurnStartResponse>(turn_resp)?;
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("turn/completed"),
)
.await??;
let bodies = responses_bodies(&server).await?;
assert!(
bodies
.iter()
.all(|body| find_tool(body, &dynamic_tool.name).is_none()),
"hidden dynamic tool should not be sent to the model"
);
Ok(())
}
/// Exercises the full dynamic tool call path (server request, client response, model output).
#[tokio::test]
async fn dynamic_tool_call_round_trip_sends_text_content_items_to_model() -> Result<()> {
@@ -154,6 +227,7 @@ async fn dynamic_tool_call_round_trip_sends_text_content_items_to_model() -> Res
"required": ["city"],
"additionalProperties": false,
}),
defer_loading: false,
};
let thread_req = mcp
@@ -322,6 +396,7 @@ async fn dynamic_tool_call_round_trip_sends_content_items_to_model() -> Result<(
"required": ["city"],
"additionalProperties": false,
}),
defer_loading: false,
};
let thread_req = mcp

View File

@@ -407,7 +407,10 @@ async fn plugin_list_returns_plugin_interface_with_absolute_asset_paths() -> Res
"websiteURL": "https://openai.com/",
"privacyPolicyURL": "https://openai.com/policies/row-privacy-policy/",
"termsOfServiceURL": "https://openai.com/policies/row-terms-of-use/",
"defaultPrompt": "Starter prompt for trying a plugin",
"defaultPrompt": [
"Starter prompt for trying a plugin",
"Find my next action"
],
"brandColor": "#3B82F6",
"composerIcon": "./assets/icon.png",
"logo": "./assets/logo.png",
@@ -466,6 +469,13 @@ async fn plugin_list_returns_plugin_interface_with_absolute_asset_paths() -> Res
interface.terms_of_service_url.as_deref(),
Some("https://openai.com/policies/row-terms-of-use/")
);
assert_eq!(
interface.default_prompt,
Some(vec![
"Starter prompt for trying a plugin".to_string(),
"Find my next action".to_string()
])
);
assert_eq!(
interface.composer_icon,
Some(AbsolutePathBuf::try_from(
@@ -488,6 +498,72 @@ async fn plugin_list_returns_plugin_interface_with_absolute_asset_paths() -> Res
Ok(())
}
#[tokio::test]
async fn plugin_list_accepts_legacy_string_default_prompt() -> Result<()> {
let codex_home = TempDir::new()?;
let repo_root = TempDir::new()?;
let plugin_root = repo_root.path().join("plugins/demo-plugin");
std::fs::create_dir_all(repo_root.path().join(".git"))?;
std::fs::create_dir_all(repo_root.path().join(".agents/plugins"))?;
std::fs::create_dir_all(plugin_root.join(".codex-plugin"))?;
std::fs::write(
repo_root.path().join(".agents/plugins/marketplace.json"),
r#"{
"name": "codex-curated",
"plugins": [
{
"name": "demo-plugin",
"source": {
"source": "local",
"path": "./plugins/demo-plugin"
}
}
]
}"#,
)?;
std::fs::write(
plugin_root.join(".codex-plugin/plugin.json"),
r##"{
"name": "demo-plugin",
"interface": {
"defaultPrompt": "Starter prompt for trying a plugin"
}
}"##,
)?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
.send_plugin_list_request(PluginListParams {
cwds: Some(vec![AbsolutePathBuf::try_from(repo_root.path())?]),
force_remote_sync: false,
})
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let response: PluginListResponse = to_response(response)?;
let plugin = response
.marketplaces
.iter()
.flat_map(|marketplace| marketplace.plugins.iter())
.find(|plugin| plugin.name == "demo-plugin")
.expect("expected demo-plugin entry");
assert_eq!(
plugin
.interface
.as_ref()
.and_then(|interface| interface.default_prompt.clone()),
Some(vec!["Starter prompt for trying a plugin".to_string()])
);
Ok(())
}
#[tokio::test]
async fn plugin_list_force_remote_sync_returns_remote_sync_error_on_fail_open() -> Result<()> {
let codex_home = TempDir::new()?;

View File

@@ -58,7 +58,10 @@ async fn plugin_read_returns_plugin_details_with_bundle_contents() -> Result<()>
"websiteURL": "https://openai.com/",
"privacyPolicyURL": "https://openai.com/policies/row-privacy-policy/",
"termsOfServiceURL": "https://openai.com/policies/row-terms-of-use/",
"defaultPrompt": "Starter prompt for trying a plugin",
"defaultPrompt": [
"Draft the reply",
"Find my next action"
],
"brandColor": "#3B82F6",
"composerIcon": "./assets/icon.png",
"logo": "./assets/logo.png",
@@ -162,6 +165,18 @@ enabled = true
.and_then(|interface| interface.category.as_deref()),
Some("Design")
);
assert_eq!(
response
.plugin
.summary
.interface
.as_ref()
.and_then(|interface| interface.default_prompt.clone()),
Some(vec![
"Draft the reply".to_string(),
"Find my next action".to_string()
])
);
assert_eq!(response.plugin.skills.len(), 1);
assert_eq!(
response.plugin.skills[0].name,
@@ -183,6 +198,70 @@ enabled = true
Ok(())
}
#[tokio::test]
async fn plugin_read_accepts_legacy_string_default_prompt() -> Result<()> {
let codex_home = TempDir::new()?;
let repo_root = TempDir::new()?;
let plugin_root = repo_root.path().join("plugins/demo-plugin");
std::fs::create_dir_all(repo_root.path().join(".git"))?;
std::fs::create_dir_all(repo_root.path().join(".agents/plugins"))?;
std::fs::create_dir_all(plugin_root.join(".codex-plugin"))?;
std::fs::write(
repo_root.path().join(".agents/plugins/marketplace.json"),
r#"{
"name": "codex-curated",
"plugins": [
{
"name": "demo-plugin",
"source": {
"source": "local",
"path": "./plugins/demo-plugin"
}
}
]
}"#,
)?;
std::fs::write(
plugin_root.join(".codex-plugin/plugin.json"),
r##"{
"name": "demo-plugin",
"interface": {
"defaultPrompt": "Starter prompt for trying a plugin"
}
}"##,
)?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
.send_plugin_read_request(PluginReadParams {
marketplace_path: AbsolutePathBuf::try_from(
repo_root.path().join(".agents/plugins/marketplace.json"),
)?,
plugin_name: "demo-plugin".to_string(),
})
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let response: PluginReadResponse = to_response(response)?;
assert_eq!(
response
.plugin
.summary
.interface
.as_ref()
.and_then(|interface| interface.default_prompt.clone()),
Some(vec!["Starter prompt for trying a plugin".to_string()])
);
Ok(())
}
#[tokio::test]
async fn plugin_read_returns_invalid_request_when_plugin_is_missing() -> Result<()> {
let codex_home = TempDir::new()?;

View File

@@ -2223,7 +2223,7 @@
"$ref": "#/definitions/ModelProviderInfo"
},
"default": {},
"description": "User-defined provider entries that extend/override the built-in list.",
"description": "User-defined provider entries that extend the built-in list. Built-in IDs cannot be overridden.",
"type": "object"
},
"model_reasoning_effort": {

View File

@@ -6219,9 +6219,25 @@ fn build_prompt(
turn_context: &TurnContext,
base_instructions: BaseInstructions,
) -> Prompt {
let deferred_dynamic_tools = turn_context
.dynamic_tools
.iter()
.filter(|tool| tool.defer_loading)
.map(|tool| tool.name.as_str())
.collect::<HashSet<_>>();
let tools = if deferred_dynamic_tools.is_empty() {
router.model_visible_specs()
} else {
router
.model_visible_specs()
.into_iter()
.filter(|spec| !deferred_dynamic_tools.contains(spec.name()))
.collect()
};
Prompt {
input,
tools: router.model_visible_specs(),
tools,
parallel_tool_calls: turn_context.model_info.supports_parallel_tool_calls,
base_instructions,
personality: turn_context.personality,

View File

@@ -47,6 +47,7 @@ use crate::model_provider_info::LMSTUDIO_OSS_PROVIDER_ID;
use crate::model_provider_info::ModelProviderInfo;
use crate::model_provider_info::OLLAMA_CHAT_PROVIDER_REMOVED_ERROR;
use crate::model_provider_info::OLLAMA_OSS_PROVIDER_ID;
use crate::model_provider_info::OPENAI_PROVIDER_ID;
use crate::model_provider_info::built_in_model_providers;
use crate::path_utils::normalize_for_native_workdir;
use crate::project_doc::DEFAULT_PROJECT_DOC_FILENAME;
@@ -139,6 +140,11 @@ pub(crate) const DEFAULT_AGENT_JOB_MAX_RUNTIME_SECONDS: Option<u64> = None;
pub const CONFIG_TOML_FILE: &str = "config.toml";
const OPENAI_BASE_URL_ENV_VAR: &str = "OPENAI_BASE_URL";
const RESERVED_MODEL_PROVIDER_IDS: [&str; 3] = [
OPENAI_PROVIDER_ID,
OLLAMA_OSS_PROVIDER_ID,
LMSTUDIO_OSS_PROVIDER_ID,
];
fn resolve_sqlite_home_env(resolved_cwd: &Path) -> Option<PathBuf> {
let raw = std::env::var(codex_state::SQLITE_HOME_ENV).ok()?;
@@ -367,7 +373,7 @@ pub struct Config {
/// to 127.0.0.1 (using `mcp_oauth_callback_port` when provided).
pub mcp_oauth_callback_url: Option<String>,
/// Combined provider map (defaults merged with user-defined overrides).
/// Combined provider map (defaults plus user-defined providers).
pub model_providers: HashMap<String, ModelProviderInfo>,
/// Maximum number of bytes to include from an AGENTS.md project doc file.
@@ -1262,8 +1268,9 @@ pub struct ConfigToml {
/// to 127.0.0.1 (using `mcp_oauth_callback_port` when provided).
pub mcp_oauth_callback_url: Option<String>,
/// User-defined provider entries that extend/override the built-in list.
#[serde(default)]
/// User-defined provider entries that extend the built-in list. Built-in
/// IDs cannot be overridden.
#[serde(default, deserialize_with = "deserialize_model_providers")]
pub model_providers: HashMap<String, ModelProviderInfo>,
/// Maximum number of bytes to include from an AGENTS.md project doc file.
@@ -1890,6 +1897,37 @@ pub struct ConfigOverrides {
pub additional_writable_roots: Vec<PathBuf>,
}
fn validate_reserved_model_provider_ids(
model_providers: &HashMap<String, ModelProviderInfo>,
) -> Result<(), String> {
let mut conflicts = model_providers
.keys()
.filter(|key| RESERVED_MODEL_PROVIDER_IDS.contains(&key.as_str()))
.map(|key| format!("`{key}`"))
.collect::<Vec<_>>();
conflicts.sort_unstable();
if conflicts.is_empty() {
Ok(())
} else {
Err(format!(
"model_providers contains reserved built-in provider IDs: {}. \
Built-in providers cannot be overridden. Rename your custom provider (for example, `openai-custom`).",
conflicts.join(", ")
))
}
}
fn deserialize_model_providers<'de, D>(
deserializer: D,
) -> Result<HashMap<String, ModelProviderInfo>, D::Error>
where
D: serde::Deserializer<'de>,
{
let model_providers = HashMap::<String, ModelProviderInfo>::deserialize(deserializer)?;
validate_reserved_model_provider_ids(&model_providers).map_err(serde::de::Error::custom)?;
Ok(model_providers)
}
/// Resolves the OSS provider from CLI override, profile config, or global config.
/// Returns `None` if no provider is configured at any level.
pub fn resolve_oss_provider(
@@ -2011,6 +2049,8 @@ impl Config {
codex_home: PathBuf,
config_layer_stack: ConfigLayerStack,
) -> std::io::Result<Self> {
validate_reserved_model_provider_ids(&cfg.model_providers)
.map_err(|message| std::io::Error::new(std::io::ErrorKind::InvalidInput, message))?;
// Ensure that every field of ConfigRequirements is applied to the final
// Config.
let ConfigRequirements {

View File

@@ -386,6 +386,34 @@ async fn invalid_user_value_rejected_even_if_overridden_by_managed() {
assert_eq!(contents.trim(), "model = \"user\"");
}
#[tokio::test]
async fn reserved_builtin_provider_override_rejected() {
let tmp = tempdir().expect("tempdir");
std::fs::write(tmp.path().join(CONFIG_TOML_FILE), "model = \"user\"\n").unwrap();
let service = ConfigService::new_with_defaults(tmp.path().to_path_buf());
let error = service
.write_value(ConfigValueWriteParams {
file_path: Some(tmp.path().join(CONFIG_TOML_FILE).display().to_string()),
key_path: "model_providers.openai.name".to_string(),
value: serde_json::json!("OpenAI Override"),
merge_strategy: MergeStrategy::Replace,
expected_version: None,
})
.await
.expect_err("should reject reserved provider override");
assert_eq!(
error.write_error_code(),
Some(ConfigWriteErrorCode::ConfigValidationError)
);
assert!(error.to_string().contains("reserved built-in provider IDs"));
assert!(error.to_string().contains("`openai`"));
let contents = std::fs::read_to_string(tmp.path().join(CONFIG_TOML_FILE)).expect("read config");
assert_eq!(contents, "model = \"user\"\n");
}
#[tokio::test]
async fn write_value_rejects_feature_requirement_conflict() {
let tmp = tempdir().expect("tempdir");

View File

@@ -1,10 +1,13 @@
use codex_utils_absolute_path::AbsolutePathBuf;
use serde::Deserialize;
use serde_json::Value as JsonValue;
use std::fs;
use std::path::Component;
use std::path::Path;
pub(crate) const PLUGIN_MANIFEST_PATH: &str = ".codex-plugin/plugin.json";
const MAX_DEFAULT_PROMPT_COUNT: usize = 3;
const MAX_DEFAULT_PROMPT_LEN: usize = 128;
#[derive(Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -43,7 +46,7 @@ pub struct PluginManifestInterfaceSummary {
pub website_url: Option<String>,
pub privacy_policy_url: Option<String>,
pub terms_of_service_url: Option<String>,
pub default_prompt: Option<String>,
pub default_prompt: Option<Vec<String>>,
pub brand_color: Option<String>,
pub composer_icon: Option<AbsolutePathBuf>,
pub logo: Option<AbsolutePathBuf>,
@@ -75,7 +78,7 @@ struct PluginManifestInterface {
#[serde(alias = "termsOfServiceURL")]
terms_of_service_url: Option<String>,
#[serde(default)]
default_prompt: Option<String>,
default_prompt: Option<PluginManifestDefaultPrompt>,
#[serde(default)]
brand_color: Option<String>,
#[serde(default)]
@@ -86,6 +89,21 @@ struct PluginManifestInterface {
screenshots: Vec<String>,
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum PluginManifestDefaultPrompt {
String(String),
List(Vec<PluginManifestDefaultPromptEntry>),
Invalid(JsonValue),
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum PluginManifestDefaultPromptEntry {
String(String),
Invalid(JsonValue),
}
pub(crate) fn load_plugin_manifest(plugin_root: &Path) -> Option<PluginManifest> {
let manifest_path = plugin_root.join(PLUGIN_MANIFEST_PATH);
if !manifest_path.is_file() {
@@ -128,7 +146,7 @@ pub(crate) fn plugin_manifest_interface(
website_url: interface.website_url.clone(),
privacy_policy_url: interface.privacy_policy_url.clone(),
terms_of_service_url: interface.terms_of_service_url.clone(),
default_prompt: interface.default_prompt.clone(),
default_prompt: resolve_default_prompts(plugin_root, interface.default_prompt.as_ref()),
brand_color: interface.brand_color.clone(),
composer_icon: resolve_interface_asset_path(
plugin_root,
@@ -190,6 +208,99 @@ fn resolve_interface_asset_path(
resolve_manifest_path(plugin_root, field, path)
}
fn resolve_default_prompts(
plugin_root: &Path,
value: Option<&PluginManifestDefaultPrompt>,
) -> Option<Vec<String>> {
match value? {
PluginManifestDefaultPrompt::String(prompt) => {
resolve_default_prompt_str(plugin_root, "interface.defaultPrompt", prompt)
.map(|prompt| vec![prompt])
}
PluginManifestDefaultPrompt::List(values) => {
let mut prompts = Vec::new();
for (index, item) in values.iter().enumerate() {
if prompts.len() >= MAX_DEFAULT_PROMPT_COUNT {
warn_invalid_default_prompt(
plugin_root,
"interface.defaultPrompt",
&format!("maximum of {MAX_DEFAULT_PROMPT_COUNT} prompts is supported"),
);
break;
}
match item {
PluginManifestDefaultPromptEntry::String(prompt) => {
let field = format!("interface.defaultPrompt[{index}]");
if let Some(prompt) =
resolve_default_prompt_str(plugin_root, &field, prompt)
{
prompts.push(prompt);
}
}
PluginManifestDefaultPromptEntry::Invalid(value) => {
let field = format!("interface.defaultPrompt[{index}]");
warn_invalid_default_prompt(
plugin_root,
&field,
&format!("expected a string, found {}", json_value_type(value)),
);
}
}
}
(!prompts.is_empty()).then_some(prompts)
}
PluginManifestDefaultPrompt::Invalid(value) => {
warn_invalid_default_prompt(
plugin_root,
"interface.defaultPrompt",
&format!(
"expected a string or array of strings, found {}",
json_value_type(value)
),
);
None
}
}
}
fn resolve_default_prompt_str(plugin_root: &Path, field: &str, prompt: &str) -> Option<String> {
let prompt = prompt.split_whitespace().collect::<Vec<_>>().join(" ");
if prompt.is_empty() {
warn_invalid_default_prompt(plugin_root, field, "prompt must not be empty");
return None;
}
if prompt.chars().count() > MAX_DEFAULT_PROMPT_LEN {
warn_invalid_default_prompt(
plugin_root,
field,
&format!("prompt must be at most {MAX_DEFAULT_PROMPT_LEN} characters"),
);
return None;
}
Some(prompt)
}
fn warn_invalid_default_prompt(plugin_root: &Path, field: &str, message: &str) {
let manifest_path = plugin_root.join(PLUGIN_MANIFEST_PATH);
tracing::warn!(
path = %manifest_path.display(),
"ignoring {field}: {message}"
);
}
fn json_value_type(value: &JsonValue) -> &'static str {
match value {
JsonValue::Null => "null",
JsonValue::Bool(_) => "boolean",
JsonValue::Number(_) => "number",
JsonValue::String(_) => "string",
JsonValue::Array(_) => "array",
JsonValue::Object(_) => "object",
}
}
fn resolve_manifest_path(
plugin_root: &Path,
field: &'static str,
@@ -232,3 +343,112 @@ fn resolve_manifest_path(
})
.ok()
}
#[cfg(test)]
mod tests {
use super::MAX_DEFAULT_PROMPT_LEN;
use super::PluginManifest;
use super::plugin_manifest_interface;
use pretty_assertions::assert_eq;
use std::fs;
use std::path::Path;
use tempfile::tempdir;
fn write_manifest(plugin_root: &Path, interface: &str) {
fs::create_dir_all(plugin_root.join(".codex-plugin")).expect("create manifest dir");
fs::write(
plugin_root.join(".codex-plugin/plugin.json"),
format!(
r#"{{
"name": "demo-plugin",
"interface": {interface}
}}"#
),
)
.expect("write manifest");
}
fn load_manifest(plugin_root: &Path) -> PluginManifest {
let manifest_path = plugin_root.join(".codex-plugin/plugin.json");
let contents = fs::read_to_string(manifest_path).expect("read manifest");
serde_json::from_str(&contents).expect("parse manifest")
}
#[test]
fn plugin_manifest_interface_accepts_legacy_default_prompt_string() {
let tmp = tempdir().expect("tempdir");
let plugin_root = tmp.path().join("demo-plugin");
write_manifest(
&plugin_root,
r#"{
"displayName": "Demo Plugin",
"defaultPrompt": " Summarize my inbox "
}"#,
);
let manifest = load_manifest(&plugin_root);
let interface =
plugin_manifest_interface(&manifest, &plugin_root).expect("plugin interface");
assert_eq!(
interface.default_prompt,
Some(vec!["Summarize my inbox".to_string()])
);
}
#[test]
fn plugin_manifest_interface_normalizes_default_prompt_array() {
let tmp = tempdir().expect("tempdir");
let plugin_root = tmp.path().join("demo-plugin");
let too_long = "x".repeat(MAX_DEFAULT_PROMPT_LEN + 1);
write_manifest(
&plugin_root,
&format!(
r#"{{
"displayName": "Demo Plugin",
"defaultPrompt": [
" Summarize my inbox ",
123,
"{too_long}",
" ",
"Draft the reply ",
"Find my next action",
"Archive old mail"
]
}}"#
),
);
let manifest = load_manifest(&plugin_root);
let interface =
plugin_manifest_interface(&manifest, &plugin_root).expect("plugin interface");
assert_eq!(
interface.default_prompt,
Some(vec![
"Summarize my inbox".to_string(),
"Draft the reply".to_string(),
"Find my next action".to_string(),
])
);
}
#[test]
fn plugin_manifest_interface_ignores_invalid_default_prompt_shape() {
let tmp = tempdir().expect("tempdir");
let plugin_root = tmp.path().join("demo-plugin");
write_manifest(
&plugin_root,
r#"{
"displayName": "Demo Plugin",
"defaultPrompt": { "text": "Summarize my inbox" }
}"#,
);
let manifest = load_manifest(&plugin_root);
let interface =
plugin_manifest_interface(&manifest, &plugin_root).expect("plugin interface");
assert_eq!(interface.default_prompt, None);
}
}

View File

@@ -1851,6 +1851,7 @@ async fn js_repl_emit_image_rejects_mixed_content() -> anyhow::Result<()> {
"properties": {},
"additionalProperties": false
}),
defer_loading: false,
}])
.await;
if !turn
@@ -1949,6 +1950,7 @@ async fn js_repl_dynamic_tool_response_preserves_js_line_separator_text() -> any
"properties": {},
"additionalProperties": false
}),
defer_loading: false,
}])
.await;
@@ -2008,6 +2010,79 @@ console.log(text);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn js_repl_can_call_hidden_dynamic_tools() -> anyhow::Result<()> {
if !can_run_js_repl_runtime_tests().await {
return Ok(());
}
let (session, turn, rx_event) =
make_session_and_context_with_dynamic_tools_and_rx(vec![DynamicToolSpec {
name: "hidden_dynamic_tool".to_string(),
description: "A hidden dynamic tool.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"city": { "type": "string" }
},
"required": ["city"],
"additionalProperties": false
}),
defer_loading: true,
}])
.await;
*session.active_turn.lock().await = Some(crate::state::ActiveTurn::default());
let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::default()));
let manager = turn.js_repl.manager().await?;
let code = r#"
const out = await codex.tool("hidden_dynamic_tool", { city: "Paris" });
console.log(JSON.stringify(out));
"#;
let session_for_response = Arc::clone(&session);
let response_watcher = async move {
loop {
let event = tokio::time::timeout(Duration::from_secs(2), rx_event.recv()).await??;
if let EventMsg::DynamicToolCallRequest(request) = event.msg {
session_for_response
.notify_dynamic_tool_response(
&request.call_id,
DynamicToolResponse {
content_items: vec![DynamicToolCallOutputContentItem::InputText {
text: "hidden-ok".to_string(),
}],
success: true,
},
)
.await;
return Ok::<(), anyhow::Error>(());
}
}
};
let (result, response_watcher_result) = tokio::join!(
manager.execute(
Arc::clone(&session),
Arc::clone(&turn),
tracker,
JsReplArgs {
code: code.to_string(),
timeout_ms: Some(15_000),
},
),
response_watcher,
);
let result = result?;
response_watcher_result?;
assert!(result.output.contains("hidden-ok"));
assert!(session.get_pending_input().await.is_empty());
Ok(())
}
#[tokio::test]
async fn js_repl_prefers_env_node_module_dirs_over_config() -> anyhow::Result<()> {
if !can_run_js_repl_runtime_tests().await {

View File

@@ -4,6 +4,14 @@ use anyhow::Result;
use codex_core::config::types::McpServerConfig;
use codex_core::config::types::McpServerTransportConfig;
use codex_core::features::Feature;
use codex_protocol::dynamic_tools::DynamicToolCallOutputContentItem;
use codex_protocol::dynamic_tools::DynamicToolResponse;
use codex_protocol::dynamic_tools::DynamicToolSpec;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::Op;
use codex_protocol::protocol::SandboxPolicy;
use codex_protocol::user_input::UserInput;
use core_test_support::assert_regex_match;
use core_test_support::responses;
use core_test_support::responses::ResponseMock;
@@ -17,6 +25,8 @@ use core_test_support::skip_if_no_network;
use core_test_support::stdio_server_bin;
use core_test_support::test_codex::TestCodex;
use core_test_support::test_codex::test_codex;
use core_test_support::wait_for_event;
use core_test_support::wait_for_event_match;
use pretty_assertions::assert_eq;
use serde_json::Value;
use std::collections::HashMap;
@@ -91,18 +101,39 @@ fn custom_tool_output_body_and_success(
req: &ResponsesRequest,
call_id: &str,
) -> (String, Option<bool>) {
let (_, success) = req
let (content, success) = req
.custom_tool_call_output_content_and_success(call_id)
.expect("custom tool output should be present");
let items = custom_tool_output_items(req, call_id);
let output = items
let text_items = items
.iter()
.skip(1)
.filter_map(|item| item.get("text").and_then(Value::as_str))
.collect();
.collect::<Vec<_>>();
let output = match text_items.as_slice() {
[] => content.unwrap_or_default(),
[only] => (*only).to_string(),
[_, rest @ ..] => rest.concat(),
};
(output, success)
}
fn custom_tool_output_last_non_empty_text(req: &ResponsesRequest, call_id: &str) -> Option<String> {
match req.custom_tool_call_output(call_id).get("output") {
Some(Value::String(text)) if !text.trim().is_empty() => Some(text.clone()),
Some(Value::Array(items)) => items
.iter()
.filter_map(|item| item.get("text").and_then(Value::as_str))
.rfind(|text| !text.trim().is_empty())
.map(str::to_string),
Some(Value::String(_))
| Some(Value::Object(_))
| Some(Value::Number(_))
| Some(Value::Bool(_))
| Some(Value::Null)
| None => None,
}
}
async fn run_code_mode_turn(
server: &MockServer,
prompt: &str,
@@ -1506,6 +1537,10 @@ text({ json: true });
let req = second_mock.single_request();
let (output, success) = custom_tool_output_body_and_success(&req, "call-1");
eprintln!(
"hidden dynamic tool raw output: {}",
req.custom_tool_call_output("call-1")
);
assert_ne!(
success,
Some(false),
@@ -1920,7 +1955,10 @@ text(JSON.stringify(tool));
"exec ALL_TOOLS lookup failed unexpectedly: {output}"
);
let parsed: Value = serde_json::from_str(&output)?;
let parsed: Value = serde_json::from_str(
&custom_tool_output_last_non_empty_text(&req, "call-1")
.expect("exec ALL_TOOLS lookup should emit JSON"),
)?;
assert_eq!(
parsed,
serde_json::json!({
@@ -1955,7 +1993,10 @@ text(JSON.stringify(tool));
"exec ALL_TOOLS MCP lookup failed unexpectedly: {output}"
);
let parsed: Value = serde_json::from_str(&output)?;
let parsed: Value = serde_json::from_str(
&custom_tool_output_last_non_empty_text(&req, "call-1")
.expect("exec ALL_TOOLS MCP lookup should emit JSON"),
)?;
assert_eq!(
parsed,
serde_json::json!({
@@ -1967,6 +2008,159 @@ text(JSON.stringify(tool));
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn code_mode_can_call_hidden_dynamic_tools() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = responses::start_mock_server().await;
let mut builder = test_codex().with_config(move |config| {
let _ = config.features.enable(Feature::CodeMode);
});
let base_test = builder.build(&server).await?;
let new_thread = base_test
.thread_manager
.start_thread_with_tools(
base_test.config.clone(),
vec![DynamicToolSpec {
name: "hidden_dynamic_tool".to_string(),
description: "A hidden dynamic tool.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"city": { "type": "string" }
},
"required": ["city"],
"additionalProperties": false,
}),
defer_loading: true,
}],
false,
)
.await?;
let test = TestCodex {
home: base_test.home,
cwd: base_test.cwd,
codex: new_thread.thread,
session_configured: new_thread.session_configured,
config: base_test.config,
thread_manager: base_test.thread_manager,
};
let code = r#"
import { ALL_TOOLS, hidden_dynamic_tool } from "tools.js";
const tool = ALL_TOOLS.find(({ name }) => name === "hidden_dynamic_tool");
const out = await hidden_dynamic_tool({ city: "Paris" });
text(
JSON.stringify({
name: tool?.name ?? null,
description: tool?.description ?? null,
out,
})
);
"#;
responses::mount_sse_once(
&server,
sse(vec![
ev_response_created("resp-1"),
ev_custom_tool_call("call-1", "exec", code),
ev_completed("resp-1"),
]),
)
.await;
let second_mock = responses::mount_sse_once(
&server,
sse(vec![
ev_assistant_message("msg-1", "done"),
ev_completed("resp-2"),
]),
)
.await;
test.codex
.submit(Op::UserTurn {
items: vec![UserInput::Text {
text: "use exec to inspect and call hidden tools".into(),
text_elements: Vec::new(),
}],
final_output_json_schema: None,
cwd: test.cwd.path().to_path_buf(),
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::DangerFullAccess,
model: test.session_configured.model.clone(),
effort: None,
summary: None,
service_tier: None,
collaboration_mode: None,
personality: None,
})
.await?;
let turn_id = wait_for_event_match(&test.codex, |event| match event {
EventMsg::TurnStarted(event) => Some(event.turn_id.clone()),
_ => None,
})
.await;
let request = wait_for_event_match(&test.codex, |event| match event {
EventMsg::DynamicToolCallRequest(request) => Some(request.clone()),
_ => None,
})
.await;
assert_eq!(request.tool, "hidden_dynamic_tool");
assert_eq!(request.arguments, serde_json::json!({ "city": "Paris" }));
test.codex
.submit(Op::DynamicToolResponse {
id: request.call_id,
response: DynamicToolResponse {
content_items: vec![DynamicToolCallOutputContentItem::InputText {
text: "hidden-ok".to_string(),
}],
success: true,
},
})
.await?;
wait_for_event(&test.codex, |event| match event {
EventMsg::TurnComplete(event) => event.turn_id == turn_id,
_ => false,
})
.await;
let req = second_mock.single_request();
let (output, success) = custom_tool_output_body_and_success(&req, "call-1");
assert_ne!(
success,
Some(false),
"exec hidden dynamic tool call failed unexpectedly: {output}"
);
let parsed: Value = serde_json::from_str(
&custom_tool_output_last_non_empty_text(&req, "call-1")
.expect("exec hidden dynamic tool lookup should emit JSON"),
)?;
assert_eq!(
parsed.get("name"),
Some(&Value::String("hidden_dynamic_tool".to_string()))
);
assert_eq!(
parsed.get("out"),
Some(&Value::String("hidden-ok".to_string()))
);
assert!(
parsed
.get("description")
.and_then(Value::as_str)
.is_some_and(|description| {
description.contains("A hidden dynamic tool.")
&& description.contains("declare const tools:")
&& description.contains("hidden_dynamic_tool(args:")
})
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn code_mode_can_print_content_only_mcp_tool_result_fields() -> Result<()> {
skip_if_no_network!(Ok(()));
@@ -2130,7 +2324,10 @@ text(JSON.stringify(load("nb")));
Some(false),
"exec load call failed unexpectedly: {second_output}"
);
let loaded: Value = serde_json::from_str(&second_output)?;
let loaded: Value = serde_json::from_str(
&custom_tool_output_last_non_empty_text(&second_request, "call-2")
.expect("exec load call should emit JSON"),
)?;
assert_eq!(
loaded,
serde_json::json!({ "title": "Notebook", "items": [1, true, null] })

View File

@@ -110,6 +110,7 @@ async fn backfill_scans_existing_rollouts() -> Result<()> {
"required": ["city"],
"properties": { "city": { "type": "string" } }
}),
defer_loading: true,
},
DynamicToolSpec {
name: "weather_lookup".to_string(),
@@ -119,6 +120,7 @@ async fn backfill_scans_existing_rollouts() -> Result<()> {
"required": ["zip"],
"properties": { "zip": { "type": "string" } }
}),
defer_loading: false,
},
];
let dynamic_tools_for_hook = dynamic_tools.clone();

View File

@@ -1,15 +1,18 @@
use schemars::JsonSchema;
use serde::Deserialize;
use serde::Deserializer;
use serde::Serialize;
use serde_json::Value as JsonValue;
use ts_rs::TS;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)]
#[derive(Debug, Clone, Serialize, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
pub struct DynamicToolSpec {
pub name: String,
pub description: String,
pub input_schema: JsonValue,
#[serde(default)]
pub defer_loading: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)]
@@ -37,3 +40,92 @@ pub enum DynamicToolCallOutputContentItem {
#[serde(rename_all = "camelCase")]
InputImage { image_url: String },
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct DynamicToolSpecDe {
name: String,
description: String,
input_schema: JsonValue,
defer_loading: Option<bool>,
expose_to_context: Option<bool>,
}
impl<'de> Deserialize<'de> for DynamicToolSpec {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let DynamicToolSpecDe {
name,
description,
input_schema,
defer_loading,
expose_to_context,
} = DynamicToolSpecDe::deserialize(deserializer)?;
Ok(Self {
name,
description,
input_schema,
defer_loading: defer_loading
.unwrap_or_else(|| expose_to_context.map(|visible| !visible).unwrap_or(false)),
})
}
}
#[cfg(test)]
mod tests {
use super::DynamicToolSpec;
use pretty_assertions::assert_eq;
use serde_json::json;
#[test]
fn dynamic_tool_spec_deserializes_defer_loading() {
let value = json!({
"name": "lookup_ticket",
"description": "Fetch a ticket",
"inputSchema": {
"type": "object",
"properties": {
"id": { "type": "string" }
}
},
"deferLoading": true,
});
let actual: DynamicToolSpec = serde_json::from_value(value).expect("deserialize");
assert_eq!(
actual,
DynamicToolSpec {
name: "lookup_ticket".to_string(),
description: "Fetch a ticket".to_string(),
input_schema: json!({
"type": "object",
"properties": {
"id": { "type": "string" }
}
}),
defer_loading: true,
}
);
}
#[test]
fn dynamic_tool_spec_legacy_expose_to_context_inverts_to_defer_loading() {
let value = json!({
"name": "lookup_ticket",
"description": "Fetch a ticket",
"inputSchema": {
"type": "object",
"properties": {}
},
"exposeToContext": false,
});
let actual: DynamicToolSpec = serde_json::from_value(value).expect("deserialize");
assert!(actual.defer_loading);
}
}

View File

@@ -0,0 +1,2 @@
ALTER TABLE thread_dynamic_tools
ADD COLUMN defer_loading INTEGER NOT NULL DEFAULT 0;

View File

@@ -50,7 +50,7 @@ WHERE id = ?
) -> anyhow::Result<Option<Vec<DynamicToolSpec>>> {
let rows = sqlx::query(
r#"
SELECT name, description, input_schema
SELECT name, description, input_schema, defer_loading
FROM thread_dynamic_tools
WHERE thread_id = ?
ORDER BY position ASC
@@ -70,6 +70,7 @@ ORDER BY position ASC
name: row.try_get("name")?,
description: row.try_get("description")?,
input_schema,
defer_loading: row.try_get("defer_loading")?,
});
}
Ok(Some(tools))
@@ -425,8 +426,9 @@ INSERT INTO thread_dynamic_tools (
position,
name,
description,
input_schema
) VALUES (?, ?, ?, ?, ?)
input_schema,
defer_loading
) VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(thread_id, position) DO NOTHING
"#,
)
@@ -435,6 +437,7 @@ ON CONFLICT(thread_id, position) DO NOTHING
.bind(tool.name.as_str())
.bind(tool.description.as_str())
.bind(input_schema)
.bind(tool.defer_loading)
.execute(&mut *tx)
.await?;
}

View File

@@ -22,6 +22,8 @@ from .v2_all import FuzzyFileSearchSessionUpdatedNotification
from .v2_all import HookCompletedNotification
from .v2_all import HookStartedNotification
from .v2_all import ItemCompletedNotification
from .v2_all import ItemGuardianApprovalReviewCompletedNotification
from .v2_all import ItemGuardianApprovalReviewStartedNotification
from .v2_all import ItemStartedNotification
from .v2_all import McpServerOauthLoginCompletedNotification
from .v2_all import McpToolCallProgressNotification
@@ -66,6 +68,8 @@ NOTIFICATION_MODELS: dict[str, type[BaseModel]] = {
"hook/completed": HookCompletedNotification,
"hook/started": HookStartedNotification,
"item/agentMessage/delta": AgentMessageDeltaNotification,
"item/autoApprovalReview/completed": ItemGuardianApprovalReviewCompletedNotification,
"item/autoApprovalReview/started": ItemGuardianApprovalReviewStartedNotification,
"item/commandExecution/outputDelta": CommandExecutionOutputDeltaNotification,
"item/commandExecution/terminalInteraction": TerminalInteractionNotification,
"item/completed": ItemCompletedNotification,

File diff suppressed because it is too large Load Diff

View File

@@ -117,7 +117,7 @@ def test_python_codegen_schema_annotation_adds_stable_variant_titles() -> None:
]
assert ask_for_approval_titles == [
"AskForApprovalValue",
"RejectAskForApproval",
"GranularAskForApproval",
]
reasoning_summary_titles = [