Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions codex-rs/app-server-protocol/src/protocol/common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -524,6 +524,12 @@ server_request_definitions! {
response: v2::ToolRequestUserInputResponse,
},

/// Execute a dynamic tool call on the client.
DynamicToolCall => "item/tool/call" {
params: v2::DynamicToolCallParams,
response: v2::DynamicToolCallResponse,
},

/// DEPRECATED APIs below
/// Request to approve a patch.
/// This request is used for Turns started via the legacy APIs (i.e. SendUserTurn, SendUserMessage).
Expand Down
29 changes: 29 additions & 0 deletions codex-rs/app-server-protocol/src/protocol/v2.rs
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,15 @@ pub struct ToolsV2 {
pub view_image: Option<bool>,
}

#[derive(Serialize, Deserialize, 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,
}

#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "snake_case")]
#[ts(export_to = "v2/")]
Expand Down Expand Up @@ -1088,6 +1097,7 @@ pub struct ThreadStartParams {
pub developer_instructions: Option<String>,
pub personality: Option<Personality>,
pub ephemeral: Option<bool>,
pub dynamic_tools: Option<Vec<DynamicToolSpec>>,
/// If true, opt into emitting raw response items on the event stream.
///
/// This is for internal use only (e.g. Codex Cloud).
Expand Down Expand Up @@ -2372,6 +2382,25 @@ pub struct FileChangeRequestApprovalResponse {
pub decision: FileChangeApprovalDecision,
}

#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct DynamicToolCallParams {
pub thread_id: String,
pub turn_id: String,
pub call_id: String,
pub tool: String,
pub arguments: JsonValue,
}

#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct DynamicToolCallResponse {
pub output: String,
pub success: bool,
}

