Skip to content

Commit 46bffcc

Browse files
authored
Merge pull request #494 from conductor-oss/fix/wmq-examples-2
Fix WMQ examples (78-84)
2 parents da1c95e + 714b289 commit 46bffcc

7 files changed

Lines changed: 116 additions & 69 deletions

examples/agents/78_approval_workflow.py

Lines changed: 64 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,24 @@
11
"""Approval Workflow — agent dynamically decides which tasks need human sign-off.
22
33
Demonstrates:
4-
- wait_for_message_tool as a dynamic approval gate driven by LLM reasoning
4+
- wait_for_message_tool as the task intake; flag_for_approval (a @tool) as a
5+
dynamic approval gate driven by LLM reasoning
56
- The agent itself decides mid-loop whether a task is risky, rather than
67
the workflow being designed with an explicit approval step upfront
78
- flag_for_approval blocks until the operator decides, returning "approve"
89
or "reject" directly — no second wait_for_message needed for the decision,
910
which prevents the agent from pulling the next task while approval is pending
1011
- Filesystem-based IPC between the main process and worker processes:
1112
tool workers run as separate OS processes (different PIDs, same filesystem),
12-
so @tool functions use sentinel files to communicate with the main thread
13-
- Clean shutdown: the agent responds with no tool calls on the stop signal,
14-
which lets the DoWhile loop exit naturally (workflow ends COMPLETED)
15-
16-
How this differs from examples 09a–09d (HITL):
17-
In 09a–09d the approval pause is a WaitTask node baked into the workflow
13+
so @tool functions use sentinel files to talk to the main process. The
14+
shared directory crosses process boundaries via APPROVAL_WORKFLOW_IPC_DIR —
15+
a per-import mkdtemp() would give every worker its own dir.
16+
- Deterministic stop: handle.stop() ends the loop once every task has been
17+
accounted for, without any stop-handling instructions in the prompt
18+
(workflow ends COMPLETED)
19+
20+
How this differs from examples 09–09d (HITL):
21+
In 09–09d the approval pause is a WaitTask node baked into the workflow
1822
definition at compile time — the workflow always pauses at that point
1923
regardless of the input. Here, the LLM inspects each incoming task and
2024
decides dynamically whether it is safe to execute immediately or requires
@@ -31,7 +35,7 @@
3135
blocks on flag_for_approval until the operator responds.
3236
3337
Requirements:
34-
- Conductor server running at http://localhost:8080
38+
- Conductor server with WMQ support (conductor.workflow-message-queue.enabled=true)
3539
- CONDUCTOR_SERVER_URL=http://localhost:8080/api as environment variable
3640
- CONDUCTOR_AGENT_LLM_MODEL=openai/gpt-4o-mini as environment variable
3741
"""
@@ -49,12 +53,19 @@
4953
from settings import settings
5054

5155
# Shared directory for IPC between main process and worker processes.
52-
# Workers run as separate OS processes (different PIDs, same filesystem).
53-
_ipc_dir = Path(tempfile.mkdtemp(prefix="approval_workflow_"))
56+
# Workers run as separate OS processes (different PIDs, same filesystem) that
57+
# re-import this module, so the directory is passed down via an env var — a
58+
# per-import mkdtemp() would give every worker its own dir and break the IPC.
59+
_IPC_DIR_ENV = "APPROVAL_WORKFLOW_IPC_DIR"
60+
if _IPC_DIR_ENV in os.environ:
61+
_ipc_dir = Path(os.environ[_IPC_DIR_ENV])
62+
else:
63+
_ipc_dir = Path(tempfile.mkdtemp(prefix="approval_workflow_"))
64+
os.environ[_IPC_DIR_ENV] = str(_ipc_dir)
5465
_APPROVAL_DIR = _ipc_dir / "approvals"
5566
_DONE_DIR = _ipc_dir / "done"
56-
_APPROVAL_DIR.mkdir()
57-
_DONE_DIR.mkdir()
67+
_APPROVAL_DIR.mkdir(exist_ok=True)
68+
_DONE_DIR.mkdir(exist_ok=True)
5869

5970

6071
@tool
@@ -94,7 +105,7 @@ def log_rejection(task: str) -> str:
94105

95106
receive_message = wait_for_message_tool(
96107
name="wait_for_message",
97-
description="Dequeue the next task or stop signal ({stop: true}).",
108+
description="Dequeue the next task to process.",
98109
)
99110

100111
agent = Agent(
@@ -127,35 +138,43 @@ def log_rejection(task: str) -> str:
127138
"Grant admin access to user@example.com",
128139
]
129140

130-
try:
131-
with AgentRuntime() as runtime:
132-
handle = runtime.start(agent, "Start processing the task queue.")
133-
execution_id = handle.execution_id
134-
time.sleep(4)
135-
print(f"Agent started: {execution_id}\n")
136-
137-
print("Dispatching all tasks...\n")
138-
for task in TASKS:
139-
print(f" → {task!r}")
140-
runtime.send_message(execution_id, {"task": task})
141-
142-
# Poll for approval requests; write decision files to unblock the tool.
143-
# Poll for completions to know when to send the stop signal.
144-
while len(list(_DONE_DIR.iterdir())) < len(TASKS):
145-
for req in sorted(_APPROVAL_DIR.glob("*.json")):
146-
data = json.loads(req.read_text())
147-
req.unlink()
148-
print(f"\n ⚠ APPROVAL REQUIRED")
149-
print(f" Task: {data['task']}")
150-
print(f" Reason: {data['reason']}\n")
151-
answer = input(" Approve? [Y/N]: ").strip().upper()
152-
decision = "approve" if answer == "Y" else "reject"
153-
req.with_suffix(".decision").write_text(decision)
154-
time.sleep(0.1)
155-
156-
# Deterministic stop — no stop-handling instructions needed.
157-
handle.stop()
158-
handle.join(timeout=30)
159-
print("\nDone.")
160-
finally:
161-
shutil.rmtree(_ipc_dir, ignore_errors=True)
141+
def main() -> None:
142+
try:
143+
with AgentRuntime() as runtime:
144+
handle = runtime.start(agent, "Start processing the task queue.")
145+
execution_id = handle.execution_id
146+
time.sleep(4)
147+
print(f"Agent started: {execution_id}\n")
148+
149+
print("Dispatching all tasks...\n")
150+
for task in TASKS:
151+
print(f" → {task!r}")
152+
runtime.send_message(execution_id, {"task": task})
153+
154+
# Poll for approval requests; write decision files to unblock the tool.
155+
# Poll for completions to know when to send the stop signal.
156+
while len(list(_DONE_DIR.iterdir())) < len(TASKS):
157+
for req in sorted(_APPROVAL_DIR.glob("*.json")):
158+
data = json.loads(req.read_text())
159+
req.unlink()
160+
print("\n ⚠ APPROVAL REQUIRED")
161+
print(f" Task: {data['task']}")
162+
print(f" Reason: {data['reason']}\n")
163+
answer = input(" Approve? [Y/N]: ").strip().upper()
164+
decision = "approve" if answer == "Y" else "reject"
165+
req.with_suffix(".decision").write_text(decision)
166+
time.sleep(0.1)
167+
168+
# Deterministic stop — no stop-handling instructions needed.
169+
handle.stop()
170+
handle.join(timeout=30)
171+
print("\nDone.")
172+
finally:
173+
shutil.rmtree(_ipc_dir, ignore_errors=True)
174+
175+
176+
# Guard the runtime block: spawned tool workers re-import this module, and
177+
# without the guard they would re-run the orchestration (multiprocessing's
178+
# "Safe importing of main module" error).
179+
if __name__ == "__main__":
180+
main()

examples/agents/79_agent_message_bus.py

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,16 @@
33
Demonstrates:
44
- Agent-to-agent messaging: one running agent sending messages directly
55
into another running agent's WMQ via runtime.send_message()
6-
- A tool that closes over an execution_id to forward results downstream
6+
- Module-level tools that pick up runtime values from the environment:
7+
forward_to_writer reads the Writer's execution id from
8+
MESSAGE_BUS_WRITER_EXECUTION_ID, since a tool that closed over it could not
9+
be pickled to its spawned worker process
710
- Parallel agent pipelines: researcher → writer running concurrently
8-
- Filesystem-based IPC: forward_to_writer writes sentinel files so the main
9-
thread knows when all topics have been forwarded
11+
- Filesystem-based IPC between the main process and worker processes:
12+
forward_to_writer and publish each write sentinel files, so the main process
13+
can tell forwarding from publishing. The barrier waits on publish — the
14+
Researcher forwards the last topic while the Writer is still mid-turn on it,
15+
so stopping at "all forwarded" would cut the final paragraph.
1016
- Deterministic stop: handle.stop() exits each agent's loop gracefully
1117
1218
How this differs from 06_sequential_pipeline:
@@ -26,7 +32,7 @@
2632
Researcher autonomously drives the Writer.
2733
2834
Requirements:
29-
- Conductor server running at http://localhost:8080
35+
- Conductor server with WMQ support (conductor.workflow-message-queue.enabled=true)
3036
- CONDUCTOR_SERVER_URL=http://localhost:8080/api as environment variable
3137
- CONDUCTOR_AGENT_LLM_MODEL=openai/gpt-4o-mini as environment variable
3238
"""
@@ -48,8 +54,10 @@
4854
else:
4955
_ipc_dir = Path(tempfile.mkdtemp(prefix="message_bus_"))
5056
os.environ[_IPC_DIR_ENV] = str(_ipc_dir)
51-
_FORWARDED_DIR = _ipc_dir / "forwarded" # one file per forwarded topic
57+
_FORWARDED_DIR = _ipc_dir / "forwarded" # one file per topic forwarded by the Researcher
5258
_FORWARDED_DIR.mkdir(exist_ok=True)
59+
_PUBLISHED_DIR = _ipc_dir / "published" # one file per paragraph published by the Writer
60+
_PUBLISHED_DIR.mkdir(exist_ok=True)
5361

5462
TOPICS = [
5563
"the impact of edge computing on cloud infrastructure",
@@ -101,6 +109,7 @@ def publish(topic: str, paragraph: str) -> str:
101109
"""Publish the finished paragraph."""
102110
print(f"\n [writer] ── {topic} ──")
103111
print(f" {paragraph}\n")
112+
(_PUBLISHED_DIR / f"{time.time_ns()}.done").touch()
104113
return "published"
105114

106115

@@ -152,8 +161,18 @@ def main() -> None:
152161
print(f" → {topic!r}")
153162
runtime.send_message(researcher_id, {"topic": topic})
154163

155-
# Wait until all topics have been forwarded to the Writer
156-
while len(list(_FORWARDED_DIR.iterdir())) < len(TOPICS):
164+
# Wait until the Writer has published every paragraph. Gating on
165+
# _FORWARDED_DIR is not enough: the Researcher forwards the last topic
166+
# while the Writer is still mid-turn on it, so stopping there would cut
167+
# the final paragraph and can leave the Researcher's stop() racing an
168+
# in-flight iteration.
169+
deadline = time.monotonic() + 180
170+
while len(list(_PUBLISHED_DIR.iterdir())) < len(TOPICS):
171+
if time.monotonic() > deadline:
172+
raise TimeoutError(
173+
f"Writer published {len(list(_PUBLISHED_DIR.iterdir()))} of "
174+
f"{len(TOPICS)} paragraphs before the deadline."
175+
)
157176
time.sleep(0.1)
158177

159178
# Deterministic stop — no stop-handling instructions needed.

examples/agents/80_live_dashboard.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@
3737
Requirements:
3838
- Conductor server running at http://localhost:8080
3939
- CONDUCTOR_SERVER_URL=http://localhost:8080/api as environment variable
40-
- CONDUCTOR_AGENT_LLM_MODEL=anthropic/claude-sonnet-4-20250514 as environment variable
40+
- CONDUCTOR_AGENT_LLM_MODEL=anthropic/claude-sonnet-5 as environment variable
4141
"""
4242

4343
import json

examples/agents/81_chat_repl.py

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,10 @@
1515
communicated via the shared filesystem rather than an in-process queue.
1616
1717
Resume support:
18-
The REPL saves the execution_id to a session file on start. On subsequent
19-
runs, pass ``--resume`` to reconnect to the same workflow. ``resume()``
18+
The REPL saves the execution_id to a session file on start. Leave with
19+
``/disconnect`` to exit the console without stopping the agent (``quit`` /
20+
``exit`` stop it), then pass ``--resume`` on a later run to reconnect to the
21+
same workflow. ``resume()``
2022
fetches the workflow from the server, extracts the worker domain from
2123
``taskToDomain``, and re-registers tools under that domain — so stateful
2224
agents resume correctly. Conversation history is not restored in the
@@ -30,7 +32,7 @@
3032
activate predefined text-processing tasks at runtime. The agent is notified
3133
via a WMQ message and can start using the new capability immediately.
3234
33-
Built-in tasks (activate with /tool <name>):
35+
Built-in tasks (activate with /tool <name>, list the active ones with /tools):
3436
word_count — count words in input
3537
char_count — count characters in input
3638
reverse — reverse the input string
@@ -41,9 +43,9 @@
4143
bullet_split — split input into one bullet point per sentence
4244
4345
Requirements:
44-
- Conductor server running at http://localhost:8080
46+
- Conductor server with WMQ support (conductor.workflow-message-queue.enabled=true)
4547
- CONDUCTOR_SERVER_URL=http://localhost:8080/api as environment variable
46-
- CONDUCTOR_AGENT_LLM_MODEL=anthropic/claude-sonnet-4-20250514 as environment variable
48+
- CONDUCTOR_AGENT_LLM_MODEL=anthropic/claude-sonnet-5 as environment variable
4749
"""
4850

4951
import argparse

examples/agents/82_coding_agent.py

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,9 @@
1515
python 82_coding_agent.py --resume # resume last session
1616
1717
Requirements:
18-
- Conductor server running at http://localhost:8080
18+
- Conductor server with WMQ support (conductor.workflow-message-queue.enabled=true)
1919
- CONDUCTOR_SERVER_URL=http://localhost:8080/api
20-
- CONDUCTOR_AGENT_LLM_MODEL=anthropic/claude-sonnet-4-20250514
20+
- CONDUCTOR_AGENT_LLM_MODEL=anthropic/claude-sonnet-5
2121
"""
2222

2323
import argparse
@@ -461,9 +461,12 @@ def build_agent(working_dir: str, shell_timeout: int = _DEFAULT_SHELL_TIMEOUT) -
461461
- run_shell(command) run a shell command (cwd: {working_dir}, timeout: {shell_timeout}s)
462462
- find_files(pattern, path=".") find files by glob, e.g. "**/*.py"
463463
- search_in_files(regex, path=".", file_glob) grep files by regex
464-
- reply_to_user(message) send your response to the user
464+
- reply_to_user(message) REQUIRED — how the user sees your answer
465465
466466
Rules:
467+
- You MUST call reply_to_user before calling wait_for_message again. It is the only
468+
channel the user can see — plain text replies are discarded.
469+
- Never call wait_for_message twice in a row. Every task ends with reply_to_user.
467470
- Work autonomously. Do not ask for permission before reading files, running commands, or writing.
468471
- Make as many tool calls as needed to fully complete the task before replying.
469472
- Keep replies concise: what was done, what changed, key output. No lengthy explanations.
@@ -474,8 +477,8 @@ def build_agent(working_dir: str, shell_timeout: int = _DEFAULT_SHELL_TIMEOUT) -
474477
1. Call wait_for_message to receive the next task.
475478
2. Think through the task. Explore, read, search, modify, and run as needed.
476479
3. Complete the task fully.
477-
4. Call reply_to_user with a concise summary.
478-
5. Return to step 1 immediately.
480+
4. Call reply_to_user with a concise summary. Never skip this step.
481+
5. Only then return to step 1.
479482
""",
480483
)
481484

examples/agents/82_fan_out_fan_in.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,16 +21,19 @@
2121
- Filesystem IPC:
2222
* Workers write sentinels after submit_answer so main counts completions
2323
* Collector writes reports to files; main thread reads and prints them
24-
- No time.sleep() to assume message delivery — synchronisation via files
24+
- Result delivery is never assumed from elapsed time — every wait is on a
25+
sentinel file. The one bare sleep is a startup grace period, letting the
26+
agents reach their first wait call before the first message is sent.
2527
2628
Scenario:
2729
A research Orchestrator fans out each question to three Worker agents
2830
(alpha, beta, gamma) that produce independent short answers. The Collector
2931
aggregates the three answers into a side-by-side comparison report.
3032
3133
Requirements:
32-
- Conductor server running (CONDUCTOR_SERVER_URL / CONDUCTOR_SERVER_URL)
33-
- CONDUCTOR_AGENT_LLM_MODEL set to a working model
34+
- Conductor server with WMQ support (conductor.workflow-message-queue.enabled=true)
35+
- CONDUCTOR_SERVER_URL=http://localhost:8080/api as environment variable
36+
- CONDUCTOR_AGENT_LLM_MODEL=openai/gpt-4o-mini as environment variable
3437
"""
3538

3639
import json

examples/agents/84_deterministic_stop.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@
88
99
How it works:
1010
The server compiles every agent's DoWhile loop with a ``_stop_requested``
11-
workflow variable in its condition. When ``handle.stop()`` is called, the
12-
SDK sets this variable to ``true`` via Conductor's ``updateVariables`` API.
11+
workflow variable in its condition. ``handle.stop()`` POSTs to
12+
``/agent/{execution_id}/stop`` and the server sets that variable to ``true``.
1313
The loop condition evaluates to ``false`` on the next check, and the loop
1414
exits. The LLM cannot override this — it's checked by Conductor, not the
1515
LLM.
@@ -28,7 +28,8 @@
2828
The LLM could ignore this. handle.stop() makes this unnecessary.
2929
3030
Requirements:
31-
- Conductor server (with _stop_requested support in compiler)
31+
- Conductor server with WMQ support (conductor.workflow-message-queue.enabled=true)
32+
and _stop_requested support in the compiler
3233
- CONDUCTOR_SERVER_URL=http://localhost:8080/api as environment variable
3334
- CONDUCTOR_AGENT_LLM_MODEL=openai/gpt-4o-mini as environment variable
3435
"""

0 commit comments

Comments
 (0)