|
| 1 | +"""Workflow DSL example: stateful agent with gate-driven state transitions. |
| 2 | +
|
| 3 | +This example demonstrates the built-in Workflow DSL, which lets you define a |
| 4 | +graph of states, prompt profiles, and pure transition gates, compiled onto the |
| 5 | +existing ECS runtime. |
| 6 | +
|
| 7 | +Scenario: a two-phase writing assistant. |
| 8 | +
|
| 9 | + DRAFT ──(has DraftReadyMarker)──► REVIEW ──(has ApprovedMarker)──► DONE |
| 10 | +
|
| 11 | +- DRAFT and REVIEW use different prompt profiles so the LLM "persona" changes |
| 12 | + when the workflow transitions. |
| 13 | +- REVIEW re-uses a shared profile to show that transitions within the same |
| 14 | + profile cluster do NOT invalidate the rendered system prompt cache. |
| 15 | +- Gate components are plain dataclasses attached to the entity by tool calls or |
| 16 | + trigger script handlers. WorkflowStateSystem observes them once per tick. |
| 17 | +
|
| 18 | +Dual-mode: |
| 19 | + - Without LLM_API_KEY → FakeModel (deterministic, works offline) |
| 20 | + - With LLM_API_KEY → real LLM (OpenAI-compatible or Anthropic) |
| 21 | +""" |
| 22 | + |
| 23 | +from __future__ import annotations |
| 24 | + |
| 25 | +import asyncio |
| 26 | +import os |
| 27 | +from dataclasses import dataclass |
| 28 | + |
| 29 | +from ecs_agent.components import ( |
| 30 | + ConversationComponent, |
| 31 | + LLMComponent, |
| 32 | + TerminalComponent, |
| 33 | + ToolRegistryComponent, |
| 34 | +) |
| 35 | +from ecs_agent.core import Runner, World |
| 36 | +from ecs_agent.logging import configure_logging, get_logger |
| 37 | +from ecs_agent.prompts.contracts import PromptTemplateSource, SystemPromptConfigSpec |
| 38 | +from ecs_agent.providers import FakeModel, Model |
| 39 | +from ecs_agent.providers.config import ApiFormat |
| 40 | +from ecs_agent.providers.protocol import LLMModel |
| 41 | +from ecs_agent.systems.error_handling import ErrorHandlingSystem |
| 42 | +from ecs_agent.systems.reasoning import ReasoningSystem |
| 43 | +from ecs_agent.systems.system_prompt_render_system import SystemPromptRenderSystem |
| 44 | +from ecs_agent.systems.tool_execution import ToolExecutionSystem |
| 45 | +from ecs_agent.systems.workflow_state import WorkflowStateSystem |
| 46 | +from ecs_agent.tools import tool |
| 47 | +from ecs_agent.types import CompletionResult, EntityId, Message, ToolCall, ToolSchema |
| 48 | +from ecs_agent.workflows import PromptProfileSpec, has, install_workflow, workflow |
| 49 | + |
| 50 | +logger = get_logger(__name__) |
| 51 | + |
| 52 | +# --------------------------------------------------------------------------- |
| 53 | +# Gate marker components |
| 54 | +# These are plain dataclasses. The workflow gates observe their presence via |
| 55 | +# has(DraftReadyMarker) / has(ApprovedMarker). |
| 56 | +# --------------------------------------------------------------------------- |
| 57 | + |
| 58 | + |
| 59 | +@dataclass(slots=True) |
| 60 | +class DraftReadyMarker: |
| 61 | + """Attached by the agent when the draft is ready for review.""" |
| 62 | + |
| 63 | + |
| 64 | +@dataclass(slots=True) |
| 65 | +class ApprovedMarker: |
| 66 | + """Attached by the agent when the review is approved.""" |
| 67 | + |
| 68 | + |
| 69 | +# --------------------------------------------------------------------------- |
| 70 | +# Workflow spec |
| 71 | +# --------------------------------------------------------------------------- |
| 72 | + |
| 73 | +WRITING_WORKFLOW = workflow( |
| 74 | + workflow_id="writing-flow", |
| 75 | + initial="DRAFT", |
| 76 | + profiles={ |
| 77 | + "assistant": { |
| 78 | + "drafter": PromptProfileSpec( |
| 79 | + profile_id="drafter", |
| 80 | + prompt=( |
| 81 | + "You are a creative writing assistant in DRAFT mode.\n" |
| 82 | + "Help the user write and refine their draft.\n" |
| 83 | + "When the draft is ready, call the `mark_draft_ready` tool." |
| 84 | + ), |
| 85 | + ), |
| 86 | + "reviewer": PromptProfileSpec( |
| 87 | + profile_id="reviewer", |
| 88 | + prompt=( |
| 89 | + "You are a critical reviewer in REVIEW mode.\n" |
| 90 | + "Evaluate the draft for clarity, accuracy, and completeness.\n" |
| 91 | + "When you approve it, call the `approve_draft` tool.\n" |
| 92 | + "If you need revisions, just say so." |
| 93 | + ), |
| 94 | + ), |
| 95 | + } |
| 96 | + }, |
| 97 | + states={ |
| 98 | + "DRAFT": { |
| 99 | + "bind": {"assistant": "drafter"}, |
| 100 | + "go": { |
| 101 | + # Transition fires when DraftReadyMarker is present |
| 102 | + "REVIEW": has(DraftReadyMarker), |
| 103 | + }, |
| 104 | + }, |
| 105 | + "REVIEW": { |
| 106 | + "bind": {"assistant": "reviewer"}, |
| 107 | + "go": { |
| 108 | + # Transition fires when ApprovedMarker is present |
| 109 | + "DONE": has(ApprovedMarker), |
| 110 | + }, |
| 111 | + }, |
| 112 | + "DONE": { |
| 113 | + "bind": {"assistant": "reviewer"}, # terminal state — no outgoing transitions |
| 114 | + "go": {}, |
| 115 | + }, |
| 116 | + }, |
| 117 | +) |
| 118 | + |
| 119 | +# --------------------------------------------------------------------------- |
| 120 | +# Tools that agents call to drive transitions |
| 121 | +# --------------------------------------------------------------------------- |
| 122 | + |
| 123 | + |
| 124 | +def make_tool_registry(world: World, entity_id: EntityId) -> ToolRegistryComponent: |
| 125 | + @tool(name="mark_draft_ready", description="Signal that the draft is complete and ready for review.") |
| 126 | + def mark_draft_ready() -> str: |
| 127 | + world.add_component(entity_id, DraftReadyMarker()) |
| 128 | + logger.info("workflow_tool_called", tool="mark_draft_ready", entity_id=entity_id) |
| 129 | + return "Draft marked as ready. Moving to REVIEW state." |
| 130 | + |
| 131 | + @tool(name="approve_draft", description="Approve the draft and complete the workflow.") |
| 132 | + def approve_draft() -> str: |
| 133 | + world.add_component(entity_id, ApprovedMarker()) |
| 134 | + logger.info("workflow_tool_called", tool="approve_draft", entity_id=entity_id) |
| 135 | + return "Draft approved. Workflow complete." |
| 136 | + |
| 137 | + fns = [mark_draft_ready, approve_draft] |
| 138 | + tools_map: dict[str, ToolSchema] = { |
| 139 | + fn._tool_schema.name: fn._tool_schema # type: ignore[attr-defined] |
| 140 | + for fn in fns |
| 141 | + } |
| 142 | + handlers_map = { |
| 143 | + fn._tool_schema.name: fn._tool_handler # type: ignore[attr-defined] |
| 144 | + for fn in fns |
| 145 | + } |
| 146 | + return ToolRegistryComponent(tools=tools_map, handlers=handlers_map) |
| 147 | + |
| 148 | + |
| 149 | +# --------------------------------------------------------------------------- |
| 150 | +# Main |
| 151 | +# --------------------------------------------------------------------------- |
| 152 | + |
| 153 | + |
| 154 | +async def main() -> None: |
| 155 | + """Run the workflow agent example.""" |
| 156 | + configure_logging(json_output=False) |
| 157 | + |
| 158 | + # --- LLM model (dual-mode) --- |
| 159 | + api_key = os.environ.get("LLM_API_KEY", "") |
| 160 | + base_url = os.environ.get("LLM_BASE_URL", "https://dashscope.aliyuncs.com/compatible-mode/v1") |
| 161 | + model_id = os.environ.get("LLM_MODEL", "qwen3.5-flash") |
| 162 | + api_format_env = os.environ.get("LLM_API_FORMAT", "openai_chat_completions") |
| 163 | + |
| 164 | + model: LLMModel |
| 165 | + if api_key: |
| 166 | + api_format = ( |
| 167 | + ApiFormat.ANTHROPIC_MESSAGES |
| 168 | + if api_format_env == "anthropic_messages" |
| 169 | + else ApiFormat.OPENAI_CHAT_COMPLETIONS |
| 170 | + ) |
| 171 | + model = Model(model_id, base_url=base_url, api_key=api_key, api_format=api_format) |
| 172 | + print(f"Using real LLM: {model_id} @ {base_url}") |
| 173 | + else: |
| 174 | + # Fake responses that simulate the two-phase flow: |
| 175 | + # Turn 1 (DRAFT): agent writes something and calls mark_draft_ready |
| 176 | + # Turn 2 (REVIEW): agent reviews and calls approve_draft |
| 177 | + # Turn 3 (DONE): agent gives a final summary |
| 178 | + model = FakeModel( |
| 179 | + responses=[ |
| 180 | + CompletionResult( |
| 181 | + message=Message( |
| 182 | + role="assistant", |
| 183 | + content="Here is my draft: 'The quick brown fox…' I think it's ready!", |
| 184 | + tool_calls=[ToolCall(id="call_1", name="mark_draft_ready", arguments={})], |
| 185 | + ), |
| 186 | + ), |
| 187 | + CompletionResult( |
| 188 | + message=Message( |
| 189 | + role="assistant", |
| 190 | + content="This draft looks good. Clear and concise. Approving it.", |
| 191 | + tool_calls=[ToolCall(id="call_2", name="approve_draft", arguments={})], |
| 192 | + ), |
| 193 | + ), |
| 194 | + CompletionResult( |
| 195 | + message=Message( |
| 196 | + role="assistant", |
| 197 | + content="The draft has been approved and the workflow is complete!", |
| 198 | + ), |
| 199 | + ), |
| 200 | + ] |
| 201 | + ) |
| 202 | + print("No LLM_API_KEY set — using FakeModel.") |
| 203 | + |
| 204 | + # --- World setup --- |
| 205 | + world = World(name="writing-agent") |
| 206 | + agent = world.create_entity() |
| 207 | + |
| 208 | + # Attach LLM and conversation |
| 209 | + world.add_component(agent, LLMComponent(model=model)) |
| 210 | + world.add_component( |
| 211 | + agent, |
| 212 | + ConversationComponent( |
| 213 | + messages=[ |
| 214 | + Message( |
| 215 | + role="user", |
| 216 | + content="Please write a short paragraph about the benefits of ECS architecture.", |
| 217 | + ) |
| 218 | + ] |
| 219 | + ), |
| 220 | + ) |
| 221 | + |
| 222 | + # System prompt: the ${_workflow_state_prompt} placeholder is resolved by |
| 223 | + # WorkflowPromptPlaceholderProvider, which injects the active profile's text. |
| 224 | + world.add_component( |
| 225 | + agent, |
| 226 | + SystemPromptConfigSpec( |
| 227 | + template_source=PromptTemplateSource(inline="${_workflow_state_prompt}"), |
| 228 | + ), |
| 229 | + ) |
| 230 | + |
| 231 | + # Register tools |
| 232 | + world.add_component(agent, make_tool_registry(world, agent)) |
| 233 | + |
| 234 | + # Install the workflow (attaches WorkflowDefinitionComponent + WorkflowRuntimeComponent) |
| 235 | + install_workflow(world, agent, WRITING_WORKFLOW, agent_key="assistant") |
| 236 | + |
| 237 | + # --- Systems (order matters) --- |
| 238 | + # WorkflowStateSystem MUST run before SystemPromptRenderSystem so the |
| 239 | + # active profile is committed before the prompt is rendered. |
| 240 | + world.register_system(WorkflowStateSystem(priority=-25), priority=-25) |
| 241 | + world.register_system(SystemPromptRenderSystem(priority=-20), priority=-20) |
| 242 | + world.register_system(ReasoningSystem(priority=0), priority=0) |
| 243 | + world.register_system(ToolExecutionSystem(priority=5), priority=5) |
| 244 | + world.register_system(ErrorHandlingSystem(priority=99), priority=99) |
| 245 | + |
| 246 | + # --- Run --- |
| 247 | + runner = Runner() |
| 248 | + await runner.run(world, max_ticks=20) |
| 249 | + |
| 250 | + # --- Print results --- |
| 251 | + from ecs_agent.workflows._components import WorkflowRuntimeComponent |
| 252 | + |
| 253 | + runtime = world.get_component(agent, WorkflowRuntimeComponent) |
| 254 | + conv = world.get_component(agent, ConversationComponent) |
| 255 | + |
| 256 | + print("\n" + "=" * 60) |
| 257 | + print(f"Final workflow state : {runtime.current_state_id if runtime else 'unknown'}") |
| 258 | + if runtime: |
| 259 | + print(f"Transition history : {' → '.join(runtime.transition_history)}") |
| 260 | + print("=" * 60) |
| 261 | + |
| 262 | + if conv: |
| 263 | + print("\n--- Conversation ---") |
| 264 | + for msg in conv.messages: |
| 265 | + role = msg.role.upper() |
| 266 | + content = str(msg.content)[:200] |
| 267 | + print(f"[{role}] {content}") |
| 268 | + |
| 269 | + |
| 270 | +if __name__ == "__main__": |
| 271 | + asyncio.run(main()) |
0 commit comments