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.

One expectation to drop at the door: reset(seed=) reaches the adapter's reset call, but what a live deployment does with a seed is the deployment's own behaviour — two provisioned worlds are two worlds, not two replicas.

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.

Operating a shared deployment

The warnings above stop you making a mess; this is how you find the one a dead process already made. HttpBackend.list_worlds() enumerates what a deployment is carrying — every world's id, name and chaos settings — and is the first step of any audit:

from skyfall_crl.env.morpheus import HttpBackend

backend = HttpBackend(base_url="http://localhost:8282")
for world in backend.list_worlds():
    print(world["worldId"], world.get("name"), (world.get("chaos") or {}).get("enabled"))
backend.close()

A world you do not recognise, with generation on, is money leaking: switch its chaos off (the two-step teardown order applies), then delete it once its queue drains. Three more facts an operator should hold:

  • Several training processes can share one deployment — worlds are independent, and guests change nothing — but capacity and rate limits are the deployment's own; the one recorded cross-tenant hazard is cascade-deleting a world with queued work.
  • A schedule can raise the bill. Pushed axes like ticket_arrival_rate increase the volume of model-authored incident content on a live world; treat an aggressive surge as a cost decision, not only an experimental one.
  • Authentication is the deployment's policy, not this package's: HttpBackend(headers=...) attaches whatever your deployment requires to every request; nothing is sent by default.

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.