Skip to content

feat: support code interpreter as gateway execution tool - #368

Open
haoshan98 wants to merge 7 commits into
vllm-project:mainfrom
EmbeddedLLM:serverless-code-interpreter
Open

haoshan98 wants to merge 7 commits into
vllm-project:mainfrom
EmbeddedLLM:serverless-code-interpreter

Conversation

@haoshan98

@haoshan98 haoshan98 commented Sep 23, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

  • Add an opt-in, gateway-executed code_interpreter tool backed by Eryx 0.8.
  • Require the explicit request shape {"type":"code_interpreter","execution":"gateway"} and normalize it to a strict model-facing function with one code argument.
  • Integrate execution with the existing tool registry, scheduler, multi-round loop, typed output-item ingestion, and HTTP/SSE and WebSocket delivery paths.
  • Emit the OpenAI-compatible code_interpreter_call lifecycle for gateway execution while preserving native upstream code-interpreter events.
  • Fail closed before inference unless the binary includes embedded-code-interpreter, the operator enables the executor, and the embedded runtime passes startup checks.
  • Add operator-controlled source, wall-time, fuel, guest-memory, retained-output, concurrency, and aggregate admission limits. Each execution uses a fresh sandbox, and cancellation retains its admission permit until Eryx terminates.
  • Add scripts/setup-eryx-runtime.sh to install the locked eryx-precompile version and prepare the platform-specific runtime required by feature-enabled source builds.
  • Add typed request/output/event models, OpenAPI coverage, configuration documentation, setup guidance, and recorder-generated OpenAI/gateway characterization cassettes.

The Cargo feature and operator setting remain disabled by default. Eryx 0.8.0 still accumulates complete output internally and inherits raw WASI stdout/stderr, so the gateway’s retained-output limits are not hard host-memory or host-output bounds. The design document records this containment limitation and the requirements for distributing feature-enabled production artifacts.

Test Plan

  • cargo fmt -- --check
  • bash scripts/tests/setup-eryx-runtime-test.sh
  • cargo test -p agentic-server-core code_interpreter --no-fail-fast
  • cargo test -p agentic-server-core --test code_interpreter_characterization_test
  • cargo test -p agentic-server code_interpreter --no-fail-fast
  • Verified fail-closed HTTP and WebSocket handling before upstream inference.
  • Verified native upstream code_interpreter_call ingestion and pass-through.
  • Verified non-streaming, HTTP/SSE, and WebSocket lifecycle parity from recorder-generated cassettes.
  • The feature-gated real-Eryx test was not rerun in the final environment because an Eryx 0.8.0 runtime.cwasm artifact was not locally available.

Signed-off-by: haoshan98 <haoshanw@gmail.com>
Signed-off-by: haoshan98 <haoshanw@gmail.com>
Signed-off-by: haoshan98 <haoshanw@gmail.com>
Signed-off-by: haoshan98 <haoshanw@gmail.com>

@maralbahari maralbahari left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for supporting this feature, it's really helpful and it follows the repo architecture nicely, cassettes included. Left some inline comments.

One general thing: the pluggable sandbox. In #308 (comment) we agreed this should be pluggable, but right now Eryx isn't a provider behind an interface, it is the implementation. A second sandbox (E2B, Firecracker, a container runner) means writing a whole new GatewayExecutor and redoing the OpenAI projection.

web_search already has the shape for this: the provider shapes the request and normalizes the response, the handler owns concurrency and the model-facing output. Same split here:

pub(crate) trait CodeInterpreterProvider: Debug + Send + Sync {
    fn execute<'a>(
        &'a self,
        code: &'a str,
        limits: &'a ExecutionLimits,
    ) -> Pin<Box<dyn Future<Output = Result<ExecutionOutput, ToolError>> + Send + 'a>>;

    fn max_concurrent_executions(&self) -> Option<NonZeroUsize> { None }
    fn check_ready(&self) -> Result<(), ToolError>;
}

ExecutionOutput { status, stdout, stderr } already exists in the PR, so it's mostly moving code: plan_gateway_events / public_output / output_item onto the handler, Eryx into eryx.rs behind the feature.

