Skip to content

Composition ​

Composition is how you make a Workflow from Machines that exist already. It has three parts. A Machine invokes a different Machine as its child. customize() changes the parts that a Machine carries. And you bind the parts that a Machine leaves Open. The kit ships Machines for you to compose in this way, in the package @jr2/machines.

Why it exists ​

A Machine that you import must work in your Instance with no change to its module. Thus a Machine carries all that it needs: its Vocabulary, its Agents, its Effects, its Sandbox Image and its Repo Slots. The Machine does not need an Agent list or event names from the Instance. The Instance gives only access and defaults. Access is the model provider and its key, the secrets, and the git credentials. A default is a Sandbox Image or a Size for a Machine that states none.

A package cannot know some parts. It does not know your Repo, and it cannot pay for your model. The package leaves these parts Open. You bind them where you register the Machine as a Workflow, or where you compose it. If you do not bind an Open part, jr2 up stops before it converges. Thus a missing part does not become a run that spends money on a model that nobody chose.

A Machine inside a Machine ​

A Machine composes a different Machine as an xstate child. It imports the Machine, puts it under actors, and a state invokes it by its key. The key is a slot, the same as the slot of an Agent.

This Machine composes the shipped Machine task two times. A Menu-only Agent, triager, reads the prompt first. Then the run goes to one of the two copies of task. The copies differ only in the thinking level of their coder.

The Workflow, with the model of the tab:

ts
import { z } from "zod";
import { agent, customize, defineEvent, fail, jr2Setup, type HostInjectedInput } from "@jr2/orchestrator";
import { task } from "@jr2/machines";

const model = "anthropic/claude-haiku-5-5";

// Bind the two Open parts of `task`: the Repo that the coder works in, and the coder's model.
const coding = customize(task, {
  repos: { target: { url: "https://github.com/you/app.git" } },
  agents: { coder: { model } },
});

// Two changed copies of one Machine. `coding` does not change.
const deep = customize(coding, { agents: { coder: { thinkingLevel: "high" } } });
const quick = customize(coding, { agents: { coder: { thinkingLevel: "low" } } });

const door = z.object({ prompt: z.string().describe("What to do, in prose.") });

const route = defineEvent({
  name: "route",
  description: "Say how much care the task needs.",
  audience: "agent",
  input: z.object({
    size: z.enum(["small", "large"]).describe("small: a change to one file. large: any other change."),
  }),
});

type Input = HostInjectedInput & z.infer<typeof door>;

export const machine = jr2Setup({
  types: {} as { input: Input; context: Input },
  events: [route],
  actors: {
    triager: agent({
      model,
      workspace: "none",
      thinkingLevel: "off",
      instructions: "Read the task. Call route once: small for a change to one file, large for any other change.",
    }),
    // Each composed Machine is a slot, like an Agent.
    deep,
    quick,
    fail: fail(),
  },
}).createMachine({
  id: "route",
  input: door,
  context: ({ input }) => input,
  on: { "agent.fault": ".failed" },
  initial: "triaging",
  states: {
    triaging: {
      invoke: { src: "triager", input: ({ context }) => ({ prompt: context.prompt }) },
      on: {
        route: [{ guard: ({ event }) => event.size === "large", target: "deep" }, { target: "quick" }],
      },
    },
    // A composed `task` gets its input from this Machine, so it needs a branch.
    deep: {
      invoke: {
        src: "deep",
        input: ({ context }) => ({ prompt: context.prompt, branch: `jr2/route-${context.instanceId}` }),
        onDone: "done",
      },
    },
    quick: {
      invoke: {
        src: "quick",
        input: ({ context }) => ({ prompt: context.prompt, branch: `jr2/route-${context.instanceId}` }),
        onDone: "done",
      },
    },
    done: { type: "final" },
    failed: { invoke: { src: "fail", input: { reason: "the Agent's Turn could not go on" } } },
  },
});

To use @jr2/machines, add it to the Instance at the Kit version: npm install --save-exact @jr2/machines@<version>. <version> is the version of @jr2/orchestrator in your package.json.

A composed Machine is a part of the run of its parent:

  • The run has one run id and one snapshot. A child Machine does not start a second run.
  • The Gates of the child show in the run of the parent. Its Emits go to the same feed.
  • Each Machine keeps its own Vocabulary. The approve of a child is not an event of its parent, and the parent does not declare it again.
  • The parent gets to the child by import. It does not use the Workflow name that jr2 run uses.
  • Only the root Machine of a run gets instanceId (HostInjectedInput) beside the run input. A child gets only the input that its parent gives it. Thus the example gives each task a branch.

Composing a Workflow ​

routethe Machine that workflows/route.ts registers as the Workflow route
triager Agentpicks deep or quick
deep Machinethinking high
quick Machinethinking low
codingbinds the Open parts of task
Repo Slot targetyour app
coder modelthe model of the tab
taskfrom @jr2/machines
coder Agentedits and commits
Repo SlotsOpen
coder modelOpen
The Workflow route, and the Machines that it composes

