Skip to content

fix(runtime): rebase PR 1039 and preserve sub-agent transfer - #1040

Merged
yaozheng-fang merged 8 commits into
volcengine:mainfrom
FirstayZheng:fix/pr-1039-runtime-conflicts
Sep 2, 2026
Merged

fix(runtime): rebase PR 1039 and preserve sub-agent transfer#1040
yaozheng-fang merged 8 commits into
volcengine:mainfrom
FirstayZheng:fix/pr-1039-runtime-conflicts

Conversation

@FirstayZheng

Copy link
Copy Markdown
Contributor

Summary

  • Rebased the PR Fix/codex runtime critical gaps #1039 runtime fixes onto current main in a new branch.
  • Kept main branch sub-agent transfer support for codex/piagent by wiring transfer_to_agent into the updated tool loops.
  • Removed the stale compat/docs claim that sub_agents are unsupported for external runtimes and restored transfer coverage.

Tests

  • uv run pre-commit run --all-files
  • uv run pytest tests/runtime/codex tests/runtime/piagent tests/runtime/differential
  • uv run pytest -n 16

tiantt and others added 8 commits September 1, 2026 20:56
`Agent(runtime="codex"|"piagent")` replaces ADK's whole LLM flow, and a
number of things that replacement silently dropped turned out to produce
wrong answers rather than merely missing features.

Codex fidelity
- Stop passing `base_instructions` to `thread_start`. It *replaces* Codex's
  20.9KB tuned system prompt, and `Agent.name` has a default, so every codex
  agent was running without Codex's apply_patch/shell/planning guidance.
  Identity now rides `developer_instructions`, which is additive. Side
  effect: `personality` was inert whenever `base_instructions` was set and
  now takes effect.
- Replay the shim's own ADK tool calls/results into later requests of the
  same turn. Codex never sees those items and rebuilds `input` from its own
  thread, so the model previously saw a conversation in which it had never
  called the tool and re-issued it, re-running side effects.
- Gate tool advertisement and transcript replay on the agent turn. Codex's
  compaction and review passes reuse the same provider and bearer token, so
  they were being offered the agent's tools and could execute them.
- Drop the dead `disable_response_storage` key; `store:false` is
  unconditional upstream.

Correctness and budgets
- Enforce `RunConfig.max_llm_calls`: charged per backend call, surfaced to
  Codex as a typed 429 rather than a 500 it would retry, and re-raised to
  the caller on every exit path.
- Bound tool iterations per turn rather than per request (was rounds x
  budget); default raised 8 -> 32 to compensate.
- Never let `output_key` + `output_schema` abort an invocation on prose.
- piagent: the first assistant message carrying text won the whole
  invocation, so a preamble became the answer and the answering round was
  dropped.

Observability
- Emit the `call_llm` span both runtimes' telemetry chain keys off; without
  it trace dumps were empty and trace-based evaluation raised.
- Populate `usage_metadata` on one merged final event per turn, and sum
  usage across the shim's internal tool loop (only the last call counted).
- Forward `model_extra_config`, restoring Ark prompt caching and billing
  attribution.
- Propagate OTel context into the shim so ADK tool spans keep their parent.

Fail-fast instead of silent no-ops
- New `veadk/runtime/compat.py`: 8 configurations that produce wrong results
  now raise at construction and invocation; 11 that degrade gracefully warn
  once. `_run_live_impl` raises instead of silently running the ADK flow.
- `update_model()` now sets `model_name`, which the runtimes actually read.

Shim lifecycle
- Serialize cold start, bound the bind, reserve across the
  get_shim -> register_turn window, cap the cache, and map litellm errors
  correctly (429/401 were surfacing as bare 500s).

Packaging
- `[codex]` declares fastapi/uvicorn; harness image and template install it.

Tests: a differential harness runs one agent config through both `adk` and
`codex` against a shared scripted backend and compares an equivalence class
of outcomes, with meta-tests that verify the comparator can actually fail.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Follow-ups from an adversarial review of the previous commit, plus the
resolution of a design conflict two parallel changes had left in place.

