Add notify to code-mode (#14842)
Allows model to send an out-of-band notification. The notification is injected as another tool call output for the same call_id.
This commit is contained in:
parent
7ae99576a6
commit
606d85055f
27 changed files with 323 additions and 77 deletions
|
|
@ -1759,6 +1759,12 @@
|
|||
"call_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"output": {
|
||||
"$ref": "#/definitions/FunctionCallOutputBody"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -10360,6 +10360,12 @@
|
|||
"call_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"output": {
|
||||
"$ref": "#/definitions/v2/FunctionCallOutputBody"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -7148,6 +7148,12 @@
|
|||
"call_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"output": {
|
||||
"$ref": "#/definitions/FunctionCallOutputBody"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -607,6 +607,12 @@
|
|||
"call_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"output": {
|
||||
"$ref": "#/definitions/FunctionCallOutputBody"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -673,6 +673,12 @@
|
|||
"call_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"output": {
|
||||
"$ref": "#/definitions/FunctionCallOutputBody"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -15,4 +15,4 @@ export type ResponseItem = { "type": "message", role: string, content: Array<Con
|
|||
/**
|
||||
* Set when using the Responses API.
|
||||
*/
|
||||
call_id: string | null, status: LocalShellStatus, action: LocalShellAction, } | { "type": "function_call", name: string, namespace?: string, arguments: string, call_id: string, } | { "type": "tool_search_call", call_id: string | null, status?: string, execution: string, arguments: unknown, } | { "type": "function_call_output", call_id: string, output: FunctionCallOutputBody, } | { "type": "custom_tool_call", status?: string, call_id: string, name: string, input: string, } | { "type": "custom_tool_call_output", call_id: string, output: FunctionCallOutputBody, } | { "type": "tool_search_output", call_id: string | null, status: string, execution: string, tools: unknown[], } | { "type": "web_search_call", status?: string, action?: WebSearchAction, } | { "type": "image_generation_call", id: string, status: string, revised_prompt?: string, result: string, } | { "type": "ghost_snapshot", ghost_commit: GhostCommit, } | { "type": "compaction", encrypted_content: string, } | { "type": "other" };
|
||||
call_id: string | null, status: LocalShellStatus, action: LocalShellAction, } | { "type": "function_call", name: string, namespace?: string, arguments: string, call_id: string, } | { "type": "tool_search_call", call_id: string | null, status?: string, execution: string, arguments: unknown, } | { "type": "function_call_output", call_id: string, output: FunctionCallOutputBody, } | { "type": "custom_tool_call", status?: string, call_id: string, name: string, input: string, } | { "type": "custom_tool_call_output", call_id: string, name?: string, output: FunctionCallOutputBody, } | { "type": "tool_search_output", call_id: string | null, status: string, execution: string, tools: unknown[], } | { "type": "web_search_call", status?: string, action?: WebSearchAction, } | { "type": "image_generation_call", id: string, status: string, revised_prompt?: string, result: string, } | { "type": "ghost_snapshot", ghost_commit: GhostCommit, } | { "type": "compaction", encrypted_content: string, } | { "type": "other" };
|
||||
|
|
|
|||
|
|
@ -89,8 +89,12 @@ fn reserialize_shell_outputs(items: &mut [ResponseItem]) {
|
|||
{
|
||||
shell_call_ids.insert(call_id.clone());
|
||||
}
|
||||
ResponseItem::FunctionCallOutput { call_id, output }
|
||||
| ResponseItem::CustomToolCallOutput { call_id, output } => {
|
||||
ResponseItem::FunctionCallOutput {
|
||||
call_id, output, ..
|
||||
}
|
||||
| ResponseItem::CustomToolCallOutput {
|
||||
call_id, output, ..
|
||||
} => {
|
||||
if shell_call_ids.remove(call_id)
|
||||
&& let Some(structured) = output
|
||||
.text_content()
|
||||
|
|
|
|||
|
|
@ -161,6 +161,7 @@ fn reserializes_shell_outputs_for_function_and_custom_tool_calls() {
|
|||
},
|
||||
ResponseItem::CustomToolCallOutput {
|
||||
call_id: "call-2".to_string(),
|
||||
name: None,
|
||||
output: FunctionCallOutputPayload::from_text(raw_output.to_string()),
|
||||
},
|
||||
];
|
||||
|
|
@ -190,6 +191,7 @@ fn reserializes_shell_outputs_for_function_and_custom_tool_calls() {
|
|||
},
|
||||
ResponseItem::CustomToolCallOutput {
|
||||
call_id: "call-2".to_string(),
|
||||
name: None,
|
||||
output: FunctionCallOutputPayload::from_text(expected_output.to_string()),
|
||||
},
|
||||
]
|
||||
|
|
|
|||
|
|
@ -362,15 +362,15 @@ impl ContextManager {
|
|||
),
|
||||
}
|
||||
}
|
||||
ResponseItem::CustomToolCallOutput { call_id, output } => {
|
||||
ResponseItem::CustomToolCallOutput {
|
||||
call_id: call_id.clone(),
|
||||
output: truncate_function_output_payload(
|
||||
output,
|
||||
policy_with_serialization_budget,
|
||||
),
|
||||
}
|
||||
}
|
||||
ResponseItem::CustomToolCallOutput {
|
||||
call_id,
|
||||
name,
|
||||
output,
|
||||
} => ResponseItem::CustomToolCallOutput {
|
||||
call_id: call_id.clone(),
|
||||
name: name.clone(),
|
||||
output: truncate_function_output_payload(output, policy_with_serialization_budget),
|
||||
},
|
||||
ResponseItem::Message { .. }
|
||||
| ResponseItem::Reasoning { .. }
|
||||
| ResponseItem::LocalShellCall { .. }
|
||||
|
|
|
|||
|
|
@ -73,6 +73,7 @@ fn user_input_text_msg(text: &str) -> ResponseItem {
|
|||
fn custom_tool_call_output(call_id: &str, output: &str) -> ResponseItem {
|
||||
ResponseItem::CustomToolCallOutput {
|
||||
call_id: call_id.to_string(),
|
||||
name: None,
|
||||
output: FunctionCallOutputPayload::from_text(output.to_string()),
|
||||
}
|
||||
}
|
||||
|
|
@ -296,6 +297,7 @@ fn for_prompt_strips_images_when_model_does_not_support_images() {
|
|||
},
|
||||
ResponseItem::CustomToolCallOutput {
|
||||
call_id: "tool-1".to_string(),
|
||||
name: None,
|
||||
output: FunctionCallOutputPayload::from_content_items(vec![
|
||||
FunctionCallOutputContentItem::InputText {
|
||||
text: "js repl result".to_string(),
|
||||
|
|
@ -358,6 +360,7 @@ fn for_prompt_strips_images_when_model_does_not_support_images() {
|
|||
},
|
||||
ResponseItem::CustomToolCallOutput {
|
||||
call_id: "tool-1".to_string(),
|
||||
name: None,
|
||||
output: FunctionCallOutputPayload::from_content_items(vec![
|
||||
FunctionCallOutputContentItem::InputText {
|
||||
text: "js repl result".to_string(),
|
||||
|
|
@ -806,6 +809,7 @@ fn remove_first_item_handles_custom_tool_pair() {
|
|||
},
|
||||
ResponseItem::CustomToolCallOutput {
|
||||
call_id: "tool-1".to_string(),
|
||||
name: None,
|
||||
output: FunctionCallOutputPayload::from_text("ok".to_string()),
|
||||
},
|
||||
];
|
||||
|
|
@ -885,6 +889,7 @@ fn record_items_truncates_custom_tool_call_output_content() {
|
|||
let long_output = line.repeat(2_500);
|
||||
let item = ResponseItem::CustomToolCallOutput {
|
||||
call_id: "tool-200".to_string(),
|
||||
name: None,
|
||||
output: FunctionCallOutputPayload::from_text(long_output.clone()),
|
||||
};
|
||||
|
||||
|
|
@ -1087,6 +1092,7 @@ fn normalize_adds_missing_output_for_custom_tool_call() {
|
|||
},
|
||||
ResponseItem::CustomToolCallOutput {
|
||||
call_id: "tool-x".to_string(),
|
||||
name: None,
|
||||
output: FunctionCallOutputPayload::from_text("aborted".to_string()),
|
||||
},
|
||||
]
|
||||
|
|
@ -1154,6 +1160,7 @@ fn normalize_removes_orphan_function_call_output() {
|
|||
fn normalize_removes_orphan_custom_tool_call_output() {
|
||||
let items = vec![ResponseItem::CustomToolCallOutput {
|
||||
call_id: "orphan-2".to_string(),
|
||||
name: None,
|
||||
output: FunctionCallOutputPayload::from_text("ok".to_string()),
|
||||
}];
|
||||
let mut h = create_history_with_items(items);
|
||||
|
|
@ -1229,6 +1236,7 @@ fn normalize_mixed_inserts_and_removals() {
|
|||
},
|
||||
ResponseItem::CustomToolCallOutput {
|
||||
call_id: "t1".to_string(),
|
||||
name: None,
|
||||
output: FunctionCallOutputPayload::from_text("aborted".to_string()),
|
||||
},
|
||||
ResponseItem::LocalShellCall {
|
||||
|
|
@ -1366,6 +1374,7 @@ fn normalize_removes_orphan_function_call_output_panics_in_debug() {
|
|||
fn normalize_removes_orphan_custom_tool_call_output_panics_in_debug() {
|
||||
let items = vec![ResponseItem::CustomToolCallOutput {
|
||||
call_id: "orphan-2".to_string(),
|
||||
name: None,
|
||||
output: FunctionCallOutputPayload::from_text("ok".to_string()),
|
||||
}];
|
||||
let mut h = create_history_with_items(items);
|
||||
|
|
@ -1532,6 +1541,7 @@ fn image_data_url_payload_does_not_dominate_custom_tool_call_output_estimate() {
|
|||
let image_url = format!("data:image/png;base64,{payload}");
|
||||
let item = ResponseItem::CustomToolCallOutput {
|
||||
call_id: "call-js-repl".to_string(),
|
||||
name: None,
|
||||
output: FunctionCallOutputPayload::from_content_items(vec![
|
||||
FunctionCallOutputContentItem::InputText {
|
||||
text: "Screenshot captured".to_string(),
|
||||
|
|
|
|||
|
|
@ -79,6 +79,7 @@ pub(crate) fn ensure_call_outputs_present(items: &mut Vec<ResponseItem>) {
|
|||
idx,
|
||||
ResponseItem::CustomToolCallOutput {
|
||||
call_id: call_id.clone(),
|
||||
name: None,
|
||||
output: FunctionCallOutputPayload::from_text("aborted".to_string()),
|
||||
},
|
||||
));
|
||||
|
|
|
|||
|
|
@ -264,20 +264,22 @@ pub(crate) fn collect_guardian_transcript_entries(
|
|||
serde_json::to_string(action).ok(),
|
||||
)
|
||||
}),
|
||||
ResponseItem::FunctionCallOutput { call_id, output }
|
||||
| ResponseItem::CustomToolCallOutput { call_id, output } => {
|
||||
output.body.to_text().and_then(|text| {
|
||||
non_empty_entry(
|
||||
GuardianTranscriptEntryKind::Tool(
|
||||
tool_names_by_call_id.get(call_id).map_or_else(
|
||||
|| "tool result".to_string(),
|
||||
|name| format!("tool {name} result"),
|
||||
),
|
||||
),
|
||||
text,
|
||||
)
|
||||
})
|
||||
ResponseItem::FunctionCallOutput {
|
||||
call_id, output, ..
|
||||
}
|
||||
| ResponseItem::CustomToolCallOutput {
|
||||
call_id, output, ..
|
||||
} => output.body.to_text().and_then(|text| {
|
||||
non_empty_entry(
|
||||
GuardianTranscriptEntryKind::Tool(
|
||||
tool_names_by_call_id.get(call_id).map_or_else(
|
||||
|| "tool result".to_string(),
|
||||
|name| format!("tool {name} result"),
|
||||
),
|
||||
),
|
||||
text,
|
||||
)
|
||||
}),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -384,12 +384,15 @@ pub(crate) fn response_input_to_response_item(input: &ResponseInputItem) -> Opti
|
|||
output: output.clone(),
|
||||
})
|
||||
}
|
||||
ResponseInputItem::CustomToolCallOutput { call_id, output } => {
|
||||
Some(ResponseItem::CustomToolCallOutput {
|
||||
call_id: call_id.clone(),
|
||||
output: output.clone(),
|
||||
})
|
||||
}
|
||||
ResponseInputItem::CustomToolCallOutput {
|
||||
call_id,
|
||||
name,
|
||||
output,
|
||||
} => Some(ResponseItem::CustomToolCallOutput {
|
||||
call_id: call_id.clone(),
|
||||
name: name.clone(),
|
||||
output: output.clone(),
|
||||
}),
|
||||
ResponseInputItem::McpToolCallOutput { call_id, output } => {
|
||||
let output = output.as_function_call_output_payload();
|
||||
Some(ResponseItem::FunctionCallOutput {
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ Object.defineProperty(globalThis, '__codexContentItems', {
|
|||
defineGlobal('exit', __codexRuntime.exit);
|
||||
defineGlobal('image', __codexRuntime.image);
|
||||
defineGlobal('load', __codexRuntime.load);
|
||||
defineGlobal('notify', __codexRuntime.notify);
|
||||
defineGlobal('store', __codexRuntime.store);
|
||||
defineGlobal('text', __codexRuntime.text);
|
||||
defineGlobal('tools', __codexRuntime.tools);
|
||||
|
|
|
|||
|
|
@ -14,5 +14,6 @@
|
|||
- `image(imageUrl: string)`: Appends an image item and returns it. `image_url` can be an HTTPS URL or a base64-encoded `data:` URL.
|
||||
- `store(key: string, value: any)`: stores a serializable value under a string key for later `exec` calls in the same session.
|
||||
- `load(key: string)`: returns the stored value for a string key, or `undefined` if it is missing.
|
||||
- `notify(value: string | number | boolean | undefined | null)`: immediately injects an extra `custom_tool_call_output` for the current `exec` call. Values are stringified like `text(...)`.
|
||||
- `ALL_TOOLS`: metadata for the enabled nested tools as `{ name, description }` entries.
|
||||
- `yield_control()`: yields the accumulated output to the model immediately while the script keeps running.
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ impl CodeModeExecuteHandler {
|
|||
&self,
|
||||
session: std::sync::Arc<Session>,
|
||||
turn: std::sync::Arc<TurnContext>,
|
||||
call_id: String,
|
||||
code: String,
|
||||
) -> Result<FunctionToolOutput, FunctionCallError> {
|
||||
let args = parse_freeform_args(&code)?;
|
||||
|
|
@ -62,6 +63,7 @@ impl CodeModeExecuteHandler {
|
|||
let message = HostToNodeMessage::Start {
|
||||
request_id: request_id.clone(),
|
||||
cell_id: cell_id.clone(),
|
||||
tool_call_id: call_id,
|
||||
default_yield_time_ms: super::DEFAULT_EXEC_YIELD_TIME_MS,
|
||||
enabled_tools,
|
||||
stored_values,
|
||||
|
|
@ -198,6 +200,7 @@ impl ToolHandler for CodeModeExecuteHandler {
|
|||
let ToolInvocation {
|
||||
session,
|
||||
turn,
|
||||
call_id,
|
||||
tool_name,
|
||||
payload,
|
||||
..
|
||||
|
|
@ -205,7 +208,7 @@ impl ToolHandler for CodeModeExecuteHandler {
|
|||
|
||||
match payload {
|
||||
ToolPayload::Custom { input } if tool_name == PUBLIC_TOOL_NAME => {
|
||||
self.execute(session, turn, input).await
|
||||
self.execute(session, turn, call_id, input).await
|
||||
}
|
||||
_ => Err(FunctionCallError::RespondToModel(format!(
|
||||
"{PUBLIC_TOOL_NAME} expects raw JavaScript source text"
|
||||
|
|
|
|||
|
|
@ -110,6 +110,9 @@ async fn handle_node_message(
|
|||
) -> Result<CodeModeSessionProgress, String> {
|
||||
match message {
|
||||
protocol::NodeToHostMessage::ToolCall { .. } => Err(protocol::unexpected_tool_call_error()),
|
||||
protocol::NodeToHostMessage::Notify { .. } => Err(format!(
|
||||
"unexpected {PUBLIC_TOOL_NAME} notify message in response path"
|
||||
)),
|
||||
protocol::NodeToHostMessage::Yielded { content_items, .. } => {
|
||||
let mut delta_items = output_content_items_from_json_values(content_items)?;
|
||||
delta_items = truncate_code_mode_result(delta_items, poll_max_output_tokens.flatten());
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@ use tracing::warn;
|
|||
|
||||
use super::CODE_MODE_RUNNER_SOURCE;
|
||||
use super::PUBLIC_TOOL_NAME;
|
||||
use super::protocol::CodeModeToolCall;
|
||||
use super::protocol::HostToNodeMessage;
|
||||
use super::protocol::NodeToHostMessage;
|
||||
use super::protocol::message_request_id;
|
||||
|
|
@ -23,7 +22,7 @@ pub(super) struct CodeModeProcess {
|
|||
pub(super) stdin: Arc<Mutex<tokio::process::ChildStdin>>,
|
||||
pub(super) stdout_task: JoinHandle<()>,
|
||||
pub(super) response_waiters: Arc<Mutex<HashMap<String, oneshot::Sender<NodeToHostMessage>>>>,
|
||||
pub(super) tool_call_rx: Arc<Mutex<mpsc::UnboundedReceiver<CodeModeToolCall>>>,
|
||||
pub(super) message_rx: Arc<Mutex<mpsc::UnboundedReceiver<NodeToHostMessage>>>,
|
||||
}
|
||||
|
||||
impl CodeModeProcess {
|
||||
|
|
@ -92,7 +91,7 @@ pub(super) async fn spawn_code_mode_process(
|
|||
String,
|
||||
oneshot::Sender<NodeToHostMessage>,
|
||||
>::new()));
|
||||
let (tool_call_tx, tool_call_rx) = mpsc::unbounded_channel();
|
||||
let (message_tx, message_rx) = mpsc::unbounded_channel();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let mut reader = BufReader::new(stderr);
|
||||
|
|
@ -135,12 +134,14 @@ pub(super) async fn spawn_code_mode_process(
|
|||
}
|
||||
};
|
||||
match message {
|
||||
NodeToHostMessage::ToolCall { tool_call } => {
|
||||
let _ = tool_call_tx.send(tool_call);
|
||||
message @ (NodeToHostMessage::ToolCall { .. }
|
||||
| NodeToHostMessage::Notify { .. }) => {
|
||||
let _ = message_tx.send(message);
|
||||
}
|
||||
message => {
|
||||
let request_id = message_request_id(&message).to_string();
|
||||
if let Some(waiter) = response_waiters.lock().await.remove(&request_id) {
|
||||
if let Some(request_id) = message_request_id(&message)
|
||||
&& let Some(waiter) = response_waiters.lock().await.remove(request_id)
|
||||
{
|
||||
let _ = waiter.send(message);
|
||||
}
|
||||
}
|
||||
|
|
@ -155,7 +156,7 @@ pub(super) async fn spawn_code_mode_process(
|
|||
stdin,
|
||||
stdout_task,
|
||||
response_waiters,
|
||||
tool_call_rx: Arc::new(Mutex::new(tool_call_rx)),
|
||||
message_rx: Arc::new(Mutex::new(message_rx)),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -36,12 +36,20 @@ pub(super) struct CodeModeToolCall {
|
|||
pub(super) input: Option<JsonValue>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub(super) struct CodeModeNotify {
|
||||
pub(super) cell_id: String,
|
||||
pub(super) call_id: String,
|
||||
pub(super) text: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub(super) enum HostToNodeMessage {
|
||||
Start {
|
||||
request_id: String,
|
||||
cell_id: String,
|
||||
tool_call_id: String,
|
||||
default_yield_time_ms: u64,
|
||||
enabled_tools: Vec<EnabledTool>,
|
||||
stored_values: HashMap<String, JsonValue>,
|
||||
|
|
@ -65,7 +73,7 @@ pub(super) enum HostToNodeMessage {
|
|||
},
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub(super) enum NodeToHostMessage {
|
||||
ToolCall {
|
||||
|
|
@ -80,6 +88,10 @@ pub(super) enum NodeToHostMessage {
|
|||
request_id: String,
|
||||
content_items: Vec<JsonValue>,
|
||||
},
|
||||
Notify {
|
||||
#[serde(flatten)]
|
||||
notify: CodeModeNotify,
|
||||
},
|
||||
Result {
|
||||
request_id: String,
|
||||
content_items: Vec<JsonValue>,
|
||||
|
|
@ -105,15 +117,51 @@ pub(super) fn build_source(
|
|||
.replace("__CODE_MODE_USER_CODE_PLACEHOLDER__", user_code))
|
||||
}
|
||||
|
||||
pub(super) fn message_request_id(message: &NodeToHostMessage) -> &str {
|
||||
pub(super) fn message_request_id(message: &NodeToHostMessage) -> Option<&str> {
|
||||
match message {
|
||||
NodeToHostMessage::ToolCall { tool_call } => &tool_call.request_id,
|
||||
NodeToHostMessage::ToolCall { .. } => None,
|
||||
NodeToHostMessage::Yielded { request_id, .. }
|
||||
| NodeToHostMessage::Terminated { request_id, .. }
|
||||
| NodeToHostMessage::Result { request_id, .. } => request_id,
|
||||
| NodeToHostMessage::Result { request_id, .. } => Some(request_id),
|
||||
NodeToHostMessage::Notify { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn unexpected_tool_call_error() -> String {
|
||||
format!("{PUBLIC_TOOL_NAME} received an unexpected tool call response")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::HashMap;
|
||||
|
||||
use super::CodeModeNotify;
|
||||
use super::NodeToHostMessage;
|
||||
use super::message_request_id;
|
||||
|
||||
#[test]
|
||||
fn message_request_id_absent_for_notify() {
|
||||
let message = NodeToHostMessage::Notify {
|
||||
notify: CodeModeNotify {
|
||||
cell_id: "1".to_string(),
|
||||
call_id: "call-1".to_string(),
|
||||
text: "hello".to_string(),
|
||||
},
|
||||
};
|
||||
|
||||
assert_eq!(None, message_request_id(&message));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn message_request_id_present_for_result() {
|
||||
let message = NodeToHostMessage::Result {
|
||||
request_id: "req-1".to_string(),
|
||||
content_items: Vec::new(),
|
||||
stored_values: HashMap::new(),
|
||||
error_text: None,
|
||||
max_output_tokens_per_exec_call: None,
|
||||
};
|
||||
|
||||
assert_eq!(Some("req-1"), message_request_id(&message));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -233,7 +233,7 @@ function codeModeWorkerMain() {
|
|||
throw new TypeError('image expects an http(s) or data URL');
|
||||
}
|
||||
|
||||
function createCodeModeHelpers(context, state) {
|
||||
function createCodeModeHelpers(context, state, toolCallId) {
|
||||
const load = (key) => {
|
||||
if (typeof key !== 'string') {
|
||||
throw new TypeError('load key must be a string');
|
||||
|
|
@ -268,6 +268,21 @@ function codeModeWorkerMain() {
|
|||
const yieldControl = () => {
|
||||
parentPort.postMessage({ type: 'yield' });
|
||||
};
|
||||
const notify = (value) => {
|
||||
const text = serializeOutputText(value);
|
||||
if (text.trim().length === 0) {
|
||||
throw new TypeError('notify expects non-empty text');
|
||||
}
|
||||
if (typeof toolCallId !== 'string' || toolCallId.length === 0) {
|
||||
throw new TypeError('notify requires a valid tool call id');
|
||||
}
|
||||
parentPort.postMessage({
|
||||
type: 'notify',
|
||||
call_id: toolCallId,
|
||||
text,
|
||||
});
|
||||
return text;
|
||||
};
|
||||
const exit = () => {
|
||||
throw new CodeModeExitSignal();
|
||||
};
|
||||
|
|
@ -276,6 +291,7 @@ function codeModeWorkerMain() {
|
|||
exit,
|
||||
image,
|
||||
load,
|
||||
notify,
|
||||
output_image: image,
|
||||
output_text: text,
|
||||
store,
|
||||
|
|
@ -290,6 +306,7 @@ function codeModeWorkerMain() {
|
|||
'exit',
|
||||
'image',
|
||||
'load',
|
||||
'notify',
|
||||
'output_text',
|
||||
'output_image',
|
||||
'store',
|
||||
|
|
@ -300,6 +317,7 @@ function codeModeWorkerMain() {
|
|||
this.setExport('exit', helpers.exit);
|
||||
this.setExport('image', helpers.image);
|
||||
this.setExport('load', helpers.load);
|
||||
this.setExport('notify', helpers.notify);
|
||||
this.setExport('output_text', helpers.output_text);
|
||||
this.setExport('output_image', helpers.output_image);
|
||||
this.setExport('store', helpers.store);
|
||||
|
|
@ -316,6 +334,7 @@ function codeModeWorkerMain() {
|
|||
exit: helpers.exit,
|
||||
image: helpers.image,
|
||||
load: helpers.load,
|
||||
notify: helpers.notify,
|
||||
store: helpers.store,
|
||||
text: helpers.text,
|
||||
tools: createGlobalToolsNamespace(callTool, enabledTools),
|
||||
|
|
@ -448,6 +467,7 @@ function codeModeWorkerMain() {
|
|||
|
||||
async function main() {
|
||||
const start = workerData ?? {};
|
||||
const toolCallId = start.tool_call_id;
|
||||
const state = {
|
||||
storedValues: cloneJsonValue(start.stored_values ?? {}),
|
||||
};
|
||||
|
|
@ -457,7 +477,7 @@ function codeModeWorkerMain() {
|
|||
const context = vm.createContext({
|
||||
__codexContentItems: contentItems,
|
||||
});
|
||||
const helpers = createCodeModeHelpers(context, state);
|
||||
const helpers = createCodeModeHelpers(context, state, toolCallId);
|
||||
Object.defineProperty(context, '__codexRuntime', {
|
||||
value: createBridgeRuntime(callTool, enabledTools, helpers),
|
||||
configurable: true,
|
||||
|
|
@ -631,6 +651,9 @@ function sessionWorkerSource() {
|
|||
}
|
||||
|
||||
function startSession(protocol, sessions, start) {
|
||||
if (typeof start.tool_call_id !== 'string' || start.tool_call_id.length === 0) {
|
||||
throw new TypeError('start requires a valid tool_call_id');
|
||||
}
|
||||
const maxOutputTokensPerExecCall =
|
||||
start.max_output_tokens == null
|
||||
? DEFAULT_MAX_OUTPUT_TOKENS_PER_EXEC_CALL
|
||||
|
|
@ -704,6 +727,22 @@ async function handleWorkerMessage(protocol, sessions, session, message) {
|
|||
return;
|
||||
}
|
||||
|
||||
if (message.type === 'notify') {
|
||||
if (typeof message.text !== 'string' || message.text.trim().length === 0) {
|
||||
throw new TypeError('notify requires non-empty text');
|
||||
}
|
||||
if (typeof message.call_id !== 'string' || message.call_id.length === 0) {
|
||||
throw new TypeError('notify requires a valid call id');
|
||||
}
|
||||
await protocol.send({
|
||||
type: 'notify',
|
||||
cell_id: session.id,
|
||||
call_id: message.call_id,
|
||||
text: message.text,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === 'tool_call') {
|
||||
void forwardToolCall(protocol, session, message);
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -1,13 +1,18 @@
|
|||
use tokio::sync::oneshot;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tracing::error;
|
||||
use tracing::warn;
|
||||
|
||||
use codex_protocol::models::FunctionCallOutputPayload;
|
||||
use codex_protocol::models::ResponseInputItem;
|
||||
|
||||
use super::ExecContext;
|
||||
use super::PUBLIC_TOOL_NAME;
|
||||
use super::call_nested_tool;
|
||||
use super::process::CodeModeProcess;
|
||||
use super::process::write_message;
|
||||
use super::protocol::HostToNodeMessage;
|
||||
use super::protocol::NodeToHostMessage;
|
||||
use crate::tools::parallel::ToolCallRuntime;
|
||||
pub(crate) struct CodeModeWorker {
|
||||
shutdown_tx: Option<oneshot::Sender<()>>,
|
||||
|
|
@ -29,39 +34,71 @@ impl CodeModeProcess {
|
|||
) -> CodeModeWorker {
|
||||
let (shutdown_tx, mut shutdown_rx) = oneshot::channel();
|
||||
let stdin = self.stdin.clone();
|
||||
let tool_call_rx = self.tool_call_rx.clone();
|
||||
let message_rx = self.message_rx.clone();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
let tool_call = tokio::select! {
|
||||
let next_message = tokio::select! {
|
||||
_ = &mut shutdown_rx => break,
|
||||
tool_call = async {
|
||||
let mut tool_call_rx = tool_call_rx.lock().await;
|
||||
tool_call_rx.recv().await
|
||||
} => tool_call,
|
||||
message = async {
|
||||
let mut message_rx = message_rx.lock().await;
|
||||
message_rx.recv().await
|
||||
} => message,
|
||||
};
|
||||
let Some(tool_call) = tool_call else {
|
||||
let Some(next_message) = next_message else {
|
||||
break;
|
||||
};
|
||||
let exec = exec.clone();
|
||||
let tool_runtime = tool_runtime.clone();
|
||||
let stdin = stdin.clone();
|
||||
tokio::spawn(async move {
|
||||
let response = HostToNodeMessage::Response {
|
||||
request_id: tool_call.request_id,
|
||||
id: tool_call.id,
|
||||
code_mode_result: call_nested_tool(
|
||||
exec,
|
||||
tool_runtime,
|
||||
tool_call.name,
|
||||
tool_call.input,
|
||||
CancellationToken::new(),
|
||||
)
|
||||
.await,
|
||||
};
|
||||
if let Err(err) = write_message(&stdin, &response).await {
|
||||
warn!("failed to write {PUBLIC_TOOL_NAME} tool response: {err}");
|
||||
match next_message {
|
||||
NodeToHostMessage::ToolCall { tool_call } => {
|
||||
let exec = exec.clone();
|
||||
let tool_runtime = tool_runtime.clone();
|
||||
let stdin = stdin.clone();
|
||||
tokio::spawn(async move {
|
||||
let response = HostToNodeMessage::Response {
|
||||
request_id: tool_call.request_id,
|
||||
id: tool_call.id,
|
||||
code_mode_result: call_nested_tool(
|
||||
exec,
|
||||
tool_runtime,
|
||||
tool_call.name,
|
||||
tool_call.input,
|
||||
CancellationToken::new(),
|
||||
)
|
||||
.await,
|
||||
};
|
||||
if let Err(err) = write_message(&stdin, &response).await {
|
||||
warn!("failed to write {PUBLIC_TOOL_NAME} tool response: {err}");
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
NodeToHostMessage::Notify { notify } => {
|
||||
if notify.text.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
if exec
|
||||
.session
|
||||
.inject_response_items(vec![ResponseInputItem::CustomToolCallOutput {
|
||||
call_id: notify.call_id.clone(),
|
||||
name: Some(PUBLIC_TOOL_NAME.to_string()),
|
||||
output: FunctionCallOutputPayload::from_text(notify.text),
|
||||
}])
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
warn!(
|
||||
"failed to inject {PUBLIC_TOOL_NAME} notify message for cell {}: no active turn",
|
||||
notify.cell_id
|
||||
);
|
||||
}
|
||||
}
|
||||
unexpected_message @ (NodeToHostMessage::Yielded { .. }
|
||||
| NodeToHostMessage::Terminated { .. }
|
||||
| NodeToHostMessage::Result { .. }) => {
|
||||
error!(
|
||||
"received unexpected {PUBLIC_TOOL_NAME} message in worker loop: {unexpected_message:?}"
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -417,6 +417,7 @@ fn function_tool_response(
|
|||
if matches!(payload, ToolPayload::Custom { .. }) {
|
||||
return ResponseInputItem::CustomToolCallOutput {
|
||||
call_id: call_id.to_string(),
|
||||
name: None,
|
||||
output: FunctionCallOutputPayload { body, success },
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,9 @@ fn custom_tool_calls_should_roundtrip_as_custom_outputs() {
|
|||
.to_response_item("call-42", &payload);
|
||||
|
||||
match response {
|
||||
ResponseInputItem::CustomToolCallOutput { call_id, output } => {
|
||||
ResponseInputItem::CustomToolCallOutput {
|
||||
call_id, output, ..
|
||||
} => {
|
||||
assert_eq!(call_id, "call-42");
|
||||
assert_eq!(output.content_items(), None);
|
||||
assert_eq!(output.body.to_text().as_deref(), Some("patched"));
|
||||
|
|
@ -106,7 +108,9 @@ fn custom_tool_calls_can_derive_text_from_content_items() {
|
|||
.to_response_item("call-99", &payload);
|
||||
|
||||
match response {
|
||||
ResponseInputItem::CustomToolCallOutput { call_id, output } => {
|
||||
ResponseInputItem::CustomToolCallOutput {
|
||||
call_id, output, ..
|
||||
} => {
|
||||
let expected = vec![
|
||||
FunctionCallOutputContentItem::InputText {
|
||||
text: "line 1".to_string(),
|
||||
|
|
|
|||
|
|
@ -376,6 +376,7 @@ fn validate_emitted_image_url_rejects_non_data_scheme() {
|
|||
fn summarize_tool_call_response_for_multimodal_custom_output() {
|
||||
let response = ResponseInputItem::CustomToolCallOutput {
|
||||
call_id: "call-1".to_string(),
|
||||
name: None,
|
||||
output: FunctionCallOutputPayload::from_content_items(vec![
|
||||
FunctionCallOutputContentItem::InputImage {
|
||||
image_url: "data:image/png;base64,abcd".to_string(),
|
||||
|
|
|
|||
|
|
@ -389,6 +389,7 @@ async fn resume_replays_legacy_js_repl_image_rollout_shapes() {
|
|||
timestamp: "2024-01-01T00:00:02.000Z".to_string(),
|
||||
item: RolloutItem::ResponseItem(ResponseItem::CustomToolCallOutput {
|
||||
call_id: "legacy-js-call".to_string(),
|
||||
name: None,
|
||||
output: FunctionCallOutputPayload::from_text("legacy js_repl stdout".to_string()),
|
||||
}),
|
||||
},
|
||||
|
|
@ -546,6 +547,7 @@ async fn resume_replays_image_tool_outputs_with_detail() {
|
|||
timestamp: "2024-01-01T00:00:02.500Z".to_string(),
|
||||
item: RolloutItem::ResponseItem(ResponseItem::CustomToolCallOutput {
|
||||
call_id: custom_call_id.to_string(),
|
||||
name: None,
|
||||
output: FunctionCallOutputPayload::from_content_items(vec![
|
||||
FunctionCallOutputContentItem::InputImage {
|
||||
image_url: image_url.to_string(),
|
||||
|
|
@ -1898,6 +1900,7 @@ async fn azure_responses_request_includes_store_and_reasoning_ids() {
|
|||
});
|
||||
prompt.input.push(ResponseItem::CustomToolCallOutput {
|
||||
call_id: "custom-tool-call-id".into(),
|
||||
name: None,
|
||||
output: FunctionCallOutputPayload::from_text("ok".into()),
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1551,6 +1551,44 @@ text({ json: true });
|
|||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn code_mode_notify_injects_additional_exec_tool_output_into_active_context() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = responses::start_mock_server().await;
|
||||
let (_test, second_mock) = run_code_mode_turn(
|
||||
&server,
|
||||
"use exec notify helper",
|
||||
r#"
|
||||
notify("code_mode_notify_marker");
|
||||
await tools.test_sync_tool({});
|
||||
text("done");
|
||||
"#,
|
||||
false,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let req = second_mock.single_request();
|
||||
let has_notify_output = req
|
||||
.inputs_of_type("custom_tool_call_output")
|
||||
.iter()
|
||||
.any(|item| {
|
||||
item.get("call_id").and_then(serde_json::Value::as_str) == Some("call-1")
|
||||
&& item
|
||||
.get("output")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.is_some_and(|text| text.contains("code_mode_notify_marker"))
|
||||
&& item.get("name").and_then(serde_json::Value::as_str) == Some("exec")
|
||||
});
|
||||
assert!(
|
||||
has_notify_output,
|
||||
"expected notify marker in custom_tool_call_output item: {:?}",
|
||||
req.input()
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn code_mode_exit_stops_script_immediately() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
|
@ -1957,6 +1995,7 @@ text(JSON.stringify(Object.getOwnPropertyNames(globalThis).sort()));
|
|||
"isFinite",
|
||||
"isNaN",
|
||||
"load",
|
||||
"notify",
|
||||
"parseFloat",
|
||||
"parseInt",
|
||||
"store",
|
||||
|
|
|
|||
|
|
@ -241,6 +241,9 @@ pub enum ResponseInputItem {
|
|||
},
|
||||
CustomToolCallOutput {
|
||||
call_id: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
name: Option<String>,
|
||||
#[ts(as = "FunctionCallOutputBody")]
|
||||
#[schemars(with = "FunctionCallOutputBody")]
|
||||
output: FunctionCallOutputPayload,
|
||||
|
|
@ -382,6 +385,9 @@ pub enum ResponseItem {
|
|||
// text or structured content items.
|
||||
CustomToolCallOutput {
|
||||
call_id: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
name: Option<String>,
|
||||
#[ts(as = "FunctionCallOutputBody")]
|
||||
#[schemars(with = "FunctionCallOutputBody")]
|
||||
output: FunctionCallOutputPayload,
|
||||
|
|
@ -1008,9 +1014,15 @@ impl From<ResponseInputItem> for ResponseItem {
|
|||
let output = output.into_function_call_output_payload();
|
||||
Self::FunctionCallOutput { call_id, output }
|
||||
}
|
||||
ResponseInputItem::CustomToolCallOutput { call_id, output } => {
|
||||
Self::CustomToolCallOutput { call_id, output }
|
||||
}
|
||||
ResponseInputItem::CustomToolCallOutput {
|
||||
call_id,
|
||||
name,
|
||||
output,
|
||||
} => Self::CustomToolCallOutput {
|
||||
call_id,
|
||||
name,
|
||||
output,
|
||||
},
|
||||
ResponseInputItem::ToolSearchOutput {
|
||||
call_id,
|
||||
status,
|
||||
|
|
@ -2392,6 +2404,7 @@ mod tests {
|
|||
fn serializes_custom_tool_image_outputs_as_array() -> Result<()> {
|
||||
let item = ResponseInputItem::CustomToolCallOutput {
|
||||
call_id: "call1".into(),
|
||||
name: None,
|
||||
output: FunctionCallOutputPayload::from_content_items(vec![
|
||||
FunctionCallOutputContentItem::InputImage {
|
||||
image_url: "data:image/png;base64,BASE64".into(),
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue