Skip to content

Commit 5584002

Browse files
examples: add workflow_agent — dual-mode DRAFT→REVIEW→DONE writing assistant
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
1 parent e5c9c23 commit 5584002

1 file changed

Lines changed: 271 additions & 0 deletions

File tree

examples/workflow_agent.py

Lines changed: 271 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,271 @@
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

Comments
 (0)