- The `merge_target` fix for tool-only turns parked every partial event
  behind the held-back durable one, so a turn's final answer and a long
  command's output were delivered only after the Codex stream had already
  ended. Partials now go out immediately; overtaking is safe because they
  are never persisted, so the order among durable events -- the only order
  session history can observe -- is unchanged. `trailing_events` goes with
  it, and with it the chance of re-yielding an already-emitted prefix when
  an exception is thrown into the generator mid-flush.

- The agent-turn gate failed *open* in its fallback path: with no turn
  marker it matched any remembered user text, and a compaction pass
  re-sends the whole history, so the summarizer was handed the agent's ADK
  tools and its tool transcript. It now anchors on the last user message
  only, and deliberately does not mirror the marker path's
  `tools_advertised` arm, which without a marker would also admit a review
  pass. Losing ADK tools after a mid-turn compaction is a degradation;
  running a real tool inside a summarization is a wrong answer with side
  effects.

- `output_schema` under a non-adk runtime: two changes had shipped opposite
  resolutions -- compat refusing it outright, output_state degrading
  gracefully -- leaving ~150 lines whose only justification was a
  reachability that does not exist. Kept the refusal, since the schema
  reaches neither the backend nor the prompt; deleted the fence-extraction
  heuristic; kept a minimal guard so a future demotion cannot kill a turn
  mid-stream, and pinned the invariant with a test that fails on the
  demoter's own diff.

- An id-less Codex thread item made every later id-less tool item match the
  dedupe key and lose its `function_call`, leaving an orphan
  `function_response` in session history.

Tests: the multi-round replay fixture sent a trailing user message, which
is indistinguishable from a compaction pass -- replaced with the shape Codex
actually sends. Added the degraded fallback path's first coverage.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
All three were found by building the first examples that actually exercise
this runtime against a real model, not by tests.

- **Every codex turn 400s on Ark under VeADK's defaults.** This branch
  started forwarding `model_extra_config`, which was the right fix for
  "the user's config is silently dropped" -- but `DEFAULT_MODEL_EXTRA_CONFIG`
  carries `caching`, and Ark refuses caching on a request that also has the
  `instructions` field Codex always sends. So the fix for one silent failure
  created a loud one, on every turn, including in both pre-existing codex
  examples. Body keys the Responses transport cannot carry are now filtered;
  the attribution headers -- the valuable half -- still go through, and a
  user's own `extra_body` keys are untouched.

- **A rejected backend request read as a completed turn.** Codex treats it as
  the end of its turn and returns what it had, so the caller saw
  `status=completed`, a half-finished workspace and a plausible-sounding
  summary, while the 4xx appeared only as a warning that logged no message.
  Backend errors are now recorded on the turn state -- the runtime already
  re-raises those after the stream ends -- and the message is logged, still
  redacted.

- **`doubao-seed-1-6` could not complete a turn at all.** After its first
  tool round Codex replays its own `reasoning` items; Ark refuses them for
  that model and the turn died mid-investigation. The backend call now
  retries once without them, but only in response to that specific refusal:
  stripping them pre-emptively would trade a hard failure on a few models
  for quieter, worse answers on every model that accepts them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
VeADK had two codex examples and neither demonstrated why anyone would
choose the runtime: one was "you are a helpful assistant", the other asked
for the weather and answered in one line. Both are tasks where
`runtime="codex"` is strictly worse than `runtime="adk"` -- a subprocess
spawn per turn, the whole history re-serialized into the prompt, and a
matrix of ADK features refused -- for no benefit. No example anywhere set
`CodexRuntimeConfig` or touched the sandbox.

Two examples that do, both verified end to end against a real Ark model:

- `codex_data_analysis` leads on self-iteration. An ADK tool lands a 2,400
  row extract in the workspace; Codex writes an analysis script, crashes on
  a blank numeric cell buried at line 212, probes for bad rows, rewrites
  twice, and publishes a report whose figures match ground truth to the
  cent. Turn two reads back its own artifacts and swaps the chart. The data
  volume is load-bearing: at 40 rows the model just `cat`s the file and
  writes a correct script first try, proving nothing.

- `codex_ops_assistant` leads on the security story. Three tools land a day
  of logs, a metric series and a deploy log in the workspace; the seeded
  root cause needs a change point, a pivot, and a join across UTC and
  UTC+08:00 timestamps, behind three decoys, so a single grep cannot reach
  it. The sandbox boundary is verified from inside, not assumed. With the
  network off, the audited ADK tools are the only egress -- the model can
  chew on production logs but cannot exfiltrate them.

