core-agent-ide/codex-rs/code-mode/src/runtime/value.rs

164 lines
5.9 KiB
Rust
Raw Normal View History

Code mode on v8 (#15276) Moves Code Mode to a new crate with no dependencies on codex. This create encodes the code mode semantics that we want for lifetime, mounting, tool calling. The model-facing surface is mostly unchanged. `exec` still runs raw JavaScript, `wait` still resumes or terminates a `cell_id`, nested tools are still available through `tools.*`, and helpers like `text`, `image`, `store`, `load`, `notify`, `yield_control`, and `exit` still exist. The major change is underneath that surface: - Old code mode was an external Node runtime. - New code mode is an in-process V8 runtime embedded directly in Rust. - Old code mode managed cells inside a long-lived Node runner process. - New code mode manages cells in Rust, with one V8 runtime thread per active `exec`. - Old code mode used JSON protocol messages over child stdin/stdout plus Node worker-thread messages. - New code mode uses Rust channels and direct V8 callbacks/events. This PR also fixes the two migration regressions that fell out of that substrate change: - `wait { terminate: true }` now waits for the V8 runtime to actually stop before reporting termination. - synchronous top-level `exit()` now succeeds again instead of surfacing as a script error. --- - `core/src/tools/code_mode/*` is now mostly an adapter layer for the public `exec` / `wait` tools. - `code-mode/src/service.rs` owns cell sessions and async control flow in Rust. - `code-mode/src/runtime/*.rs` owns the embedded V8 isolate and JavaScript execution. - each `exec` spawns a dedicated runtime thread plus a Rust session-control task. - helper globals are installed directly into the V8 context instead of being injected through a source prelude. - helper modules like `tools.js` and `@openai/code_mode` are synthesized through V8 module resolution callbacks in Rust. --- Also added a benchmark for showing the speed of init and use of a code mode env: ``` $ cargo bench -p codex-code-mode --bench exec_overhead -- --samples 30 --warm-iterations 25 --tool-counts 0,32,128 Finished [`bench` profile [optimized]](https://doc.rust-lang.org/cargo/reference/profiles.html#default-profiles) target(s) in 0.18s Running benches/exec_overhead.rs (target/release/deps/exec_overhead-008c440d800545ae) exec_overhead: samples=30, warm_iterations=25, tool_counts=[0, 32, 128] scenario tools samples warmups iters mean/exec p95/exec rssΔ p50 rssΔ max cold_exec 0 30 0 1 1.13ms 1.20ms 8.05MiB 8.06MiB warm_exec 0 30 1 25 473.43us 512.49us 912.00KiB 1.33MiB cold_exec 32 30 0 1 1.03ms 1.15ms 8.08MiB 8.11MiB warm_exec 32 30 1 25 509.73us 545.76us 960.00KiB 1.30MiB cold_exec 128 30 0 1 1.14ms 1.19ms 8.30MiB 8.34MiB warm_exec 128 30 1 25 575.08us 591.03us 736.00KiB 864.00KiB memory uses a fresh-process max RSS delta for each scenario ``` --------- Co-authored-by: Codex <noreply@openai.com>
2026-03-20 23:36:58 -07:00
use serde_json::Value as JsonValue;
use crate::response::FunctionCallOutputContentItem;
use crate::response::ImageDetail;
pub(super) fn serialize_output_text(
scope: &mut v8::PinScope<'_, '_>,
value: v8::Local<'_, v8::Value>,
) -> Result<String, String> {
if value.is_undefined()
|| value.is_null()
|| value.is_boolean()
|| value.is_number()
|| value.is_big_int()
|| value.is_string()
{
return Ok(value.to_rust_string_lossy(scope));
}
let tc = std::pin::pin!(v8::TryCatch::new(scope));
let mut tc = tc.init();
if let Some(stringified) = v8::json::stringify(&tc, value) {
return Ok(stringified.to_rust_string_lossy(&tc));
}
if tc.has_caught() {
return Err(tc
.exception()
.map(|exception| value_to_error_text(&mut tc, exception))
.unwrap_or_else(|| "unknown code mode exception".to_string()));
}
Ok(value.to_rust_string_lossy(&tc))
}
pub(super) fn normalize_output_image(
scope: &mut v8::PinScope<'_, '_>,
value: v8::Local<'_, v8::Value>,
) -> Result<FunctionCallOutputContentItem, ()> {
let result = (|| -> Result<FunctionCallOutputContentItem, String> {
let (image_url, detail) = if value.is_string() {
(value.to_rust_string_lossy(scope), None)
} else if value.is_object() && !value.is_array() {
let object = v8::Local::<v8::Object>::try_from(value).map_err(|_| {
"image expects a non-empty image URL string or an object with image_url and optional detail".to_string()
})?;
let image_url_key = v8::String::new(scope, "image_url")
.ok_or_else(|| "failed to allocate image helper keys".to_string())?;
let detail_key = v8::String::new(scope, "detail")
.ok_or_else(|| "failed to allocate image helper keys".to_string())?;
let image_url = object
.get(scope, image_url_key.into())
.filter(|value| value.is_string())
.map(|value| value.to_rust_string_lossy(scope))
.ok_or_else(|| {
"image expects a non-empty image URL string or an object with image_url and optional detail"
.to_string()
})?;
let detail = match object.get(scope, detail_key.into()) {
Some(value) if value.is_string() => Some(value.to_rust_string_lossy(scope)),
Some(value) if value.is_null() || value.is_undefined() => None,
Some(_) => return Err("image detail must be a string when provided".to_string()),
None => None,
};
(image_url, detail)
} else {
return Err(
"image expects a non-empty image URL string or an object with image_url and optional detail"
.to_string(),
);
};
if image_url.is_empty() {
return Err(
"image expects a non-empty image URL string or an object with image_url and optional detail"
.to_string(),
);
}
let lower = image_url.to_ascii_lowercase();
if !(lower.starts_with("http://")
|| lower.starts_with("https://")
|| lower.starts_with("data:"))
{
return Err("image expects an http(s) or data URL".to_string());
}
let detail = match detail {
Some(detail) => {
let normalized = detail.to_ascii_lowercase();
Some(match normalized.as_str() {
"auto" => ImageDetail::Auto,
"low" => ImageDetail::Low,
"high" => ImageDetail::High,
"original" => ImageDetail::Original,
_ => {
return Err(
"image detail must be one of: auto, low, high, original".to_string()
);
}
})
}
None => None,
};
Ok(FunctionCallOutputContentItem::InputImage { image_url, detail })
})();
match result {
Ok(item) => Ok(item),
Err(error_text) => {
throw_type_error(scope, &error_text);
Err(())
}
}
}
pub(super) fn v8_value_to_json(
scope: &mut v8::PinScope<'_, '_>,
value: v8::Local<'_, v8::Value>,
) -> Result<Option<JsonValue>, String> {
let tc = std::pin::pin!(v8::TryCatch::new(scope));
let mut tc = tc.init();
let Some(stringified) = v8::json::stringify(&tc, value) else {
if tc.has_caught() {
return Err(tc
.exception()
.map(|exception| value_to_error_text(&mut tc, exception))
.unwrap_or_else(|| "unknown code mode exception".to_string()));
}
return Ok(None);
};
serde_json::from_str(&stringified.to_rust_string_lossy(&tc))
.map(Some)
.map_err(|err| format!("failed to serialize JavaScript value: {err}"))
}
pub(super) fn json_to_v8<'s>(
scope: &mut v8::PinScope<'s, '_>,
value: &JsonValue,
) -> Option<v8::Local<'s, v8::Value>> {
let json = serde_json::to_string(value).ok()?;
let json = v8::String::new(scope, &json)?;
v8::json::parse(scope, json)
}
pub(super) fn value_to_error_text(
scope: &mut v8::PinScope<'_, '_>,
value: v8::Local<'_, v8::Value>,
) -> String {
if value.is_object()
&& let Ok(object) = v8::Local::<v8::Object>::try_from(value)
&& let Some(key) = v8::String::new(scope, "stack")
&& let Some(stack) = object.get(scope, key.into())
&& stack.is_string()
{
return stack.to_rust_string_lossy(scope);
}
value.to_rust_string_lossy(scope)
}
pub(super) fn throw_type_error(scope: &mut v8::PinScope<'_, '_>, message: &str) {
if let Some(message) = v8::String::new(scope, message) {
scope.throw_exception(message.into());
}
}