← Về thư mục
📄 / / profiles / r-and-d / raw_evidence / OpenAI4S / docs / backend-extension-guide.md

Backend extension guide

OpenAI4S has two action planes and one rule decides where new behaviour belongs:

Dependency map

provider wire -> AgentEngine -> action router -> append-only Action Ledger
                                  |       |             |
                                  |       |             +-> FinalizeAction
                                  |       +-> Python/R kernel
                                  |                 |
                                  +-> Tool class    +-> synchronous host RPC
                                         |                      |
                                         +--- HostDispatcher ---+
                                                    |
                                   host service classes / repositories
                                                    |
                                           Store compatibility facade

HostDispatcher owns the shared policy envelope: Host-RPC argument decoding, permissions, human approval, audit/replay, injection screening, and UI activity events. Business behaviour belongs in a tool or service class. Store remains the compatible public facade and connection/migration owner; SQL behaviour belongs in domain repositories sharing that connection and lock.

Add an LLM provider or model

First classify the change as a provider identity, model preset, or wire transport. Most OpenAI-compatible/self-hosted additions need no transport code:

from openai4s.llm import register_model_preset, register_provider

register_provider(
    "lab_openai",
    wire="openai",
    base_url="http://127.0.0.1:11434/v1",
    model="science-model",
    tool_calling=False,
)
register_model_preset("lab_openai", "science-model", "Local science model")

register_provider accepts only the four shipped wires and validates identity, URL, credentials-in-URL, default model, and capability metadata before making the provider visible. Registration is process-local; a deployment/plugin owns re-registration at startup. Model-profile seeding consumes the independent preset catalog through server/model_profiles.py, so do not add provider/model conditionals to gateway.py.

Only an upstream protocol that cannot be expressed as openai, responses, anthropic, or gemini warrants a module under llm/providers/. In that case, add one normalized adapter, extend the dispatch and supported-wire sets together, and add request/response/tool-call fixtures. Provider-native response shapes must not escape into AgentEngine or persisted public projections.

Add a native control tool

Create one module under openai4s/tools/. The class must contain its schema, security policy, and behaviour so a maintainer can understand the capability by opening one file.

from openai4s.tools.base import Tool


class CreateExperimentTool(Tool):
    name = "create_experiment"
    host_method = "create_experiment"
    description = "Create an approved scientific workflow record."
    parameters = {
        "properties": {
            "type": {"type": "string"},
        },
        "required": ["type"],
    }
    read_only = False
    requires_approval = True
    permission_target_key = "type"
    side_effect_class = "metadata_write"
    resource_key_prefix = "experiment"
    resource_target_key = "type"

    def execute(self, context, arguments: dict) -> dict:
        return context.invoke(self.host_method, {"type": arguments["type"]})

Then add the class—not a pre-created instance—to TOOL_TYPES in openai4s/tools/registry.py. The registry is the only built-in composition point and creates the runtime instances in a deterministic order.

Registration and invocation contracts:

requires_approval defaults to true. A class that turns it off must document and test its safe boundary; the registry preserves this class-owned policy, while the dispatcher is responsible for enforcing it at invocation time. read_only and resource_keys() also drive batch scheduling: only a leading lane of non-conflicting read-only calls may run in parallel, while mutating or unknown capabilities form a sequential barrier. Declare these fields conservatively; missing resource identity is treated as a conflict.

Add direct tests for the class behaviour and policy metadata, plus an engine test for the provider-neutral call/result group when the wire contract changes. If the tool mutates workspace files, declare writes_files = True. The Web control adapter then snapshots each individual native call and registers every changed file/version as an Artifact. Do not add Artifact capture to the dispatcher itself: kernel-side host.write_file() is already captured by the Cell transaction and would otherwise be registered twice.

finalize_response is intentionally outside TOOL_TYPES. Its schema, validation, and completion record live in openai4s/agent/finalize.py; do not register another tool with that name or route plugin code around the Engine.

Session-authored dynamic tools

Model/session-authored tools use the existing DefineDynamicTool control path, not register_tool(). Definitions are schema-checked, content-hashed, tested in an enforced OS sandbox with the kernel environment allowlist, and exposed through a trusted proxy with a session TTL. Promotion to broader scope is a separate approval operation. Model-authored code is never imported into the Host process. If the OS sandbox is unavailable, definition fails closed.

Add an in-kernel host.* capability

The worker-facing signature belongs in openai4s/sdk/. A cohesive namespace such as compute should have its own module; sdk/host.py composes and compatibly re-exports it.

The host-side implementation belongs in a class under openai4s/host/:

class ExperimentService:
    def __init__(self, store_provider):
        self._store_provider = store_provider

    def create(self, spec: dict) -> dict:
        store = self._store_provider()
        return store.create_experiment(**spec)

Construct the service once in HostDispatcher.__init__, using small provider callbacks when session state can be replaced at runtime. Keep the existing _m_<method> method only as a thin compatibility adapter:

def _m_create_experiment(self, spec: dict) -> dict:
    return self._experiment_service.create(spec)

Return {"error": message} only for the established soft-fail contract. Uncaught exceptions are converted at the kernel protocol boundary. Do not duplicate permission, audit, replay, or injection policy inside the service; all calls already cross the dispatcher envelope.

Add persisted data

Create a focused repository under openai4s/storage/. Repositories receive the existing SQLite connection, the existing RLock, and a clock callback. They do not open another connection for application writes.

class ExperimentRepository:
    def __init__(self, connection, lock, *, clock_ms):
        self._connection = connection
        self._lock = lock
        self._clock_ms = clock_ms

    def get(self, experiment_id: str) -> dict | None:
        with self._lock:
            row = self._connection.execute(
                "SELECT * FROM experiments WHERE experiment_id=?",
                (experiment_id,),
            ).fetchone()
        return dict(row) if row else None

