|
1 | 1 | """Approval Workflow — agent dynamically decides which tasks need human sign-off. |
2 | 2 |
|
3 | 3 | 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 |
5 | 6 | - The agent itself decides mid-loop whether a task is risky, rather than |
6 | 7 | the workflow being designed with an explicit approval step upfront |
7 | 8 | - flag_for_approval blocks until the operator decides, returning "approve" |
8 | 9 | or "reject" directly — no second wait_for_message needed for the decision, |
9 | 10 | which prevents the agent from pulling the next task while approval is pending |
10 | 11 | - Filesystem-based IPC between the main process and worker processes: |
11 | 12 | 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 |
18 | 22 | definition at compile time — the workflow always pauses at that point |
19 | 23 | regardless of the input. Here, the LLM inspects each incoming task and |
20 | 24 | decides dynamically whether it is safe to execute immediately or requires |
|
31 | 35 | blocks on flag_for_approval until the operator responds. |
32 | 36 |
|
33 | 37 | Requirements: |
34 | | - - Conductor server running at http://localhost:8080 |
| 38 | + - Conductor server with WMQ support (conductor.workflow-message-queue.enabled=true) |
35 | 39 | - CONDUCTOR_SERVER_URL=http://localhost:8080/api as environment variable |
36 | 40 | - CONDUCTOR_AGENT_LLM_MODEL=openai/gpt-4o-mini as environment variable |
37 | 41 | """ |
|
49 | 53 | from settings import settings |
50 | 54 |
|
51 | 55 | # 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) |
54 | 65 | _APPROVAL_DIR = _ipc_dir / "approvals" |
55 | 66 | _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) |
58 | 69 |
|
59 | 70 |
|
60 | 71 | @tool |
@@ -94,7 +105,7 @@ def log_rejection(task: str) -> str: |
94 | 105 |
|
95 | 106 | receive_message = wait_for_message_tool( |
96 | 107 | name="wait_for_message", |
97 | | - description="Dequeue the next task or stop signal ({stop: true}).", |
| 108 | + description="Dequeue the next task to process.", |
98 | 109 | ) |
99 | 110 |
|
100 | 111 | agent = Agent( |
@@ -127,35 +138,43 @@ def log_rejection(task: str) -> str: |
127 | 138 | "Grant admin access to user@example.com", |
128 | 139 | ] |
129 | 140 |
|
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() |
0 commit comments