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:
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_closeisFalseby default, soclose()alone does not remove a world you created: passdelete_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.