|
| 1 | +# Workflow DSL |
| 2 | + |
| 3 | +The ECS-based LLM Agent framework provides a declarative Domain Specific Language (DSL) for building stateful agents. Workflows allow you to define a graph of states, prompt profiles, and transition gates that drive an agent's behavior over multiple ticks. |
| 4 | + |
| 5 | +## Architecture Overview |
| 6 | + |
| 7 | +Workflows are implemented as a set of ECS components and a dedicated system: |
| 8 | + |
| 9 | +- **`WorkflowDefinitionComponent`**: Holds the compiled workflow graph. |
| 10 | +- **`WorkflowRuntimeComponent`**: Tracks the current state and transition history. |
| 11 | +- **`WorkflowBindingComponent`**: Binds an agent key to the workflow. |
| 12 | +- **`WorkflowStateSystem`**: Evaluates gates and commits transitions (priority -25). |
| 13 | +- **`WorkflowPromptPlaceholderProvider`**: Injects the active state's prompt into the system prompt via the `${_workflow_state_prompt}` placeholder. |
| 14 | + |
| 15 | +## Quick Start |
| 16 | + |
| 17 | +The following example defines a simple two-state workflow where an agent starts in a `DRAFT` state and moves to `REVIEW` once a `DoneMarker` component is attached to the entity. |
| 18 | + |
| 19 | +```python |
| 20 | +from ecs_agent.core import World |
| 21 | +from ecs_agent.workflows import workflow, install_workflow, has |
| 22 | +from ecs_agent.systems.workflow_state import WorkflowStateSystem |
| 23 | +from ecs_agent.systems.system_prompt_render_system import SystemPromptRenderSystem |
| 24 | +from ecs_agent.prompts.contracts import SystemPromptConfigSpec, PromptTemplateSource |
| 25 | + |
| 26 | +# 1. Define the workflow |
| 27 | +spec = workflow( |
| 28 | + "my-flow", |
| 29 | + initial="DRAFT", |
| 30 | + profiles={ |
| 31 | + "agent": { |
| 32 | + "draft_p": "You are in draft mode. Help the user write a plan.", |
| 33 | + "review_p": "You are in review mode. Critique the plan.", |
| 34 | + } |
| 35 | + }, |
| 36 | + states={ |
| 37 | + "DRAFT": { |
| 38 | + "bind": {"agent": "draft_p"}, |
| 39 | + "go": {"REVIEW": has(DoneMarker)}, |
| 40 | + }, |
| 41 | + "REVIEW": { |
| 42 | + "bind": {"agent": "review_p"}, |
| 43 | + "go": {}, |
| 44 | + }, |
| 45 | + }, |
| 46 | +) |
| 47 | + |
| 48 | +# 2. Setup World and Entity |
| 49 | +world = World() |
| 50 | +eid = world.create_entity() |
| 51 | + |
| 52 | +# 3. Install workflow and prompt config |
| 53 | +install_workflow(world, eid, spec, agent_key="agent") |
| 54 | +world.add_component(eid, SystemPromptConfigSpec( |
| 55 | + template_source=PromptTemplateSource(inline="${_workflow_state_prompt}") |
| 56 | +)) |
| 57 | + |
| 58 | +# 4. Register systems |
| 59 | +world.register_system(WorkflowStateSystem(priority=-25), priority=-25) |
| 60 | +world.register_system(SystemPromptRenderSystem(priority=-20), priority=-20) |
| 61 | +``` |
| 62 | + |
| 63 | +## Gate Primitives |
| 64 | + |
| 65 | +Gates are expressions evaluated against the entity's components to determine if a transition should fire. |
| 66 | + |
| 67 | +| Primitive | Description | |
| 68 | +| :--- | :--- | |
| 69 | +| `has(Component)` | Matches if the component is present on the entity. | |
| 70 | +| `absent(Component)` | Matches if the component is absent from the entity. | |
| 71 | +| `field(Component, "attr") == value` | Matches if the component is present and the attribute equals the value. | |
| 72 | +| `all_of([gate1, gate2])` | Matches if all child gates match. | |
| 73 | +| `any_of([gate1, gate2])` | Matches if any child gate matches. | |
| 74 | +| `not_(gate)` | Negates the child gate. | |
| 75 | + |
| 76 | +```python |
| 77 | +from ecs_agent.workflows import has, absent, field, all_of, any_of, not_ |
| 78 | + |
| 79 | +# Complex gate example |
| 80 | +gate = all_of([ |
| 81 | + has(StatusComponent), |
| 82 | + field(StatusComponent, "value") == "ready", |
| 83 | + not_(has(ErrorComponent)) |
| 84 | +]) |
| 85 | +``` |
| 86 | + |
| 87 | +## Shared Prompt Profiles |
| 88 | + |
| 89 | +Prompt profiles are grouped by `agent_key`. Multiple states can bind to the same profile. When a transition occurs between two states that share the same profile, the `${_workflow_state_prompt}` placeholder remains identical. This prevents unnecessary cache invalidation in the `SystemPromptRenderSystem`, ensuring stable prefix caching for LLM providers. |
| 90 | + |
| 91 | +```python |
| 92 | +profiles = { |
| 93 | + "main": { |
| 94 | + "standard": "You are a helpful assistant.", |
| 95 | + "expert": "You are a subject matter expert.", |
| 96 | + } |
| 97 | +} |
| 98 | + |
| 99 | +states = { |
| 100 | + "IDLE": { |
| 101 | + "bind": {"main": "standard"}, |
| 102 | + "go": {"ACTIVE": has(StartFlag)}, |
| 103 | + }, |
| 104 | + "ACTIVE": { |
| 105 | + "bind": {"main": "standard"}, # Shared profile: no prompt churn on transition |
| 106 | + "go": {"DONE": has(EndFlag)}, |
| 107 | + } |
| 108 | +} |
| 109 | +``` |
| 110 | + |
| 111 | +## System Ordering Contract |
| 112 | + |
| 113 | +For workflows to function correctly, systems must be registered in a specific order. The `WorkflowStateSystem` must run after any systems that mutate components used in gates (like `UserPromptNormalizationSystem` script handlers) and before the `SystemPromptRenderSystem`. |
| 114 | + |
| 115 | +Recommended priorities: |
| 116 | +1. **`UserPromptNormalizationSystem`** (priority -30): Processes triggers and runs script handlers that might attach "gate" components. |
| 117 | +2. **`WorkflowStateSystem`** (priority -25): Evaluates gates and commits transitions. |
| 118 | +3. **`SystemPromptRenderSystem`** (priority -20): Resolves `${_workflow_state_prompt}` based on the newly committed state. |
| 119 | +4. **`ReasoningSystem`** (priority 0): Performs LLM inference using the rendered prompt. |
| 120 | + |
| 121 | +## TriggerSpec Interaction |
| 122 | + |
| 123 | +Triggers defined in `UserPromptConfigComponent` can use `action="script"` to run Python code when a keyword or event is matched. These scripts can attach components to the entity, which the `WorkflowStateSystem` will observe in the same tick. This allows slash commands to drive immediate state transitions. |
| 124 | + |
| 125 | +```python |
| 126 | +async def handle_activate(world, eid, text): |
| 127 | + world.add_component(eid, FlagComponent()) |
| 128 | + return None |
| 129 | + |
| 130 | +# User types "/activate" -> FlagComponent attached -> Workflow transitions to ACTIVE -> Prompt updates |
| 131 | +``` |
| 132 | + |
| 133 | +## Checkpoint and Resume |
| 134 | + |
| 135 | +The workflow system is designed to be serializable, with one important exception: the `WorkflowDefinitionComponent`. |
| 136 | + |
| 137 | +- **`WorkflowRuntimeComponent`**: Contains the `current_state_id` and `transition_history`. This component IS serialized and restored. |
| 138 | +- **`WorkflowDefinitionComponent`**: Contains the compiled graph. This component is NOT serialized because it may contain non-serializable callables or large static data. |
| 139 | + |
| 140 | +**Resume Contract**: After loading a world from a checkpoint (e.g., via `Runner.load_checkpoint`), the caller must re-install the workflow definition using `install_workflow` or manually adding the `WorkflowDefinitionComponent`. The runtime state will be preserved, and the agent will resume from the correct state. |
| 141 | + |
| 142 | +## Transition Semantics |
| 143 | + |
| 144 | +The `WorkflowStateSystem` evaluates all transitions from the current state in every tick. |
| 145 | + |
| 146 | +- **0 matches**: No transition occurs. The state remains unchanged. |
| 147 | +- **1 match**: The transition is committed. `current_state_id` is updated, the transition ID is appended to `transition_history`, and a `WorkflowLastTransitionComponent` is attached for the current tick. |
| 148 | +- **>1 match**: This is considered an ambiguous state. The system attaches an `ErrorComponent` and a `TerminalComponent` with the reason `workflow_ambiguous_transition`, halting execution. |
0 commit comments