#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
Expand Down
15 changes: 14 additions & 1 deletion codex-rs/app-server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,20 @@ Start a fresh thread when you need a new Codex conversation.
"cwd": "/Users/me/project",
"approvalPolicy": "never",
"sandbox": "workspaceWrite",
"personality": "friendly"
"personality": "friendly",
"dynamicTools": [
{
"name": "lookup_ticket",
"description": "Fetch a ticket by id",
"inputSchema": {
"type": "object",
"properties": {
"id": { "type": "string" }
},
"required": ["id"]
}
}
],
} }
{ "id": 10, "result": {
"thread": {
Expand Down
36 changes: 36 additions & 0 deletions codex-rs/app-server/src/bespoke_event_handling.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ use codex_app_server_protocol::CommandExecutionRequestApprovalResponse;
use codex_app_server_protocol::CommandExecutionStatus;
use codex_app_server_protocol::ContextCompactedNotification;
use codex_app_server_protocol::DeprecationNoticeNotification;
use codex_app_server_protocol::DynamicToolCallParams;
use codex_app_server_protocol::ErrorNotification;
use codex_app_server_protocol::ExecCommandApprovalParams;
use codex_app_server_protocol::ExecCommandApprovalResponse;
Expand Down Expand Up @@ -85,6 +86,7 @@ use codex_core::protocol::TurnDiffEvent;
use codex_core::review_format::format_review_findings_block;
use codex_core::review_prompts;
use codex_protocol::ThreadId;
use codex_protocol::dynamic_tools::DynamicToolResponse as CoreDynamicToolResponse;
use codex_protocol::plan_tool::UpdatePlanArgs;
use codex_protocol::protocol::ReviewOutputEvent;
use codex_protocol::request_user_input::RequestUserInputAnswer as CoreRequestUserInputAnswer;
Expand Down Expand Up @@ -318,6 +320,40 @@ pub(crate) async fn apply_bespoke_event_handling(
}
}
}
EventMsg::DynamicToolCallRequest(request) => {
if matches!(api_version, ApiVersion::V2) {
let call_id = request.call_id;
let params = DynamicToolCallParams {
thread_id: conversation_id.to_string(),
turn_id: request.turn_id,
call_id: call_id.clone(),
tool: request.tool,
arguments: request.arguments,
};
let rx = outgoing
.send_request(ServerRequestPayload::DynamicToolCall(params))
.await;
tokio::spawn(async move {
crate::dynamic_tools::on_call_response(call_id, rx, conversation).await;
});
} else {
error!(
"dynamic tool calls are only supported on api v2 (call_id: {})",
request.call_id
);
let call_id = request.call_id;
let _ = conversation
.submit(Op::DynamicToolResponse {
id: call_id.clone(),
response: CoreDynamicToolResponse {
call_id,
output: "dynamic tool calls require api v2".to_string(),
success: false,
},
})
.await;
}
}
// TODO(celia): properly construct McpToolCall TurnItem in core.
EventMsg::McpToolCallBegin(begin_event) => {
let notification = construct_mcp_tool_call_notification(
Expand Down
157 changes: 131 additions & 26 deletions codex-rs/app-server/src/codex_message_processor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ use codex_app_server_protocol::CollaborationModeListResponse;
use codex_app_server_protocol::CommandExecParams;
use codex_app_server_protocol::ConversationGitInfo;
use codex_app_server_protocol::ConversationSummary;
use codex_app_server_protocol::DynamicToolSpec as ApiDynamicToolSpec;
use codex_app_server_protocol::ExecOneOffCommandResponse;
use codex_app_server_protocol::FeedbackUploadParams;
use codex_app_server_protocol::FeedbackUploadResponse;
Expand Down Expand Up @@ -171,6 +172,7 @@ use codex_login::run_login_server;
use codex_protocol::ThreadId;
use codex_protocol::config_types::ForcedLoginMethod;
use codex_protocol::config_types::Personality;
use codex_protocol::dynamic_tools::DynamicToolSpec as CoreDynamicToolSpec;
use codex_protocol::items::TurnItem;
use codex_protocol::models::ResponseItem;
use codex_protocol::protocol::AgentStatus;
Expand Down Expand Up @@ -1411,35 +1413,81 @@ impl CodexMessageProcessor {
}

async fn thread_start(&mut self, request_id: RequestId, params: ThreadStartParams) {
let ThreadStartParams {
model,
model_provider,
cwd,
approval_policy,
sandbox,
config,
base_instructions,
developer_instructions,
dynamic_tools,
experimental_raw_events,
personality,
ephemeral,
} = params;
let mut typesafe_overrides = self.build_thread_config_overrides(
params.model,
params.model_provider,
params.cwd,
params.approval_policy,
params.sandbox,
params.base_instructions,
params.developer_instructions,
params.personality,
model,
model_provider,
cwd,
approval_policy,
sandbox,
base_instructions,
developer_instructions,
personality,
);
typesafe_overrides.ephemeral = Some(params.ephemeral.unwrap_or_default());
typesafe_overrides.ephemeral = ephemeral;

let config =
match derive_config_from_params(&self.cli_overrides, params.config, typesafe_overrides)
.await
{
Ok(config) => config,
Err(err) => {
let error = JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: format!("error deriving config: {err}"),
data: None,
};
self.outgoing.send_error(request_id, error).await;
return;
}
};
let config = match derive_config_from_params(
&self.cli_overrides,
config,
typesafe_overrides,
)
.await
{
Ok(config) => config,
Err(err) => {
let error = JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: format!("error deriving config: {err}"),
data: None,
};
self.outgoing.send_error(request_id, error).await;
return;
}
};

match self.thread_manager.start_thread(config).await {
let dynamic_tools = dynamic_tools.unwrap_or_default();
let core_dynamic_tools = if dynamic_tools.is_empty() {
Vec::new()
} else {
let snapshot = collect_mcp_snapshot(&config).await;
let mcp_tool_names = snapshot.tools.keys().cloned().collect::<HashSet<_>>();
if let Err(message) = validate_dynamic_tools(&dynamic_tools, &mcp_tool_names) {
let error = JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message,
data: None,
};
self.outgoing.send_error(request_id, error).await;
return;
}
dynamic_tools
.into_iter()
.map(|tool| CoreDynamicToolSpec {
name: tool.name,
description: tool.description,
input_schema: tool.input_schema,
})
.collect()
};

match self
.thread_manager
.start_thread_with_tools(config, core_dynamic_tools)
.await
{
Ok(new_conv) => {
let NewThread {
thread_id,
Expand Down Expand Up @@ -1489,7 +1537,7 @@ impl CodexMessageProcessor {
if let Err(err) = self
.attach_conversation_listener(
thread_id,
params.experimental_raw_events,
experimental_raw_events,
ApiVersion::V2,
)
.await
Expand Down Expand Up @@ -4322,6 +4370,41 @@ fn errors_to_info(
.collect()
}

fn validate_dynamic_tools(
tools: &[ApiDynamicToolSpec],
mcp_tool_names: &HashSet<String>,
) -> Result<(), String> {
let mut seen = HashSet::new();
for tool in tools {
let name = tool.name.trim();
if name.is_empty() {
return Err("dynamic tool name must not be empty".to_string());
}
if name != tool.name {
return Err(format!(
"dynamic tool name has leading/trailing whitespace: {}",
tool.name
));
}
if name == "mcp" || name.starts_with("mcp__") {
return Err(format!("dynamic tool name is reserved: {name}"));
}
if mcp_tool_names.contains(name) {
return Err(format!("dynamic tool name conflicts with MCP tool: {name}"));
}
if !seen.insert(name.to_string()) {
return Err(format!("duplicate dynamic tool name: {name}"));
}

if let Err(err) = codex_core::parse_tool_input_schema(&tool.input_schema) {
return Err(format!(
"dynamic tool input schema is not supported for {name}: {err}"
));
}
}
Ok(())
}

/// Derive the effective [`Config`] by layering three override sources.
///
/// Precedence (lowest to highest):
Expand Down Expand Up @@ -4602,6 +4685,28 @@ mod tests {
use serde_json::json;
use tempfile::TempDir;

#[test]
fn validate_dynamic_tools_rejects_unsupported_input_schema() {
let tools = vec![ApiDynamicToolSpec {
name: "my_tool".to_string(),
description: "test".to_string(),
input_schema: json!({"type": "null"}),
}];
let err = validate_dynamic_tools(&tools, &HashSet::new()).expect_err("invalid schema");
assert!(err.contains("my_tool"), "unexpected error: {err}");
}

#[test]
fn validate_dynamic_tools_accepts_sanitizable_input_schema() {
let tools = vec![ApiDynamicToolSpec {
name: "my_tool".to_string(),
description: "test".to_string(),
// Missing `type` is common; core sanitizes these to a supported schema.
input_schema: json!({"properties": {}}),
}];
validate_dynamic_tools(&tools, &HashSet::new()).expect("valid schema");
}

#[test]
fn extract_conversation_summary_prefers_plain_user_messages() -> Result<()> {
let conversation_id = ThreadId::from_string("3f941c35-29b3-493b-b0a4-e25800d9aeb0")?;
Expand Down
Loading
Loading