Store owns schema creation and migrations, constructs the repository with its shared connection/lock, and exposes a thin forwarding method. Composite writes that span several aggregates must keep their existing single-lock and commit boundary. When old code dynamically called another Store method, inject a late-bound lambda rather than freezing a bound method and silently breaking subclass/monkeypatch compatibility.

Respect Store generations: Store.close() is idempotent and removes only that exact instance from the get_store(path) cache. A long-lived service that may outlive a Store must resolve the current Store/repository at operation time; never retain a repository backed by a connection that configuration reload or test teardown may close. The default SkillLoader capability adapter is the reference pattern.

Repository tests should lock down SQL-visible results, commit/rollback boundaries, ordering, timestamp evaluation, JSON fallback, and legacy error shapes. Default tests remain offline.

Extend the Skill lifecycle

Bundled recipes belong under skills/ and are read-only. User-authored files belong under <data_dir>/user-skills; reject symlink/path escapes and name collisions rather than shadowing a bundled Skill. The in-kernel Host editor uses draft and promotes explicitly to personal; Web Customize writes whole documents with user origin. Discovery must preserve these user origins and must never allow a user-space frontmatter value to claim openai4s trust. Capability enablement is durable and scoped, and default loaders must follow the current Store generation as described above.

Extend the Action Ledger

action_groups, action_events, and execution_attempts are the canonical runtime history. Chat messages, Notebook rows, activity cards, and the Action Timeline are projections. Open a group before execution, append every ordered result (including validation/permission failures), allocate a Cell attempt before lazy runtime startup, and append a terminal event. Never update an old event to make a retry look successful.

Provider wire metadata and raw arguments stay in the durable audit/replay record. Researcher-facing services must use the redacted, field-bounded ActionTimelineService; do not expose wire_state, raw argument strings, credentials, or unrestricted results in a new REST/WS payload. Its page limit is 1–500: an initial read returns the latest window, before_ordinal moves older, and after_ordinal moves newer. The cursors are non-negative and mutually exclusive, and the projection returns explicit truncation/cursor metadata. Consumers must not treat per-field truncation as history pagination.

Enable interactive remote sessions

An AllocationBackend can run BATCH workloads without any additional capability. Interactive remote SESSION workloads are different: the daemon mints a worker bootstrap credential before the queued allocation starts, then sends session Cell source and services host.* calls over the authenticated worker connection. If sibling allocations run as the same Unix uid, one can read another's unused credential even when its directory is 0700 and the file is 0600, register first, and become the victim's worker.

Implement the optional SessionIsolationProvider Protocol from openai4s.orchestration.ports only when the backend guarantees that code in one allocation cannot read or modify another allocation's workspace or pre-use bootstrap credential. isolates_session_credentials() may return True only after the deployment prerequisites for a per-allocation OS identity, container, or mount namespace have been verified. A shared uid, random path, file modes, token expiry, single-use verification, or network isolation alone does not satisfy this contract. If the check is absent, returns false, or raises, composition fails closed. A true result is a backend-lifetime promise for every interactive allocation the backend accepts; it must not be a transient configuration observation or profile-specific best effort.

The built-in LocalBackend and Slurm backend deliberately do not implement this capability. They remain valid for BATCH, while interactive placement returns 409 remote_isolation_required before creating a session-keyed workspace, workload, lease, allocation, or credential. An extension that opts in must test both sides of the boundary: a sibling allocation cannot read the workspace/credential or register as the worker, while the intended allocation can still connect and execute Cell and Host-RPC traffic. Also test that an unavailable or misconfigured isolation provider refuses before every durable or filesystem write.

Add Web session behaviour

HTTP and WebSocket code is an adapter. Stateful behaviour belongs in a service under openai4s/server/ with narrow protocols/callbacks for persistence, kernel lifecycle, event broadcast, and configuration. SessionRunner may keep a private forwarding method when tests or integrations depend on it, but the algorithm should be visible in the service module.

Preserve event payload keys and order-sensitive lifecycle rules. Changes to kernel execution, host RPC, artifact capture, review, streaming, or resume need both focused tests and a real browser run against ./start.sh.

Scientific execution must enter WebExecutionCoordinator: submit an owner (agent, user_repl, lifecycle, or recovery), wait for FIFO admission, bind the exact KernelLease, and mark finalizing before publication. Cancel and interrupt adapters must require the exact execution ID and owner pair; never reintroduce a session-global broad interrupt.

Checkpoint/revert, recovery projection, Timeline, Notebook export, and renderer selection belong behind SessionDomainService. Their algorithms are already implemented independently of HTTP, and the Gateway routes are thin adapters to that service. Extend those adapters instead of duplicating CAS, journal, .ipynb, Timeline, or renderer logic in gateway.py. Recovery status/actions are public, but actually running the verified pipeline still needs an explicit coordinated Gateway operation.

Definition of done

For every backend extension or extraction:

  1. The class file contains the behaviour; the registry/dispatcher/facade only composes or forwards.
  2. Core imports remain standard-library-only.
  3. Public SDK, host.*, CLI, REST/WebSocket, SQLite, and saved-session contracts remain compatible or receive an explicit migration.
  4. Terminal behavior remains explicit: an Engine-owned FinalizeAction, or host.submit_output() as the only completion emitted from inside a Python Cell. Ordinary prose and normal tool results never complete a run.
  5. Run focused tests, the full offline suite, and the browser flow when session, kernel, RPC, artifact, or UI behaviour is involved.
  6. Commit one cohesive change at a time.

Avoid module-level tool singletons, duplicate agent loops, host-side shell execution, independent repository connections, provider response types leaking into AgentEngine, and scientific computation disguised as JSON tools.