| 129 | |
| 130 | |
| 131 | def make_workflow_tool( |
| 132 | registry: Any, |
| 133 | provider: Any = None, |
| 134 | *, |
| 135 | runner_factory: Optional[Callable[[ToolContext, str], Any]] = None, |
| 136 | ) -> Tool: |
| 137 | factory = runner_factory or _default_runner_factory(registry, provider) |
| 138 | |
| 139 | async def _call(tool_input: dict, context: ToolContext) -> ToolResult: |
| 140 | if not is_workflows_enabled(): |
| 141 | return ToolResult(name=WORKFLOW_TOOL_NAME, output={"error": "dynamic workflows are disabled"}, is_error=True) |
| 142 | |
| 143 | source, error = _resolve_source(tool_input, context.cwd) |
| 144 | if source is None: |
| 145 | return ToolResult(name=WORKFLOW_TOOL_NAME, output={"error": error}, is_error=True) |
| 146 | |
| 147 | run_id = "wf_" + uuid.uuid4().hex[:12] |
| 148 | task_id = generate_task_id("local_workflow") |
| 149 | from src.agent.transcript import get_workflow_run_path |
| 150 | |
| 151 | output_file = get_workflow_run_path(run_id) |
| 152 | runner = factory(context, run_id) |
| 153 | |
| 154 | # Same-session resume: replay the prior run's journal if asked. |
| 155 | resume = None |
| 156 | prior_run_id = tool_input.get("resume_from_run_id") |
| 157 | if isinstance(prior_run_id, str) and prior_run_id.strip(): |
| 158 | from src.workflow.launch import load_journal |
| 159 | |
| 160 | try: |
| 161 | resume = load_journal(get_workflow_run_path(prior_run_id)) |
| 162 | except ValueError: |
| 163 | resume = None # malformed run id |
| 164 | |
| 165 | coro = run_workflow_task( |
| 166 | source=source, |
| 167 | runner=runner, |
| 168 | registry=context.runtime_tasks, |
| 169 | task_id=task_id, |
| 170 | run_id=run_id, |
| 171 | output_file=output_file, |
| 172 | args=tool_input.get("args"), |
| 173 | resume=resume, |
| 174 | tool_use_id=context.agent_id, |
| 175 | ) |
| 176 | |
| 177 | # Launch on a dedicated daemon thread that owns the run to completion. |
| 178 | # The production dispatch path executes this tool's ``call`` inside a |
| 179 | # throwaway ``asyncio.run`` loop (on a worker thread), so scheduling on |
| 180 | # the *current* loop would be torn down the instant we return the handle |
| 181 | # — the background run must outlive this call. ``task_manager.start`` |
| 182 | # invokes ``target(stop_event)``, hence the ``_stop`` parameter. |
| 183 | context.task_manager.start( |
| 184 | name=f"workflow:{run_id}", |
| 185 | target=lambda _stop: asyncio.run(coro), |
| 186 | ) |
| 187 | |
| 188 | return ToolResult( |