Skip to content

The MORPHEUS adapter

The problem. A Gymnasium environment is normally a simulation you own, reset cheaply, and step in-process. A live operational world is the opposite: it persists, runs on its own clock, is destroyed and rebuilt by a reset, and never reaches a terminal state. The two contracts have to be bridged without pretending either is the other.

The shape here. One gymnasium.Env over a live MORPHEUS world, reached through a narrow backend protocol so the transport is swappable and the whole thing is testable with an in-memory double. The environment observes an incident stream, executes scoped remediations, scores them with a reward specification, and reports the configuration label out-of-band:

observe incidents  →  choose a remediation  →  validate & execute  →  verify  →  reward

What you implement: nothing, to drive a world — morpheus_world(...) builds the environment from scalars, which is what lets a configuration document select it. To reach a different deployment, implement MorpheusBackend; to give a policy a curated action schema, implement a TaskView.

from skyfall_crl.env.morpheus import HttpBackend, MorpheusEnv

env = MorpheusEnv(
    HttpBackend(base_url="http://localhost:8282"),
    layout="process-outbound",
    reward_spec="paper",
    max_steps=256,
)
observation, info = env.reset(seed=1)

The base install carries everything a live world needs.

What a step does

An action is mapped to a tool call, checked against the world's boundary, executed, then scored: the ticket stream is re-read, the tickets the action touched are verified, and the reward specification composes the result. The ticket stream is read once per step and shared by the reward and the observation, so what scored the agent and what the agent sees can never disagree.

Observations

A fixed seven-field projection of the incident stream — step, open_tickets, counts by_priority and by_status, mean_verifier_progress, recent_invalid_actions, and the tickets themselves as JSON. The schema does not change with the world, the layout, or a shift: chaos types and affected services are open-ended, so they travel inside the ticket payload rather than as fields of the space. A shift moves the values an agent sees, never the shape.

Actions, and the boundary

By default an action is a tool call — a method, a path, and a body. A text policy recovers one from its own output through the task view it was given:

from skyfall_crl.env.morpheus import MorpheusEnv, ToolCallTaskView

view = ToolCallTaskView()
env = MorpheusEnv(backend, task_view=view)
action = view.parse_action(model_output)   # None when there is no action in the text

A call must be world-scoped and land in one of the world's own domains (tickets, erp, wms, tms, finance, ledger, planning, logs). Two things are always refused: running operational descriptors — they are the simulation, carried forward by the deployment on its own schedule, and an agent that could trigger them would be driving the world rather than working in it — and the verification endpoints, because an agent cannot mark its own homework. A refused action is scored, not raised: the world is untouched, the reason lands in info["action"]["reason"], the count appears in the next observation, and one malformed token from a policy does not end an episode.

Episode boundaries, and the label

terminated is always False — a world has no goal state. truncated becomes True at the step budget, an administrative edge rather than a win, and a configuration shift never ends an episode — the rule and its rationale are in Anatomy of a run. The active label is reported in info["regime"] and never in the observation.

Owning a world

A world you provision keeps costing money until you remove it. Incident content is authored by a model call, so a world with chaos and ticket creation enabled keeps making API requests for as long as the deployment is up — whether or not anything is still driving it. delete_on_close is False by default, so close() alone does not remove a world you created: pass delete_on_close=True, or tear it down yourself. The safe order is generation off first, delete second — switching chaos and ticket creation off is immediate and safe with work in flight, whereas deleting a world with queued jobs can cascade badly enough to disrupt the deployment. A world with generation off costs nothing, so a partial teardown is a good resting state.

By default the environment provisions its own world. Pass an existing world_id and it becomes a guest: it will not delete the world, will not rebuild it, and will not rewrite its configuration, whatever else you ask. Resetting a world destroys and rebuilds it, so a world is provisioned once and reused across episodes; options={"reprovision": True} rebuilds deliberately. A world can declare its own reward, adopted when you name none; your named specification always wins. env.describe() reports what a run was configured with — the provenance an export bundle records.

Many worlds, misbehaving worlds

Throughput comes from independent worlds together — PersistentVectorEnv, which never auto-resets. And a world is a network service written by other people, so the environment degrades rather than dies: an unusable response reads as no tickets; a verifier answering with missing counts contributes no verification; an action the world rejects is reported, not raised. What does raise is a world that stops answering entirely — better a clear failure than an episode that quietly scores nothing.

Limits

The observation projects the incident stream only — the financial figures feed the reward, not the policy. Two operational fields the reward reads are proxies of the deployment rather than first-class platform data, and the incident severity table discriminates only as well as the deployment labels its tickets; both are recorded precisely in Limits and status.


API: every public symbol, with signatures — MORPHEUS adapter — API reference.