← Về thư mục
📄 / / profiles / r-and-d / raw_evidence / OpenAI4S / openai4s / agent / README.md

Agent outer loop

中文说明

The outer-loop state machine lives here, together with the adapters that compose it for local/CLI runs. The machine itself knows nothing about any particular provider. The Web session runner builds its own composition inside the server package, but it routes actions and drives the Engine through the same contracts defined here.

Where this fits

Each model reply is routed to at most one action kind:

  1. an ordered batch of provider-native JSON control-tool calls;
  2. a sole valid Engine-owned finalize_response action; or
  3. the first complete fenced Python/R cell.

Native calls take priority over code. A mixed or malformed finalizer is not completion. Neither is a well-formed one whose claims outrun the run: both compositions accumulate execution evidence at the dispatch sites, and a turn that ran no cell and no tool cannot finalize with execution-shaped bullets, an artifacts list, or metrics — the refusal is a repairable validation error, never a completion. host.submit_output(...) is the only completion that can fire inside a Python Cell; a later sole valid finalize_response may still close the Engine after earlier Cells. Plain prose, ordinary tool observations, R cells, cancellation, and max-turn exhaustion remain non-completing outcomes.

The outer loop reaches for its foreground inner-loop kernel manager only when the action is code. A tool-only or finalizer-only turn therefore never starts that worker. An individual control tool can still manage a dedicated worker of its own, as part of what that tool does.

Files