This also cleans up the #[cfg]. Most of the 25 sites only exist because the field is typed on the concrete Eryx type. Use a dyn alias like WebSearchExecutor and 21 of them go away, leaving only mod embedded, its re-export, and the one construction site.

if you'd rather land the feature first. Your call.

Comment thread crates/agentic-server-core/src/types/tools/params.rs
Comment thread crates/agentic-server-core/src/tool/code_interpreter.rs
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum CodeInterpreterCallOutput {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This enum is missing OpenAI's image output variant, and because it is closed for deserialization the gap is not just a feature gap: it silently drops items on the native-upstream path.

OpenAI's code_interpreter_call.outputs is a union of {"type": "logs", "logs": ...} and {"type": "image", "url": ...}. Only Logs is modeled here, so any call that produces a plot or image artifact returns nothing visible. docs/design/tool-framework.md lists this as known future work, which is fair for a first cut given the Eryx executor only captures stdout/stderr.

The part that is not just future work is the ingestion path. docs/design/embedded-code-interpreter.md states:

If an upstream provider emits a native code_interpreter_call, typed ingestion validates and assembles it before the dispatcher sends its frames through the ordinary wire-restoration path without suppressing them.

That claim does not hold when the upstream item carries an image output. Walking it through:

  1. OutputItem has #[serde(other)] Unknown, but with #[serde(tag = "type")] that only catches an unrecognized tag value. The tag code_interpreter_call is recognized, so serde commits to CodeInterpreterCall and the inner failure is not caught by Unknown.
  2. slot.rs::complete parses via deserialize_from_value_opt::<OutputItem>(raw_item), which returns None on failure. The reasoning-specific fallback below it does not apply.
  3. ActiveItem::from_payload creates CodeInterpreterCall { item: None } and never populates it, since merge_done only fires on Some(OutputItem::CodeInterpreterCall(done)).
  4. ActiveItem::finalize is Self::CodeInterpreterCall { item } => OutputItem::CodeInterpreterCall(item?), so it returns None.

Net effect: the output item is dropped from the response with no error and no log. The model's code call vanishes from output[]. This is a silent-data-loss failure mode rather than a rejection, which is the harder kind to debug in production.

Suggested change: add the Image { url: String } variant now. It is a two-line addition to a #[serde(tag = "type")] enum, it makes the wire contract complete, and it removes the drop path. Worth a test that a native upstream code_interpreter_call carrying {"type": "image", ...} survives ingestion and appears in output[].

Separately, consider whether this enum should stay closed at all. OutputItem itself carries an Unknown fallback precisely so an unmodeled upstream shape degrades instead of disappearing. Applying the same policy here, or at minimum logging when a known-tag output item fails to parse in slot.rs, would make future OpenAI additions to this union non-breaking.

#[cfg(not(feature = "embedded-code-interpreter"))]
Self::CodeInterpreter(_) => {
tracing::debug!("code_interpreter tool skipped in normalize - handler not yet registered");
tracing::debug!("code_interpreter tool cannot normalize without an available handler");

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This second arm is redundant. One tool type should have one entry in this match.

CodeInterpreterHandler and its ToolHandler impl live outside the #[cfg(feature = "embedded-code-interpreter")] mod embedded block in tool/code_interpreter.rs, so CodeInterpreterHandler.normalize(param) compiles in a default build too. The cfg split is not needed to make it build.

It is also unreachable. In a default build a code_interpreter declaration is already rejected before normalization, twice:

  • registry.rs::insert_code_interpreter_entry returns code_interpreter_unavailable_error() under cfg(not(...)).
  • GatewayExecutors::validate_declarations returns the same error.

Both run before to_upstream_request calls to_function_tools, so this arm never executes and its tracing::debug! never fires.

Collapsing it also makes the arm consistent with web_search right above, which normalizes unconditionally and leaves availability to the executor (WebSearchHandler::unavailable()):

Self::WebSearch(_) => vec![web_search_function_tool()],
Self::CodeInterpreter(param) => CodeInterpreterHandler.normalize(param),

Suggested change: delete lines 109-113 and drop the #[cfg(feature = "embedded-code-interpreter")] on line 107, leaving one arm.

Note this also affects the two tests at the bottom of the file. unavailable_code_interpreter_does_not_create_a_model_visible_function asserts to_function_tools().is_empty() and would no longer hold, so it should be dropped and enabled_code_interpreter_exposes_its_fixed_function_contract un-gated to run in both builds. The fail-closed behavior it was guarding is already covered by build_with_handlers_rejects_unavailable_code_interpreter_before_entry_creation and request_validation_rejects_code_interpreter_without_a_ready_executor, which test it at the layer that actually enforces it.

}

#[cfg(feature = "embedded-code-interpreter")]
mod embedded {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Per #308 (comment), this needs to be pluggable. Right now Eryx is not a provider behind an interface, it is the implementation: there is no sandbox trait, so a second backend means writing a whole new GatewayExecutor and re-deriving the OpenAI projection by hand.

Suggest mirroring web_search, where WebSearchProvider shapes requests and normalizes responses while the handler owns concurrency and the model-facing output shape:

tool/code_interpreter/
  mod.rs        CodeInterpreterHandler: ToolHandler + GatewayExecutor.
                Owns admission, output bounding, and the public
                code_interpreter_call item + SSE lifecycle.
  provider.rs   The trait below.
  eryx.rs       EryxProvider, #[cfg(feature = "embedded-code-interpreter")].
/// A sandbox backend behind `code_interpreter`.
///
/// Implementations run one program under `limits` and normalize the result;
/// the handler owns admission, output bounding, and the public output shape.
/// Cancellation is signalled by dropping the returned future.
pub(crate) trait CodeInterpreterProvider: Debug + Send + Sync {
    fn execute<'a>(
        &'a self,
        code: &'a str,
        limits: &'a ExecutionLimits,
    ) -> Pin<Box<dyn Future<Output = Result<ExecutionOutput, ToolError>> + Send + 'a>>;

    /// Backend-imposed ceiling on concurrent executions, if any.
    fn max_concurrent_executions(&self) -> Option<NonZeroUsize> { None }

    /// Startup readiness probe (Eryx builds one sandbox; a remote backend
    /// would ping its control plane).
    fn check_ready(&self) -> Result<(), ToolError>;
}

ExecutionOutput { status, stdout, stderr } already exists in this PR and is the right neutral boundary type. Pin<Box<dyn Future>> rather than async_trait, to match WebSearchProvider and GatewayExecutor.

Two follow-ons this implies:

  • Move plan_gateway_events, public_output, and output_item off EryxCodeInterpreterExecutor onto CodeInterpreterHandler, so backends cannot drift from the OpenAI contract independently.
  • Add a provider selector to CodeInterpreterRuntimeConfig (like WebSearchProviderKind) and split the wasmtime-specific keys (max_fuel) into an Eryx sub-config, leaving the neutral limits at the top. Marking the struct #[non_exhaustive] as WebSearchProviderConfig already is would keep later additions non-breaking.

The Cargo feature gate is a separate axis and should stay: keep eryx.rs gated, keep the trait and handler unconditional. That also removes the #[cfg(...)] branching currently spread across normalize.rs, registry.rs, and executors.rs, since availability becomes "is a provider registered", the way WebSearchHandler::unavailable() already works.

if let Some(
mut item @ (OutputItem::Reasoning(_)
| OutputItem::FunctionCall(_)
| OutputItem::CodeInterpreterCall(_)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I left a comment in line 342 that is related to this.

Comment thread crates/agentic-server-core/src/executor/accumulator/slot.rs
Signed-off-by: haoshan98 <haoshanw@gmail.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Unresolved timeout, fail-closed, sandbox ownership, native event preservation, and history rehydration issues remain.

Get a fresh assessment by requesting another Copilot review.

Review effort: Lite
Findings: 1 High severity · 1 Medium severity · 1 Low severity

Open (3)
What changed in this PR

Adds an opt-in, Eryx-backed gateway code_interpreter with sandboxed execution, lifecycle events, streaming support, configuration, documentation, and tests.

Changes:

  • Adds strict gateway tool normalization and execution controls.
  • Integrates HTTP/SSE, WebSocket, scheduling, and native event handling.
  • Adds runtime setup, OpenAPI coverage, CI, and characterization fixtures.
File Summary
scripts/​tests/​setup-eryx-runtime-test.sh Tests runtime setup behavior.
scripts/​setup-eryx-runtime.sh Provisions the Eryx runtime.
rust-toolchain.toml Pins the Rust toolchain.
README.md Documents opt-in setup.
mkdocs.yaml Registers design documentation.
docs/​design/​tool-framework.md Updates tool architecture.
docs/​design/​embedded-code-interpreter.md Documents implementation and limitations.
docs/​design/​codex-integration.md Documents tool integration.
crates/​agentic-server/​tests/​responses_websocket_test.rs Tests WebSocket fail-closed behavior.
crates/​agentic-server/​tests/​responses_test.rs Tests HTTP validation.
crates/​agentic-server/​src/​openapi.rs Adds OpenAPI models.
crates/​agentic-server/​src/​main.rs Parses interpreter configuration.
crates/​agentic-server/​src/​config_file.rs Adds file-based configuration.
crates/​agentic-server/​Cargo.toml Adds feature forwarding.
crates/​agentic-server-core/​tests/​support/​mod.rs Supports new output items.
crates/​agentic-server-core/​tests/​code_interpreter_characterization_test.rs Validates recorded contracts.
crates/​agentic-server-core/​tests/​cassettes/​record_code_interpreter_cassettes.sh Records characterization cassettes.
crates/​agentic-server-core/​tests/​cassettes/​README.md Documents cassette workflows.
crates/​agentic-server-core/​tests/​cassettes/​code_interpreter/​prompts.txt Defines test prompts.
crates/​agentic-server-core/​tests/​cassettes/​code_interpreter/​openai_tools.json Defines OpenAI fixtures.
crates/​agentic-server-core/​tests/​cassettes/​code_interpreter/​gateway_tools.json Defines gateway fixtures.
crates/​agentic-server-core/​tests/​cassettes/​code_interpreter/​code-interpreter-openai-reference-gpt-5.6-streaming.yaml Stores streaming reference behavior.
crates/​agentic-server-core/​tests/​cassettes/​code_interpreter/​code-interpreter-openai-reference-gpt-5.6-nonstreaming.yaml Stores non-streaming reference behavior.
crates/​agentic-server-core/​tests/​cassettes/​code_interpreter/​code-interpreter-gateway-Qwen-Qwen3.6-35B-A3B-nonstreaming.yaml Stores gateway behavior.
crates/​agentic-server-core/​src/​types/​tools/​params.rs Defines strict request parameters.
crates/​agentic-server-core/​src/​types/​tools/​mod.rs Exports tool types.
crates/​agentic-server-core/​src/​types/​tools/​code_interpreter.rs Validates tool arguments.
crates/​agentic-server-core/​src/​types/​request_response.rs Updates request fixtures.
crates/​agentic-server-core/​src/​types/​mod.rs Re-exports new types.
crates/​agentic-server-core/​src/​types/​io/​output.rs Adds output-item integration.
crates/​agentic-server-core/​src/​types/​io/​mod.rs Exports IO models.
crates/​agentic-server-core/​src/​types/​io/​code_interpreter.rs Defines output and event models.
crates/​agentic-server-core/​src/​tool/​registry.rs Registers gateway bindings.
crates/​agentic-server-core/​src/​tool/​ownership.rs Updates ownership documentation.
crates/​agentic-server-core/​src/​tool/​normalize.rs Normalizes gateway declarations.
crates/​agentic-server-core/​src/​tool/​mod.rs Exposes tool modules.
crates/​agentic-server-core/​src/​tool/​handler.rs Updates handler contracts.
crates/​agentic-server-core/​src/​tool/​executors.rs Adds readiness-gated executors.
crates/​agentic-server-core/​src/​tool/​code_interpreter.rs Implements Eryx execution and admission control.
crates/​agentic-server-core/​src/​lib.rs Re-exports public APIs.
crates/​agentic-server-core/​src/​executor/​response_budget.rs Accounts for retained output.
crates/​agentic-server-core/​src/​executor/​request.rs Updates configuration errors.
crates/​agentic-server-core/​src/​executor/​rehydrate.rs Validates inherited declarations.
crates/​agentic-server-core/​src/​executor/​pipeline/​tests.rs Tests native lifecycle handling.
crates/​agentic-server-core/​src/​executor/​gateway.rs Emits gateway lifecycle events.
crates/​agentic-server-core/​src/​executor/​gateway_accumulator.rs Allocates event sequences.
crates/​agentic-server-core/​src/​executor/​accumulator/​tests.rs Tests output accumulation.
crates/​agentic-server-core/​src/​executor/​accumulator/​slot.rs Supports code-call slots.
crates/​agentic-server-core/​src/​executor/​accumulator/​completion.rs Merges completed calls.
crates/​agentic-server-core/​src/​executor/​accumulator/​active.rs Tracks active calls.
crates/​agentic-server-core/​src/​events/​validate.rs Validates lifecycle events.
crates/​agentic-server-core/​src/​events/​types.rs Adds event and item variants.
crates/​agentic-server-core/​src/​events/​normalize.rs Normalizes event payloads.
crates/​agentic-server-core/​src/​config.rs Adds runtime limits.
crates/​agentic-server-core/​Cargo.toml Adds optional Eryx dependencies.
Cargo.toml Adds workspace dependencies.
ARCHITECTURE.md Updates architecture guidance.
.rust-file-sizes.json Records file-size allowances.
.github/​workflows/​rust.yml Adds feature-enabled CI.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +501 to +506
let mode = std::fs::metadata(temp_dir)
.map_err(|_| ToolError::Config("code interpreter TMPDIR cannot be inspected".to_owned()))?
.permissions()
.mode()
& 0o777;
if mode != 0o700 {
Self::McpListTools(list_tools) => Some(InputItem::McpListTools(list_tools.clone())),
Self::Compaction(item) => Some(InputItem::Compaction(item.clone())),
Self::WebSearchCall(_) | Self::McpCall(_) | Self::Unknown => None,
Self::CodeInterpreterCall(_) | Self::WebSearchCall(_) | Self::McpCall(_) | Self::Unknown => None,
Comment thread .rust-file-sizes.json
Comment on lines +32 to +34
"crates/agentic-server-core/src/tool/code_interpreter.rs": {
"limit": 529,
"reason": "The dedicated Eryx executor keeps sandbox admission, cancellation, bounded output capture, and gateway execution lifecycle together."
.with_execution_timeout(config.execution_wall_time)
.with_max_memory_bytes(memory)
.with_max_fuel(config.max_fuel.get());
let builder = Sandbox::embedded()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sandbox::embedded() still permits raw WASI stdout/stderr and Eryx accumulates complete output internally, so OutputHandler limits do not bound host output or memory. a code-interpreter call can therefore exhaust the gateway or its log sink despite the configured limits. we need to keep this backend unavailable for production until output is bounded at the WASI boundary or by an equivalent external sandbox limit.

Signed-off-by: haoshan98 <haoshanw@gmail.com>
Signed-off-by: haoshan98 <haoshanw@gmail.com>
Self::McpListTools(list_tools) => Some(InputItem::McpListTools(list_tools.clone())),
Self::Compaction(item) => Some(InputItem::Compaction(item.clone())),
Self::WebSearchCall(_) | Self::McpCall(_) | Self::Unknown => None,
Self::CodeInterpreterCall(_) | Self::WebSearchCall(_) | Self::McpCall(_) | Self::Unknown => None,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

native code_interpreter_call items still disappear on continuation. OutputItem::to_input_item() returns None for every code-interpreter item, but only gateway-generated calls have the normalized function call and output persisted separately. an upstream-native call therefore loses its context on a later previous_response_id or conversation turn. we should preserve native items through explicit origin-aware history handling.

@franciscojavierarceo franciscojavierarceo left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the latest patch fixes the done-only ID and public ID-format issues, but the remaining blockers are still unresolved: Eryx can bypass the configured output limits through raw WASI stdout/stderr; native code_interpreter_call items are dropped from continuation history; native image outputs are not represented and can disappear during ingestion; and TMPDIR validation does not reject symlinks or verify ownership. we need to close those paths before this is ready to merge.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[RFC]: Support Serverless Code Interpreter

4 participants