Both are built on the technique neither the code nor the docs stated: the
workspace is the data plane, tool arguments and results are the control
plane. Tool results are `json.dumps`-ed into the model's context and then
replayed into every later request of the turn, so a tool hands Codex a
path, never a payload.

Docs gain a "when to use the codex runtime" section (good fits, bad fits,
and the costs stated honestly), the data-plane rule, and the security
baseline as one recipe. The two existing examples now say what they are --
wiring and deployment references -- so they stop reading as endorsements.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
**A supported way for an ADK tool to find the workspace.** Tools run in the
host process, so a tool that hands Codex a file -- the correct pattern, since
tool results go through the model's context -- had no way to locate the
session's directory. Both examples had to pin `workspace_root` *and*
`reuse_workspace=True`, putting every session in one directory: fine for a
demo, wrong for the multi-tenant case they were recommending it for.
`veadk.runtime.codex.current_workspace()` now returns it, with nothing
pinned. Note the mechanism is *not* a ContextVar set in `run_async`: measured
against a real shim, that is not a miss but a cross-tenant leak -- the shim's
server task is created inside the first invocation's `get_shim`, so every
later handler task inherits that invocation's context forever, and turns B, C
and D all read turn A's workspace. The path is captured per executor in a
closure instead. The concurrency test drives the shim from a detached task
with a decoy workspace bound, because `ASGITransport` would otherwise run the
handler inline and let the broken design pass.

**piagent's tool-only turns** dropped callback `state_delta` and the turn's
`usage_metadata`, the same gap codex had. Porting it surfaced a second, live
bug: the translator's text dedup suppressed a final answer whose text matched
a preamble the same turn had already parked on a tool-call event, so a model
that says "Done." beside its tool call and "Done." again as its answer wrote
no `output_key` at all. The dedup was doing two jobs with one rule; they are
now separate.

**Shim lifecycle.** The reservation protecting the `get_shim` ->
`register_turn` window was a 60s deadline that a slow setup (MCP connect, the
reaper's rmtree pass) outlives; it is now a lease object tracked weakly, so
there is no release call to miss on any exit path and the deadline survives
only as a floor. `register_turn` consumed an arbitrary reservation rather than
its own. `_SHIMS` was coroutine-atomic but not thread-atomic, so two workers
under separate `asyncio.run` loops could orphan a bound socket for the process
lifetime. litellm's retries stay uncharged against `max_llm_calls`, matching
what ADK counts -- now stated rather than implied.

**The workspace root** was created at module import, so importing the runtime
made a temp directory that `atexit` could not reclaim after SIGKILL; five
orphans were sitting in $TMPDIR. It is lazy now.

**Codex is no longer told to use tools it cannot reach.** Its own prompt
instructs the model to use `apply_patch`, which the shim never forwards, and
`request_user_input`, which nobody can answer mid-run. Both burned a round and
both examples counter-instructed by hand; the runtime states it once.

**A green baseline.** Five tests failed locally on a missing optional extra,
which made every regression hunt start by re-deriving which failures were
"ours" -- twice on inherited and wrong claims. They skip cleanly now, still
run under CI's `--all-extras`, and gate on the specific missing import so a
real breakage cannot hide behind them. That work turned up a user-facing bug
unrelated to any of this: `sqlalchemy>=2` leaves greenlet to a platform marker
that does not cover macOS arm64, so a bare install on Apple Silicon raised
"the greenlet library is required" on the first session write. CI never saw
it -- ubuntu-latest is x86_64. Pinning `sqlalchemy[asyncio]` fixes it.

**The codex-smoke CI job could go green without running anything** -- a skip
exits 0, so a runner that quietly lost the Codex binary told you nothing,
which is the one thing that job exists to detect. It now fails unless the test
actually ran.

`_merge_turn_bookkeeping` was duplicated in both runtimes; it moves to
`model_callbacks.py`, which owns both producers of the bookkeeping it folds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@yaozheng-fang
yaozheng-fang merged commit 7b3258f into volcengine:main Sep 2, 2026
11 checks passed
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.

3 participants