DEV Community

Masih Maafi
Masih Maafi

Posted on Originally published at masihmoafi.com Fully Autonomous

How Codex and Elpis work under the hood

A coding agent is a loop around a list of messages. This post follows one prompt
through the Rust code of Codex CLI, the foundation Elpis is forked from, and marks the
exact places where Elpis hooks in.

Code is quoted verbatim from commit e2efa7a5. Paths are relative to
codex-rs/. Nothing here was run for this post; it is read from the source.

How to tell Elpis code from Codex code. All of codex-rs arrived as one import of
openai/codex at revision 2e1607ee (ELPIS_UPSTREAM.md), committed as f37fc774.
A file that exists at that commit is Codex's; one that does not was written by Elpis:

git cat-file -e f37fc774:codex-rs/core/src/session/turn.rs   # exists  -> Codex's
git cat-file -e f37fc774:codex-rs/core/src/smart_prune.rs    # missing -> Elpis's
Enter fullscreen mode Exit fullscreen mode

1. Two queues, and typing while it works

The UI never calls the agent. It pushes a Submission { id, op } into a bounded
channel (capacity 512) and reads Event { id, msg } from an unbounded one
(both types are in protocol/src/protocol.rs; the channels are made in
Session::spawn). One background task,
submission_loop, routes each Op. Interrupts and approvals are handled inline, which
is how an approval can reach a turn that is already running.

Typing while the agent works does not start a second turn. user_input_or_turn_inner
first tries steer_input, and only spawns a new RegularTask if that fails with
NoActiveTurn:

// core/src/session/handlers.rs
match sess
    .steer_input(
        items.clone(),
        additional_context.clone(),
        /*expected_turn_id*/ None,
        client_user_message_id.clone(),
        responsesapi_client_metadata.clone(),
    )
    .await
{
    Ok(_) => {
        current_context.session_telemetry.user_prompt(&items);
    }
    Err(SteerInputError::NoActiveTurn(items)) => {
Enter fullscreen mode Exit fullscreen mode

Your message is seen at the next sampling step; tool calls already in flight finish
first.

2. What the model actually receives

Nothing is stored server-side. The request sets store to true only for Azure
in core/src/client.rs:

// core/src/client.rs
parallel_tool_calls: prompt.parallel_tool_calls && !model_info.use_responses_lite,
reasoning: Some(reasoning),
store: provider.is_azure_responses_endpoint(),
stream: true,
Enter fullscreen mode Exit fullscreen mode

The whole history, including encrypted reasoning items, is re-sent on every request of
the loop.

That is only affordable because of the prompt cache, and it explains a design choice:
context is append-only. On the first turn (and right after compaction) Codex builds the
full context, meaning developer messages for permissions, collaboration mode and
skills, plus one contextual user message holding instructions and environment. On later
turns it appends only diffs (record_context_updates_and_set_reference_context_item
in core/src/session/mod.rs). Each fragment is
wrapped in text markers (# AGENTS.md instructions ... </INSTRUCTIONS>) so the code
can find and replace it later (context-fragments/src/fragment.rs).

Before every request the history is normalized: a tool call with no output gets an
"aborted" output, and an output whose call is gone is dropped:

// core/src/context_manager/normalize.rs
for (idx, item) in items.iter().enumerate() {
    match item {
        ResponseItem::FunctionCall { id, call_id, .. }
            if !function_output_ids.contains(call_id.as_str()) =>
        {
            info!("Function call output is missing for call id: {call_id}");
            missing_outputs_to_insert.push((
                idx,
                ResponseItem::FunctionCallOutput {
                    id: synthetic_output_id("fco", id.as_deref()),
                    call_id: call_id.clone(),
                    output: FunctionCallOutputPayload::from_text("aborted".to_string()),
Enter fullscreen mode Exit fullscreen mode

The API rejects unpaired calls, and an interrupt can leave one behind.

3. The loop

Flowchart of one turn: a submission either steers the running turn or starts a new one, then the loop compacts if needed, records context, streams the model's reply, runs tool calls, and repeats until no follow-up is needed.

Diagram, continued (part 2 of 3)

Diagram, continued (part 3 of 3)

The stream is parsed into ResponseEvents. Text deltas go straight to the UI, but an
item only enters history when it is done (handle_output_item_done in
core/src/stream_events_utils.rs).

If the item is a tool call, the call is recorded at once and its execution is pushed
onto a FuturesOrdered, so tools start running while the model is still streaming. An
RwLock decides concurrency: tools that support parallelism take a read lock, the rest
take the write lock and run alone:

// core/src/tools/parallel.rs
let _guard = if supports_parallel {
    Either::Left(lock.read().await)
} else {
    Either::Right(lock.write().await)
};
Enter fullscreen mode Exit fullscreen mode

After Completed, the outputs are drained and recorded in call order, which keeps
the history deterministic (drain_in_flight in core/src/session/turn.rs).

The turn continues while needs_follow_up holds: there were tool calls, or the server
said end_turn: false, or you steered mid-turn. Otherwise Stop hooks run, and can
block the stop by injecting a continuation prompt. There is no planner and no state
machine underneath. "Agentic" is this inner loop.

4. Running a command

Flowchart of how a tool call runs: routing and hooks, then the shell, apply_patch or MCP path, execution policy, approval, the sandbox attempt with a possible unsandboxed retry, and output capping.

Diagram, continued (part 2 of 2)

Routing. A FunctionCall becomes JSON arguments; a CustomToolCall (apply_patch
uses this) becomes free text. An unknown tool name is returned to the model as an error,
not a crash. Hooks wrap every tool, built-in or MCP, at one choke point
(build_tool_call in core/src/tools/router.rs, dispatch_any_with_terminal_outcome
in registry.rs).

Policy. Rules are Starlark prefix_rule(pattern=[...], decision=allow|prompt|forbidden)
files, loaded per config layer (load_exec_policy in core/src/exec_policy.rs). A
bash -lc script is split into its commands, each is matched, and the strictest
decision wins, because the decisions are ordered Allow < Prompt < Forbidden:

// execpolicy/src/decision.rs
#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Decision {
    /// Command may run without further approval.
    Allow,
    /// Request explicit user approval; rejected outright when running with `approval_policy="never"`.
    Prompt,
    /// Command is blocked without further consideration.
    Forbidden,
}
Enter fullscreen mode Exit fullscreen mode

The derived Ord follows declaration order, so from_matches in
execpolicy/src/policy.rs just takes the .max(). Commands that match no rule fall to heuristics: a
known-safe list, a dangerous-command check, and the approval policy.

One choke point. Shell, unified exec and apply_patch all go through
ToolOrchestrator::run: approval first, then a sandbox, then a possible retry
(core/src/tools/orchestrator.rs). Approval is decided by a hook, then by the
Guardian (an automated reviewer agent) if configured, then by you
(core/src/tools/approvals.rs).

The sandbox, on Linux. The command is prefixed with codex-linux-sandbox. It builds
a bubblewrap jail (--new-session --die-with-parent --unshare-user --unshare-pid,
--unshare-net when the network is off, the root mounted read-only, writable roots
bound back in), then re-execs inside it, sets PR_SET_NO_NEW_PRIVS, and installs a
seccomp filter that blocks ptrace, process_vm_* and io_uring, plus network
syscalls in restricted mode (linux-sandbox/src/bwrap.rs, linux_run_main.rs). The
blocked syscalls are listed plainly:

// linux-sandbox/src/landlock.rs
deny_syscall(&mut rules, libc::SYS_ptrace);
deny_syscall(&mut rules, libc::SYS_process_vm_readv);
deny_syscall(&mut rules, libc::SYS_process_vm_writev);
deny_syscall(&mut rules, libc::SYS_io_uring_setup);
deny_syscall(&mut rules, libc::SYS_io_uring_enter);
deny_syscall(&mut rules, libc::SYS_io_uring_register);
Enter fullscreen mode Exit fullscreen mode

Landlock is only a legacy fallback behind features.use_legacy_landlock.

Limits. A command times out after 10 seconds by default and its whole process group
is killed (DEFAULT_EXEC_COMMAND_TIMEOUT_MS in core/src/exec.rs). At most 1 MiB of output is kept in memory, and the
model sees it trimmed again by the model's truncation policy.

apply_patch is a custom diff format with no line numbers. Hunks are found by
matching context in seek_sequence, which tries an exact match, then ignoring trailing
whitespace, then ignoring all surrounding whitespace, and last of all treating typographic
dashes and quotes as their ASCII twins, so small drift in model output still applies.
The first three comparisons, one per pass:

// apply-patch/src/seek_sequence.rs
    if lines[i..i + pattern.len()] == *pattern {
// …
// Then rstrip match.
// …
        if lines[i + p_idx].trim_end() != pat.trim_end() {
// …
// Finally, trim both sides to allow more lenience.
// …
        if lines[i + p_idx].trim() != pat.trim() {
Enter fullscreen mode Exit fullscreen mode

The patch is checked against the real filesystem before approval is decided, and it is
applied through a sandboxed filesystem helper, not a shell (handle_call in
core/src/tools/handlers/apply_patch.rs). Models often run apply_patch through the
shell anyway, so run_exec_like detects that and reroutes it
(core/src/tools/handlers/shell.rs).

MCP tools never touch the OS sandbox: the server process is launched with
sandbox: None, and approval comes from the server's own destructive/read-only
annotations:

// core/src/mcp_tool_call.rs
let destructive_hint = annotations.and_then(|annotations| annotations.destructive_hint);
if destructive_hint == Some(true) {
    return true;
}

let read_only_hint = annotations
    .and_then(|annotations| annotations.read_only_hint)
    .unwrap_or(false);
if read_only_hint {
    return false;
}
Enter fullscreen mode Exit fullscreen mode

Skills are not tools at all; a mentioned skill's SKILL.md is injected as context.

5. When the window fills

The context size Codex acts on is part measurement and part guess: the server's total
from the last request, plus a 4-bytes-per-token estimate for everything added since
(get_total_token_usage in core/src/context_manager/history.rs).

Auto-compaction triggers at 90% of the model's window:

// protocol/src/openai_models.rs
pub fn auto_compact_token_limit(&self) -> Option<i64> {
    let context_limit = self
        .resolved_context_window()
        .map(|context_window| (context_window * 9) / 10);
    let config_limit = self.auto_compact_token_limit;
    if let Some(context_limit) = context_limit {
        return Some(
            config_limit.map_or(context_limit, |limit| std::cmp::min(limit, context_limit)),
        );
    }
    config_limit
}
Enter fullscreen mode Exit fullscreen mode

It is checked before a turn and, mid-turn, when a follow-up is needed. The local backend
sends the history plus a "context checkpoint" prompt as an ordinary request. The
replacement history is the most recent user messages that fit in 20k tokens plus one
user-role message holding the summary:

// core/src/compact.rs
if max_tokens > 0 {
    let mut remaining = max_tokens;
    for message in user_messages.iter().rev() {
// …
history.push(ResponseItem::Message {
    id: None,
    role: "user".to_string(),
    content: vec![ContentItem::InputText { text: summary_text }],
    phase: None,
    internal_chat_message_metadata_passthrough: None,
});
Enter fullscreen mode Exit fullscreen mode

Your messages are walked newest first, and the summary goes in with role: "user". Every tool call
and every assistant message is gone.
The model does not remember its own earlier
outputs; it reads a summary of them. For OpenAI and Azure the default backend is
instead a server-side compaction that returns an opaque encrypted item.

Each thread appends to sessions/YYYY/MM/DD/rollout-<timestamp>-<id>.jsonl under
CODEX_HOME, which Elpis defaults to ~/.elpis. Resume reads that file backwards
until it reaches the last compaction checkpoint, which carries the full replacement
history, and replays only the tail (reconstruct_history_from_rollout in
core/src/session/rollout_reconstruction.rs).

6. Where Elpis hooks in

Diagram of where Elpis hooks into Codex: the TUI writes admission state that filters the request, requests go through a provider adapter, and tool outputs pass through Smart Prune with an audit record before entering history; memory saves are a separate tool.

Diagram, continued (part 2 of 2)

Smart Prune is one call inserted into the loop. Codex collects finished tool
outputs and records them into history. Elpis added optimize_pending_outputs between
those two steps:

// core/src/session/turn.rs
let pending_outputs = super::smart_prune::optimize_pending_outputs(
    &sess,
    &turn_context,
    pending_outputs,
    cancellation_token,
)
.await;
for pending_output in pending_outputs {
    let response_item = pending_output.response.into();
    sess.record_conversation_items(&turn_context, std::slice::from_ref(&response_item))
        .await;
}
Enter fullscreen mode Exit fullscreen mode

Because it acts before a result is
first admitted, the existing prefix is never rewritten and the prompt cache stays valid;
the older force-prune rewrites history and discards it.

  • Eligibility. A result needs at least 1,024 estimated tokens, a batch stays under 24,000, and a result is only replaced if that saves at least 256 tokens and at least 20%. Runtime failures and hook or policy feedback are never touched, so control text stays exact. The batch cap is MAX_PRUNE_BATCH_TOKENS in context_pruner.rs; the other three floors are here:
// core/src/smart_prune.rs
pub(crate) const MIN_SOURCE_TOKENS: usize = 1_024;
pub(crate) const MIN_SAVED_TOKENS: usize = 256;
pub(crate) const MIN_SAVINGS_PERCENT: usize = 20;
Enter fullscreen mode Exit fullscreen mode
  • The optimizer call. One extra request with its own system prompt, Low reasoning effort, a 180-second timeout and a strict JSON schema. On the OpenAI provider the default optimizer is gpt-5.6-luna (run_model_admission and selected_model_slug in core/src/session/smart_prune.rs).
  • The contract. Every call ID must appear exactly once, as compact with content or unchanged with none. An unknown, duplicate or missing ID rejects the entire batch. A kept result ends with a pointer, exact_source=smart-prune://<admission>/<call> source_sha256=…, back to the original. In parse_decision_manifest, every return None throws the whole batch away:
// core/src/smart_prune.rs
let mut by_id = HashMap::with_capacity(parsed.items.len());
for item in parsed.items {
    if !expected.contains(item.call_id.as_str()) || by_id.contains_key(&item.call_id) {
        return None;
    }
    let decision = match (item.decision, item.content) {
        (RawDecisionKind::Compact, Some(content)) if !content.trim().is_empty() => {
// …
        (RawDecisionKind::Unchanged, None) => AdmissionDecision::Unchanged {
// …
        _ => return None,
Enter fullscreen mode Exit fullscreen mode
  • Audit first. The record is written to a .pending-* directory, fsynced, and renamed into place. Only then is the shortened result recorded (write_admission in core/src/session/smart_prune_audit.rs).
  • Fail open. Any failure keeps the original. A failed batch also switches Smart Prune off for the rest of that turn (record_batch_failure in core/src/session/smart_prune.rs), and a toggle takes effect on the next turn, because the flag is copied into the turn once at its start.

The Context Ledger is a TOML file. admission.toml lives under
<elpis_home>/context/workspaces/<workspace>/ and records which sources are admitted:
AGENTS.md (global and project), GOAL.md, ES.md, MEMORY.md and the dev rule files
(core/src/elpis_context.rs). Excluding one changes the request through two gates.
Continuity files go through ElpisContinuityExtension, which emits a single developer
fragment headed ## Elpis Admitted Context and only re-emits it when the text changes.
AGENTS.md is already sent by Codex, so Elpis patched agents_md_manager to filter it,
and the cache key includes an admission fingerprint so a toggle applies on the very next
request (contribute_world_state in app-server/src/extensions.rs, refresh in
core/src/agents_md_manager.rs). There is no protocol call for this: the TUI writes
admission.toml itself through core (set_context_source_admitted in
tui/src/chatwidget/context_ledger.rs) and the app-server re-reads it each turn.

Memory is one more tool. save_memory is registered only when the turn began with a
baseline snapshot of MEMORY.md and ES.md, and only for the root agent. It applies
exact append, replace or remove edits, refuses to write if either file changed since
the turn began, and writes a receipt marked prepared, then MEMORY.md, then ES.md
(rolled back if that fails), then marks the receipt committed
(commit_update in core/src/memory_save.rs).

Providers are an adapter. At import, WireApi had one variant, Responses. Elpis
added Anthropic Messages, Gemini and Chat Completions (the WireApi enum in
model-provider-info/src/lib.rs). Every non-Responses provider goes through one
function that translates the Responses-shaped request into the provider's format and
translates the stream back into ResponseEvents (stream_native_api in
core/src/client.rs, and core/src/chat_completions.rs). The
turn loop, tools, history and pruning still only see Responses items, which is why
swapping providers touches so little.

Work graphs. The enable_fanout flag is not an Elpis flag: it is Codex's own
SpawnCsv feature, under development and off by default (features/src/lib.rs).
Elpis's run_agent_work_graph is registered beside the CSV fan-out tool. A graph is
validated before anything is stored, and a topological sort rejects cycles. A task is
ready when every dependency has succeeded, and a task only counts as done if its
report passes an evidence gate: files outside its scope, a changed_files list that
disagrees with the engine's own before/after snapshot, or empty evidence are all
rejected (report_work_task in core/src/tools/handlers/work_graphs.rs). Only one
writable task runs per environment at a time, whatever their scopes
(tasks_have_write_conflict in the same file).

7. Things you would not guess

  • Under the OnRequest approval policy, a command that fails inside the sandbox is not retried outside it. The model has to ask for escalation up front:
// core/src/tools/sandboxing.rs
fn wants_no_sandbox_approval(&self, policy: AskForApproval) -> bool {
    match policy {
        AskForApproval::UnlessTrusted => true,
        AskForApproval::Never => false,
        AskForApproval::OnRequest => false,
        AskForApproval::Granular(granular_config) => granular_config.sandbox_approval,
    }
}
Enter fullscreen mode Exit fullscreen mode
  • An Allow rule removes the sandbox entirely when every segment of a script matches an explicit Allow. It is a trust grant, not just "skip the prompt":
// core/src/exec_policy.rs
Decision::Allow => ExecApprovalRequirement::Skip {
    // Bypass sandbox only when every parsed command segment is
    // explicitly allowed by execpolicy.
    bypass_sandbox: commands.iter().all(|command| {
// …
            .any(|rule_match| {
                is_policy_match(rule_match) && rule_match.decision() == Decision::Allow
            })
    }),
Enter fullscreen mode Exit fullscreen mode
  • The "Linux seccomp" sandbox type is bubblewrap plus a seccomp filter, and the file named landlock.rs holds the seccomp code (SandboxType::LinuxSeccomp in sandboxing/src/manager.rs, and the header comment of linux-sandbox/src/landlock.rs).
  • The context-size number is partly an estimate, so the compaction trigger fires on a guess as well as a measurement.
  • Compaction keeps your messages and drops the agent's own: tool calls and replies are replaced by a summary written as a user message.
  • Toggling Smart Prune or a Ledger row applies at the next turn or request, not mid-flight.
  • Inferred, not tested: since the Ledger writes a file rather than calling the server, a remote app-server would see a different file from the one your TUI edits.

Status

Smart Prune is experimental and off by default. In a frozen synthetic study its cost
depended on the horizon: short sessions cost more, and sessions of about 35 requests cost
less. Whether it changes task success remains unproven.


Originally published at https://masihmoafi.com/blog/codex-elpis-under-the-hood.

Top comments (0)