File Responsibility
__init__.py The package surface: the Engine, the local Agent facade and run_task, the result values, and the finalization helpers.
actions.py The one place a reply becomes an action. It normalizes native calls and recognizes Python/R fences; where both appear, the native calls win. A finalize_response is picked out as the Engine finalizer only when it is the sole native call. Both outer loops route through this module, so they cannot drift apart.
cell_record.py Durable execution_log recording for delegated child Cells. DelegatedCellRecorder writes every Cell a child executes — failed and interrupted ones included, and a synthetic error row when the execution died host-side — keyed frame_id = root_frame_id = <child delegate frame> with origin="delegate", stamped with the child kernel's durable generation id. Child-frame keying gives each child an independent revision cursor and keeps child cells out of the root Notebook projection by construction, while frame_detail/lineage cell_recorded become truthful with zero projection changes. The recorder never raises into the executor (the Cell already ran; losing the observation must not fail the run), and ComposedCellHooks runs it ahead of the optional stage-1 Artifact capture hooks so a capture failure cannot lose the execution record.
compaction.py Decides when the context is too large and what leaves it. Text, images, native calls, provider wire state, and the system prompt are budgeted separately, and every action/result pair stays indivisible. The prompt is its own bucket because compaction never touches it: standing context — memory, Skills, specialists, connectors, environments — is rebuilt from scratch every turn, and counted inside text a large one read as "your conversation is long" and sent the reader to the one remedy that cannot help. An oversized output moves into a digest-addressed archive and leaves a bounded preview plus a SHA-256 reference in its place. The slice that gets compacted away becomes a structured handoff; the raw slice is also archived as its own record, carrying the branch, ledger, and recovery metadata that ties it back to the run.
control.py Runs one native-tool batch and closes every declaration with exactly one result, cancellation included. A leading run of read-only calls with non-conflicting resources may go in parallel; the first mutating or unclassified call is a barrier, and results are always written back in the provider's original order. call_reaches_dispatcher is the first half of what counts as execution evidence: an unknown tool name or refused arguments never reaches the dispatcher, so it cannot back a later execution-shaped completion claim. It is necessary, not sufficient — a call that reaches the dispatcher counts only once its dispatch also reports ok, so a precheck block, a permission denial, a soft-fail result or a raised dispatch never backs one either.
delegation.py The sub-agent tree behind host.delegate. The tree owns the fan-out, session, and depth budgets; each runner owns only its direct children, their executor, and their collected results. Cancelling reaches a child and exactly its descendants, and a stopped child can never publish a late output. Steering messages wait in memory and are consumed at a child's next turn boundary. Resource allowlists (skill_names, connectors) are narrowed, never replaced, and at both of the places a spec is merged: one narrows a request item against the delegate() call's own kwargs, the other narrows a nested child against its parent child's spec. Only the second bounds a grandchild, and without it delegating one level further down was the way out of a restriction the level above had accepted. A child also compacts against its own model's declared window rather than the daemon default, which is exactly the number that diverges when overrides["model"] sends it elsewhere. Children run in the delegating session's workspace, with the owner's read-isolation policy carried unchanged through every nesting level, and each pooled child thread is re-seeded with the parent's execution principal and correlation context, since ThreadPoolExecutor copies neither. When Web trusted Artifact capture is enabled, delegation serializes: one synchronous child at a time owns the shared-workspace snapshot, and parallel fan-out is refused rather than mis-attributed. Durable request identity is separate from attempt identity: same action-group + native-call + digest reuses the original child (budget charged once; terminal reuse makes no provider call); a digest mismatch is 409; restore never auto-resumes — only an explicit continue creates the next attempt.
delegation_workspace.py Feature-flagged (OPENAI4S_DELEGATION_PRIVATE_SCRATCH, default off) private scratch for a delegated child. The parent workspace is captured as a read-only snapshot input; child writes land in a child-scoped directory and may only be published as immutable Artifact versions with producer provenance. The parent workspace is unchanged until an explicit materialize, which never deletes published versions. Does not lift trusted-capture concurrency limits.
engine.py The state machine itself. Pure and provider-neutral, it speaks only to ports: model, context, action executor, completion, cancellation, reply interceptor, and events.
events.py The typed lifecycle events AgentEngine emits.
finalize.py Owns the finalize_response schema. Providers see a metadata-only spec, the Host revalidates the same closed schema before accepting anything, and one valid sole call becomes a structured completion record. It is deliberately not registered as a control Tool. It also owns the execution-evidence ledger behind that acceptance: executors call note_execution_evidence at the dispatch site — after the kernel ran or a dispatched call reported ok, never for a refusal or a denial — and reconcile_completion_claims rejects a zero-execution payload that claims executed work. CJK bullets carry no tense morphology and are never flagged.
ledger.py Writes typed engine events into the append-only Action Ledger, redacting declared secrets on the way in. Reading back, it reduces groups into a restart history a provider will accept, closing any tool call that a crash left without a result. In team mode it also meters model usage to the session's owner, best-effort: with no ownership row the accounting is two reads and no write, and it can never fail an action.
loop.py The backward-compatible local Agent facade, and the owner of local process lifecycle. It wires the Engine to the model, the dispatcher, the ledger, delegation, and persistent kernels that only start once a turn actually runs code. When the Web gateway embeds it as a delegated child, it additionally accepts the parent session's workspace, an OS read-isolation policy, and cell-capture hooks; unset, each of those keeps the CLI contract — the process cwd, historical read behavior, no capture. The tool declarations it offers the model are posture-aware: an approval-required tool that no attached channel, standing allow rule, Guardian allowlist, or unattended policy could approve is left out of the projection (a Web session's children, whose root has a channel, keep everything), while the catalog and the permission gate still see it and refuse it if called.
models.py The provider-neutral values that cross the Engine: a normalized model reply, mutable run state, one execution outcome, and the final result.
ports.py The protocols the Engine depends on, plus a no-op default for each. This is what keeps engine.py from importing a concrete model, storage, kernel, or UI.
progress_circuit.py Generic Agent no-progress circuit. Canonical tool fingerprints, consecutive malformed calls, normalized similar tool errors, and conservative long-text near-duplicates are counted inside one external-user epoch. Thresholds are server-fixed. The Action Ledger is the only durable authority; RunState.metadata is a process cache. A trip is stop_reason=no_progress with a closed-set progress_reason, never completion.
task_modes.py Which KIND of task a turn is — analysis_run (the default and the historical behaviour), reusable_pipeline, or codebase_change — and the per-turn prompt fragment that says so. An explicit selection (the Web task_mode body field, openai4s run --mode) always wins and an unrecognised value raises rather than falling through, so the door cannot decay into a suggestion. Detection is deliberately conservative: a mode needs a target signal and an action signal, both word-bounded, in zh and en; one signal is a topic word, not a request, and stays analysis_run — a false code mode would demand source/entry-point/test evidence from a turn that has none, refusing an honest completion. The fragment rides the user message (the system prompt is seeded once per session) and carries its own scoped override of the "working dir is deliverables only" clause, because in these modes the source files ARE the deliverable.
runtime.py The local side of those ports. A blocking LLM client, compaction, native tools, Python/R cell execution, the CLI transcript projection, and the read-back of a completion — one adapter each, and the Engine sees none of them directly. It also decides what a cell result is allowed to cost the model: each observation section is capped, and an oversized one becomes a preview with the full text spilled to a workspace-relative content reference the agent can open. Relative because an absolute path would leak $HOME into the context and make the rendered observation's length depend on where the data directory happens to live. The model adapter hands cancellation down into the transport as well, so a stop lands during a rate-limited retry backoff instead of after the whole budget of it. This is also where execution evidence is counted — calls whose dispatch reported ok and cells that ran, never declared, refused or denied ones — where a writes_files native tool is bracketed by the Web capture hooks so its file writes become Artifacts under the right frame, and where an optional team-mode quota gate may refuse a provider request before it is sent.

Extension and verification contract