The file workflows/route.ts registers the Machine route as the Workflow route. The Machine declares three slots of its own: the Agent triager and the two Machines deep and quick. Each of the two is a customize() of coding, and coding is a customize() of task. task leaves its Repo Slots and the model of its coder Open. coding binds them, so deep and quick have them too.

customize() ​

customize(machine, parts) gives a new Machine with the parts changed. The Machine that you give it does not change. Thus two calls on one import give two Machines, as deep and quick show.

Customize gives the parts that you can change:

KeyWhat it changes
agentsThe definition of an Agent, by slot, field by field. { model } keeps the rest.
effectsThe Open url, argv or secret of an Effect, by slot.
actorsA Machine that this Machine composes, by slot. The value has the same keys, one down.
reposThe Repo Slots of the workspace().
imageThe Sandbox Image of the workspace().
userThe User Container of the workspace().
resourcesThe Size of the workspace(), field by field.

The type comes from the Machine. It offers only the Agents, Effects, Machines and Repo Slots that the Machine declares. A slot that the Machine does not have does not compile.

workspace() and pool() wrap a Machine. customize() goes through each wrapper to the Machine inside it. Thus customize(task, { agents }) changes the Agents of the Machine in the workspace() of task, and you do not name the wrapper's own slot. repos, image, user and resources go down to the first workspace() on that path.

Open parts ​

A Machine marks a part Open with open. These parts can be Open:

  • a Repo Slot with no url, or the whole Repo Slot map (repos: open)
  • the model of an Agent (agent({ model: open, … }))
  • the url of an http Effect, the argv of a command, or the name of the secret of an Effect (secret(open))

An Open part has no default. You bind it with customize(). Each Open part has two checks:

  • jr2 up reads every registered Machine. It refuses an Open part that nobody bound, and it prints the customize() line that binds it.
  • At run time, the part refuses too. An Agent does not start a Turn with an Open model. An Effect does not run while it is Open.

Bind a shipped Machine's Open parts shows each binding.

@jr2/machines ​

@jr2/machines is the package of Machines that the kit ships. It ships Machines and never Workflows. A Machine becomes a Workflow only when a file in your workflows/ folder exports it.

It ships task: a coder Agent works on one prompt in a Workspace, and a person approves the work at the review Gate. task leaves two parts Open: the whole Repo Slot map and the model of coder. The first Repo Slot that you name is the Repo that the coder works in. The coder can read the other Repos. It also ships body, the Machine inside task, for a workspace() of your own.

The version of @jr2/machines is the Kit version. It names @jr2/orchestrator at that exact version as a peer dependency.

A Machine as an npm package ​

A package exports a Machine and nothing more. The run input, the Vocabulary, the Agents, the Effects, the Sandbox Image and the Repo Slots are all on the Machine. A Workflow uses an imported Machine in one of three ways:

  • as it is, when it leaves no part Open: export const machine = imported;
  • changed: export const machine = customize(task, { … });
  • composed in a Machine of your own, as in the example above

task cannot be used as it is, because it leaves two parts Open.

A file in workflows/ uses the same code. A package has no API of its own.

A Machine package names @jr2/orchestrator, xstate and zod as peer dependencies, never as dependencies. jr2 keeps the parts of a Machine in the one copy of @jr2/orchestrator and of xstate that the Instance loads. A Machine made with a second copy of either has empty Menus and Gates that accept nothing. jr2 up shows no error for it, and prints no seats: line for it. Its run stops with the status error when an Agent or a Gate of the Machine starts. @jr2/machines has this shape, and your package copies it.

A package can ship a docker context for its Sandbox Image. The Machine names the folder with import.meta.resolve("./image"), and jr2 up builds it.

Rules ​

  • A nested Machine is a child in the same run. jr2 has no second run for a child, and no invoke by Workflow name.
  • An agent(), an Effect or a workspace() is a key of actors. jr2 refuses one that a state writes in its invoke.src. jr2Setup().createMachine refuses it, and jr2 up refuses it too.
  • customize() changes only the parts that a Machine declares. You cannot add a part. A Machine that needs one more Agent is a new Machine.
  • For a part that the Machine does not declare, the compiler refuses the key, and customize() refuses it at run time. image, user and resources compile on every Machine. customize() refuses them when the Machine composes no workspace().
  • jr2 up refuses a registered Machine that leaves a part Open, and it names the Workflow and the part.
  • jr2 up also refuses an Open part on a Machine that a state writes in its invoke.src, because no customize() can get to it. Put that Machine under actors.
  • A Repo Slot map that is Open takes the slots that you name. Name one slot or more. Their order means what the Machine says it means.
  • An Effect's Open part is bound one time, to a value. A run does not choose it, and a second customize() cannot change it.
  • A file in workflows/ that a different Workflow imports is also a Workflow. Give a Machine that is only for composition a file name that starts with _, or keep it outside workflows/.
  • task inside a different Machine needs branch in its input. Without it, task refuses to start.

The docs of Kit version 0.5.1. Each page is also at its .md URL.