capability-runtime 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. capability_runtime/__init__.py +90 -0
  2. capability_runtime/adapters/__init__.py +13 -0
  3. capability_runtime/adapters/agent_adapter.py +439 -0
  4. capability_runtime/adapters/agently_backend.py +423 -0
  5. capability_runtime/adapters/triggerflow_workflow_engine.py +865 -0
  6. capability_runtime/adapters/workflow_engine.py +43 -0
  7. capability_runtime/config.py +172 -0
  8. capability_runtime/errors.py +20 -0
  9. capability_runtime/guards.py +150 -0
  10. capability_runtime/host_protocol.py +400 -0
  11. capability_runtime/host_toolkit/__init__.py +55 -0
  12. capability_runtime/host_toolkit/approvals_profiles.py +94 -0
  13. capability_runtime/host_toolkit/evidence_hooks.py +65 -0
  14. capability_runtime/host_toolkit/history.py +74 -0
  15. capability_runtime/host_toolkit/invoke_capability.py +409 -0
  16. capability_runtime/host_toolkit/resume.py +317 -0
  17. capability_runtime/host_toolkit/system_prompt.py +132 -0
  18. capability_runtime/host_toolkit/turn_delta.py +128 -0
  19. capability_runtime/logging_utils.py +94 -0
  20. capability_runtime/manifest.py +173 -0
  21. capability_runtime/output_validator.py +139 -0
  22. capability_runtime/protocol/__init__.py +43 -0
  23. capability_runtime/protocol/agent.py +62 -0
  24. capability_runtime/protocol/capability.py +98 -0
  25. capability_runtime/protocol/chat_backend.py +38 -0
  26. capability_runtime/protocol/context.py +244 -0
  27. capability_runtime/protocol/workflow.py +119 -0
  28. capability_runtime/registry.py +287 -0
  29. capability_runtime/reporting/__init__.py +2 -0
  30. capability_runtime/reporting/node_report.py +497 -0
  31. capability_runtime/runtime.py +930 -0
  32. capability_runtime/runtime_ui_events_mixin.py +310 -0
  33. capability_runtime/sdk_lifecycle.py +982 -0
  34. capability_runtime/service_facade.py +418 -0
  35. capability_runtime/services.py +181 -0
  36. capability_runtime/structured_output.py +208 -0
  37. capability_runtime/structured_stream.py +38 -0
  38. capability_runtime/types.py +103 -0
  39. capability_runtime/ui_events/__init__.py +19 -0
  40. capability_runtime/ui_events/projector.py +617 -0
  41. capability_runtime/ui_events/session.py +292 -0
  42. capability_runtime/ui_events/store.py +127 -0
  43. capability_runtime/ui_events/transport.py +33 -0
  44. capability_runtime/ui_events/v1.py +76 -0
  45. capability_runtime/upstream_compat.py +182 -0
  46. capability_runtime/utils/__init__.py +1 -0
  47. capability_runtime/utils/usage.py +65 -0
  48. capability_runtime/workflow_runtime.py +218 -0
  49. capability_runtime-0.1.0.dist-info/METADATA +232 -0
  50. capability_runtime-0.1.0.dist-info/RECORD +52 -0
  51. capability_runtime-0.1.0.dist-info/WHEEL +5 -0
  52. capability_runtime-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,865 @@
1
+ """WorkflowEngine 的 TriggerFlow 实现(内部细节,不对外暴露)。"""
2
+ from __future__ import annotations
3
+
4
+ import asyncio
5
+ import uuid
6
+ from contextlib import suppress
7
+ from dataclasses import dataclass
8
+ from types import MappingProxyType
9
+ from typing import Any, AsyncIterator, Dict, List, Optional, cast
10
+
11
+ from agently import TriggerFlow
12
+
13
+ from ..host_protocol import build_approval_ticket_from_report
14
+ from ..logging_utils import log_suppressed_exception
15
+ from ..protocol.capability import CapabilityResult, CapabilityStatus
16
+ from ..protocol.context import ExecutionContext, RecursionLimitError
17
+ from ..protocol.workflow import (
18
+ ConditionalStep,
19
+ InputMapping,
20
+ LoopStep,
21
+ ParallelStep,
22
+ Step,
23
+ WorkflowSpec,
24
+ WorkflowStep,
25
+ )
26
+ from ..services import RuntimeServices
27
+ from .workflow_engine import WorkflowStreamEvent, WorkflowStreamItem
28
+
29
+
30
+ _WF_WORKFLOW_ID_KEY = "__wf_workflow_id"
31
+ _WF_WORKFLOW_INSTANCE_ID_KEY = "__wf_workflow_instance_id"
32
+ _WF_STEP_ID_KEY = "__wf_step_id"
33
+ _WF_BRANCH_ID_KEY = "__wf_branch_id"
34
+
35
+
36
+ @dataclass
37
+ class _WorkflowContextHolder:
38
+ """
39
+ 可变 context 容器(消除 nonlocal 语义歧义)。
40
+
41
+ 说明:
42
+ - ExecutionContext 字段语义上应被视为不可变;
43
+ - LoopStep.collect_as 需要更新 context.bag;
44
+ - 通过持有可变引用来传递更新,而非使用 nonlocal 重绑定。
45
+ """
46
+
47
+ context: ExecutionContext
48
+
49
+
50
+ def _to_step_result_dict(result: CapabilityResult) -> Dict[str, Any]:
51
+ """把 CapabilityResult 归一为 workflow step_results 的最小可编排结构。"""
52
+
53
+ return {
54
+ "status": getattr(result.status, "value", str(result.status)),
55
+ "output": result.output,
56
+ "error": result.error,
57
+ "report": result.report or result.node_report,
58
+ "node_report": result.node_report,
59
+ }
60
+
61
+
62
+ class TriggerFlowWorkflowEngine:
63
+ """
64
+ 基于 Agently TriggerFlow 的 Workflow 执行引擎。
65
+
66
+ 设计说明:
67
+ - TriggerFlow 仅作为内部编排执行器;
68
+ - Runtime 对外仍只暴露 `run()`/`run_stream()`;
69
+ - Workflow 流式输出仅提供轻量事件字典,深审计仍依赖 WAL/events。
70
+ """
71
+
72
+ def _build_fail_closed_result(
73
+ self,
74
+ *,
75
+ services: RuntimeServices,
76
+ context: ExecutionContext,
77
+ workflow_id: str,
78
+ error: str,
79
+ error_code: str,
80
+ reason: str,
81
+ completion_reason: str,
82
+ meta: Optional[Dict[str, Any]] = None,
83
+ ) -> CapabilityResult:
84
+ report = services.build_fail_closed_report(
85
+ run_id=context.run_id,
86
+ status="failed",
87
+ reason=reason,
88
+ completion_reason=completion_reason,
89
+ meta={"workflow_id": workflow_id, **(meta or {})},
90
+ )
91
+ return CapabilityResult(
92
+ status=CapabilityStatus.FAILED,
93
+ error=error,
94
+ error_code=error_code,
95
+ report=report,
96
+ node_report=report,
97
+ )
98
+
99
+ async def execute(
100
+ self,
101
+ *,
102
+ spec: WorkflowSpec,
103
+ input: Dict[str, Any],
104
+ context: ExecutionContext,
105
+ services: RuntimeServices,
106
+ ) -> CapabilityResult:
107
+ """执行 Workflow(非流式)。"""
108
+
109
+ terminal: CapabilityResult | None = None
110
+ async for item in self.execute_stream(spec=spec, input=input, context=context, services=services):
111
+ if isinstance(item, CapabilityResult):
112
+ terminal = item
113
+ if terminal is not None:
114
+ return terminal
115
+ report = services.build_fail_closed_report(
116
+ run_id=context.run_id,
117
+ status="failed",
118
+ reason="engine_error",
119
+ completion_reason="missing_terminal_result",
120
+ meta={"workflow_id": spec.base.id, "source": "workflow.execute"},
121
+ )
122
+ return CapabilityResult(
123
+ status=CapabilityStatus.FAILED,
124
+ error="Workflow execution produced no result",
125
+ error_code="ENGINE_ERROR",
126
+ report=report,
127
+ node_report=report,
128
+ )
129
+
130
+ async def execute_stream(
131
+ self,
132
+ *,
133
+ spec: WorkflowSpec,
134
+ input: Dict[str, Any],
135
+ context: ExecutionContext,
136
+ services: RuntimeServices,
137
+ ) -> AsyncIterator[WorkflowStreamItem]:
138
+ """
139
+ 执行 Workflow(流式)。
140
+
141
+ 事件策略:
142
+ - 仅输出轻量 workflow 事件(workflow started/finished + step started/finished);
143
+ - 不透传上游全量 AgentEvent,避免把深审计负担带到默认流里。
144
+ - 深审计与编排分支依据应读取 WAL/events + NodeReport(真相源),而非依赖轻量事件的 payload 细节。
145
+ """
146
+
147
+ workflow_instance_id = uuid.uuid4().hex
148
+ context = context.with_bag_overlay(**(input or {}))
149
+ context = context.with_bag_overlay(
150
+ **{
151
+ _WF_WORKFLOW_ID_KEY: str(spec.base.id),
152
+ _WF_WORKFLOW_INSTANCE_ID_KEY: str(workflow_instance_id),
153
+ }
154
+ )
155
+ # 使用 _WorkflowContextHolder 替代 nonlocal context,消除闭包状态语义歧义
156
+ context_holder = _WorkflowContextHolder(context=context)
157
+ event_queue: asyncio.Queue[WorkflowStreamEvent | object] = asyncio.Queue()
158
+ terminal_holder: Dict[str, CapabilityResult] = {}
159
+ queue_stop = object()
160
+
161
+ async def emit(event: WorkflowStreamEvent) -> None:
162
+ await event_queue.put(event)
163
+
164
+ await emit(
165
+ {
166
+ "type": "workflow.started",
167
+ "run_id": context.run_id,
168
+ "workflow_id": spec.base.id,
169
+ "workflow_instance_id": workflow_instance_id,
170
+ }
171
+ )
172
+
173
+ flow = TriggerFlow(name=f"runtime-workflow-{spec.base.id}-{context.run_id[:8]}")
174
+
175
+ @flow.chunk("bootstrap")
176
+ async def bootstrap(data: Any) -> Dict[str, Any]:
177
+ payload = getattr(data, "value", None)
178
+ if isinstance(payload, dict):
179
+ return payload
180
+ return {"__terminal_result__": None}
181
+
182
+ chain = flow.to(bootstrap)
183
+
184
+ for index, step in enumerate(spec.steps):
185
+
186
+ async def run_step(
187
+ data: Any,
188
+ *,
189
+ bound_step: WorkflowStep = step,
190
+ bound_index: int = index,
191
+ ) -> Dict[str, Any]:
192
+ payload_raw = getattr(data, "value", None)
193
+ payload: Dict[str, Any] = dict(payload_raw) if isinstance(payload_raw, dict) else {}
194
+ terminal = payload.get("__terminal_result__")
195
+ if isinstance(terminal, CapabilityResult):
196
+ # 终态已确定,后续 chunk 跳过执行(保持 stop-on-non-success 语义)。
197
+ return payload
198
+
199
+ step_id = getattr(bound_step, "id", f"step_{bound_index}")
200
+ step_context = context_holder.context.with_bag_overlay(**{_WF_STEP_ID_KEY: str(step_id)})
201
+
202
+ # 取消语义(协作式):
203
+ # - 当前 step 执行中取消:不强制中断,由 _execute_step 内部与下一个 step 边界决定;
204
+ # - 下一步开始前已取消:不得发出 step.started(避免误导为"已开始执行")。
205
+ if step_context.cancel_token is not None and step_context.cancel_token.is_cancelled:
206
+ report = services.build_fail_closed_report(
207
+ run_id=step_context.run_id,
208
+ status="incomplete",
209
+ reason="cancelled",
210
+ completion_reason="run_cancelled",
211
+ meta={"workflow_id": spec.base.id, "step_id": step_id},
212
+ )
213
+ payload["__terminal_result__"] = CapabilityResult(
214
+ status=CapabilityStatus.CANCELLED,
215
+ error="execution cancelled",
216
+ error_code="RUN_CANCELLED",
217
+ report=report,
218
+ node_report=report,
219
+ )
220
+ return payload
221
+
222
+ await emit(
223
+ {
224
+ "type": "workflow.step.started",
225
+ "run_id": context_holder.context.run_id,
226
+ "workflow_id": spec.base.id,
227
+ "workflow_instance_id": workflow_instance_id,
228
+ "step_id": step_id,
229
+ "capability_id": self._step_capability_id(bound_step),
230
+ }
231
+ )
232
+
233
+ result = await self._execute_step(cast(Any, bound_step), context=step_context, services=services)
234
+ # 顶层 LoopStep.collect_as 需要把结果写回 workflow 级 bag,供后续步骤使用。
235
+ # 使用 _WorkflowContextHolder 显式更新,而非 nonlocal 重绑定
236
+ if isinstance(bound_step, LoopStep) and result.status == CapabilityStatus.SUCCESS and bound_step.collect_as:
237
+ context_holder.context = context_holder.context.with_bag_overlay(
238
+ **{str(bound_step.collect_as): result.output}
239
+ )
240
+
241
+ approval_ticket = build_approval_ticket_from_report(result.node_report, capability_id=spec.base.id)
242
+ await emit(
243
+ {
244
+ "type": "workflow.step.finished",
245
+ "run_id": context_holder.context.run_id,
246
+ "workflow_id": spec.base.id,
247
+ "workflow_instance_id": workflow_instance_id,
248
+ "step_id": step_id,
249
+ "capability_id": self._step_capability_id(bound_step),
250
+ "status": getattr(result.status, "value", str(result.status)),
251
+ "error": result.error,
252
+ "waiting_approval_key": approval_ticket.approval_key if approval_ticket is not None else None,
253
+ }
254
+ )
255
+
256
+ if result.status != CapabilityStatus.SUCCESS:
257
+ payload["__terminal_result__"] = result
258
+ return payload
259
+
260
+ chain = chain.to((f"wf_step_{index}_{getattr(step, 'id', index)}", run_step))
261
+
262
+ @flow.chunk("finalize")
263
+ async def finalize(data: Any) -> CapabilityResult:
264
+ payload_raw = getattr(data, "value", None)
265
+ payload: Dict[str, Any] = dict(payload_raw) if isinstance(payload_raw, dict) else {}
266
+ terminal = payload.get("__terminal_result__")
267
+
268
+ if isinstance(terminal, CapabilityResult):
269
+ result = terminal
270
+ else:
271
+ output = self._resolve_output_mappings(spec.output_mappings, context_holder.context)
272
+ if output is None:
273
+ output = dict(context_holder.context.step_outputs)
274
+ result = CapabilityResult(status=CapabilityStatus.SUCCESS, output=output)
275
+
276
+ terminal_holder["result"] = result
277
+ await emit(
278
+ {
279
+ "type": "workflow.finished",
280
+ "run_id": context_holder.context.run_id,
281
+ "workflow_id": spec.base.id,
282
+ "workflow_instance_id": workflow_instance_id,
283
+ "status": getattr(result.status, "value", str(result.status)),
284
+ }
285
+ )
286
+ return result
287
+
288
+ chain.to(finalize).end()
289
+
290
+ async def run_flow() -> None:
291
+ try:
292
+ result = await flow.async_start(
293
+ {"__terminal_result__": None},
294
+ wait_for_result=True,
295
+ timeout=None,
296
+ )
297
+ if isinstance(result, CapabilityResult):
298
+ terminal_holder.setdefault("result", result)
299
+ except Exception as exc:
300
+ log_suppressed_exception(
301
+ context="workflow_triggerflow_engine",
302
+ exc=exc,
303
+ run_id=context_holder.context.run_id,
304
+ capability_id=spec.base.id,
305
+ extra={"workflow_instance_id": workflow_instance_id},
306
+ )
307
+ report = services.build_fail_closed_report(
308
+ run_id=context_holder.context.run_id,
309
+ status="failed",
310
+ reason="engine_error",
311
+ completion_reason="engine_exception",
312
+ meta={"engine_exception": type(exc).__name__, "workflow_instance_id": workflow_instance_id},
313
+ )
314
+ terminal_holder["result"] = CapabilityResult(
315
+ status=CapabilityStatus.FAILED,
316
+ error=f"Workflow TriggerFlow engine error: {exc}",
317
+ error_code="ENGINE_ERROR",
318
+ report=report,
319
+ node_report=report,
320
+ )
321
+ await emit(
322
+ {
323
+ "type": "workflow.finished",
324
+ "run_id": context_holder.context.run_id,
325
+ "workflow_id": spec.base.id,
326
+ "workflow_instance_id": workflow_instance_id,
327
+ "status": "failed",
328
+ }
329
+ )
330
+ finally:
331
+ await event_queue.put(queue_stop)
332
+
333
+ flow_task = asyncio.create_task(run_flow())
334
+ try:
335
+ while True:
336
+ item = await event_queue.get()
337
+ if item is queue_stop:
338
+ break
339
+ yield cast(WorkflowStreamEvent, item)
340
+ finally:
341
+ await flow_task
342
+
343
+ terminal = terminal_holder.get("result")
344
+ if terminal is None:
345
+ report = services.build_fail_closed_report(
346
+ run_id=context_holder.context.run_id,
347
+ status="failed",
348
+ reason="engine_error",
349
+ completion_reason="missing_terminal_result",
350
+ meta={"workflow_id": spec.base.id, "workflow_instance_id": workflow_instance_id},
351
+ )
352
+ terminal = CapabilityResult(
353
+ status=CapabilityStatus.FAILED,
354
+ error="Workflow execution produced no result",
355
+ error_code="ENGINE_ERROR",
356
+ report=report,
357
+ node_report=report,
358
+ )
359
+ yield terminal
360
+
361
+ async def _execute_step(
362
+ self,
363
+ step: Any,
364
+ *,
365
+ context: ExecutionContext,
366
+ services: RuntimeServices,
367
+ ) -> CapabilityResult:
368
+ """按步骤类型分发执行。"""
369
+
370
+ if context.cancel_token is not None and context.cancel_token.is_cancelled:
371
+ report = services.build_fail_closed_report(
372
+ run_id=context.run_id,
373
+ status="incomplete",
374
+ reason="cancelled",
375
+ completion_reason="run_cancelled",
376
+ meta={"workflow_step": getattr(step, "id", None)},
377
+ )
378
+ return CapabilityResult(
379
+ status=CapabilityStatus.CANCELLED,
380
+ error="execution cancelled",
381
+ error_code="RUN_CANCELLED",
382
+ report=report,
383
+ node_report=report,
384
+ )
385
+
386
+ if isinstance(step, Step):
387
+ return await self._execute_basic_step(step, context=context, services=services)
388
+ if isinstance(step, LoopStep):
389
+ return await self._execute_loop_step(step, context=context, services=services)
390
+ if isinstance(step, ParallelStep):
391
+ return await self._execute_parallel_step(step, context=context, services=services)
392
+ if isinstance(step, ConditionalStep):
393
+ return await self._execute_conditional_step(step, context=context, services=services)
394
+ return self._build_fail_closed_result(
395
+ services=services,
396
+ context=context,
397
+ workflow_id="unknown",
398
+ error=f"Unknown step type: {type(step).__name__}",
399
+ error_code="INVALID_WORKFLOW_STEP",
400
+ reason="invalid_workflow_step",
401
+ completion_reason="unknown_step_type",
402
+ meta={"actual_type": type(step).__name__},
403
+ )
404
+
405
+ def _step_capability_id(self, step: Any) -> str | None:
406
+ """
407
+ 提取 workflow step 绑定的 capability ID。
408
+
409
+ 参数:
410
+ - step:WorkflowStep
411
+ """
412
+
413
+ capability = getattr(step, "capability", None)
414
+ capability_id = getattr(capability, "id", None)
415
+ return capability_id if isinstance(capability_id, str) and capability_id.strip() else None
416
+
417
+ async def _execute_basic_step(
418
+ self,
419
+ step: Step,
420
+ *,
421
+ context: ExecutionContext,
422
+ services: RuntimeServices,
423
+ ) -> CapabilityResult:
424
+ """执行基础步骤。"""
425
+
426
+ step_input = self._resolve_input_mappings(step.input_mappings, context)
427
+ target_spec = services.registry.get_or_raise(step.capability.id)
428
+ execute_task: asyncio.Task[CapabilityResult] | None = None
429
+ try:
430
+ if step.timeout_s is not None:
431
+ execute_task = asyncio.create_task(
432
+ services.execute_capability(spec=target_spec, input=step_input, context=context)
433
+ )
434
+ done, _pending = await asyncio.wait({execute_task}, timeout=step.timeout_s)
435
+ if not done:
436
+ execute_task.cancel()
437
+ with suppress(BaseException):
438
+ await execute_task
439
+ raise asyncio.TimeoutError()
440
+ result = execute_task.result()
441
+ else:
442
+ result = await services.execute_capability(spec=target_spec, input=step_input, context=context)
443
+ except asyncio.TimeoutError:
444
+ report = services.build_fail_closed_report(
445
+ run_id=context.run_id,
446
+ status="failed",
447
+ reason="timeout",
448
+ completion_reason="step_timeout",
449
+ meta={"step_id": step.id, "capability_id": step.capability.id},
450
+ )
451
+ result = CapabilityResult(
452
+ status=CapabilityStatus.FAILED,
453
+ error=f"step timeout: {step.id}",
454
+ error_code="STEP_TIMEOUT",
455
+ report=report,
456
+ node_report=report,
457
+ )
458
+ except asyncio.CancelledError:
459
+ if execute_task is not None:
460
+ execute_task.cancel()
461
+ with suppress(BaseException):
462
+ await execute_task
463
+ report = services.build_fail_closed_report(
464
+ run_id=context.run_id,
465
+ status="incomplete",
466
+ reason="cancelled",
467
+ completion_reason="run_cancelled",
468
+ meta={"step_id": step.id, "capability_id": step.capability.id},
469
+ )
470
+ result = CapabilityResult(
471
+ status=CapabilityStatus.CANCELLED,
472
+ error="execution cancelled",
473
+ error_code="RUN_CANCELLED",
474
+ report=report,
475
+ node_report=report,
476
+ )
477
+
478
+ context.step_outputs[step.id] = result.output
479
+ context.step_results[step.id] = _to_step_result_dict(result)
480
+ return result
481
+
482
+ async def _execute_loop_step(
483
+ self,
484
+ step: LoopStep,
485
+ *,
486
+ context: ExecutionContext,
487
+ services: RuntimeServices,
488
+ ) -> CapabilityResult:
489
+ """执行循环步骤。"""
490
+
491
+ items = context.resolve_mapping(step.iterate_over)
492
+ if not isinstance(items, list):
493
+ return self._build_fail_closed_result(
494
+ services=services,
495
+ context=context,
496
+ workflow_id=step.id,
497
+ error=(
498
+ f"LoopStep '{step.id}': iterate_over resolved to "
499
+ f"{type(items).__name__}, expected list"
500
+ ),
501
+ error_code="INVALID_LOOP_INPUT",
502
+ reason="invalid_workflow_step",
503
+ completion_reason="loop_iterate_over_not_list",
504
+ meta={"step_id": step.id, "actual_type": type(items).__name__},
505
+ )
506
+
507
+ target_spec = services.registry.get_or_raise(step.capability.id)
508
+
509
+ async def execute_item(item: Any, _idx: int) -> CapabilityResult:
510
+ # 深度递增并检查是否超限
511
+ new_depth = context.depth + 1
512
+ if new_depth > context.max_depth:
513
+ raise RecursionLimitError(
514
+ f"LoopStep '{step.id}' item[{_idx}]: 深度 {new_depth} 超过最大值 {context.max_depth}"
515
+ )
516
+ item_context = ExecutionContext(
517
+ run_id=context.run_id,
518
+ parent_context=context,
519
+ depth=new_depth,
520
+ max_depth=context.max_depth,
521
+ guards=context.guards,
522
+ cancel_token=context.cancel_token,
523
+ bag=MappingProxyType(
524
+ {
525
+ **dict(context.bag),
526
+ "__current_item__": item,
527
+ _WF_BRANCH_ID_KEY: f"{step.id}:{_idx}",
528
+ }
529
+ ),
530
+ step_outputs=dict(context.step_outputs),
531
+ step_results=dict(context.step_results),
532
+ call_chain=list(context.call_chain),
533
+ )
534
+ step_input = self._resolve_input_mappings(step.item_input_mappings, item_context)
535
+ if not step_input:
536
+ step_input = item if isinstance(item, dict) else {"item": item}
537
+ return await services.execute_capability(spec=target_spec, input=step_input, context=item_context)
538
+
539
+ if context.guards is None:
540
+ return self._build_fail_closed_result(
541
+ services=services,
542
+ context=context,
543
+ workflow_id=step.id,
544
+ error=f"LoopStep '{step.id}': missing ExecutionGuards in ExecutionContext",
545
+ error_code="MISSING_EXECUTION_GUARDS",
546
+ reason="engine_error",
547
+ completion_reason="missing_execution_guards",
548
+ meta={"step_id": step.id},
549
+ )
550
+
551
+ if step.timeout_s is not None:
552
+ loop_task: asyncio.Task[CapabilityResult] | None = None
553
+ try:
554
+ loop_task = asyncio.create_task(
555
+ context.guards.run_loop(
556
+ items=items,
557
+ max_iterations=step.max_iterations,
558
+ execute_fn=execute_item,
559
+ fail_strategy=step.fail_strategy,
560
+ )
561
+ )
562
+ done, _pending = await asyncio.wait({loop_task}, timeout=step.timeout_s)
563
+ if not done:
564
+ loop_task.cancel()
565
+ with suppress(BaseException):
566
+ await loop_task
567
+ raise asyncio.TimeoutError()
568
+ result = loop_task.result()
569
+ except asyncio.TimeoutError:
570
+ report = services.build_fail_closed_report(
571
+ run_id=context.run_id,
572
+ status="failed",
573
+ reason="timeout",
574
+ completion_reason="loop_timeout",
575
+ meta={"step_id": step.id, "capability_id": step.capability.id},
576
+ )
577
+ result = CapabilityResult(
578
+ status=CapabilityStatus.FAILED,
579
+ error=f"loop timeout: {step.id}",
580
+ error_code="LOOP_TIMEOUT",
581
+ output=[],
582
+ report=report,
583
+ node_report=report,
584
+ )
585
+ else:
586
+ result = await context.guards.run_loop(
587
+ items=items,
588
+ max_iterations=step.max_iterations,
589
+ execute_fn=execute_item,
590
+ fail_strategy=step.fail_strategy,
591
+ )
592
+
593
+ if result.status == CapabilityStatus.FAILED and result.node_report is None:
594
+ report = services.build_fail_closed_report(
595
+ run_id=context.run_id,
596
+ status="failed",
597
+ reason="workflow_step_failed",
598
+ completion_reason="loop_iteration_failed",
599
+ meta={"step_id": step.id, "capability_id": step.capability.id},
600
+ )
601
+ result.report = report
602
+ result.node_report = report
603
+
604
+ context.step_outputs[step.id] = result.output
605
+ context.step_results[step.id] = _to_step_result_dict(result)
606
+ return result
607
+
608
+ async def _execute_parallel_step(
609
+ self,
610
+ step: ParallelStep,
611
+ *,
612
+ context: ExecutionContext,
613
+ services: RuntimeServices,
614
+ ) -> CapabilityResult:
615
+ """执行并行步骤。"""
616
+
617
+ # 深度递增并检查是否超限
618
+ new_depth = context.depth + 1
619
+ if new_depth > context.max_depth:
620
+ return self._build_fail_closed_result(
621
+ services=services,
622
+ context=context,
623
+ workflow_id=step.id,
624
+ error=(
625
+ f"ParallelStep '{step.id}': 深度 {new_depth} 超过最大值 {context.max_depth}"
626
+ ),
627
+ error_code="RECURSION_LIMIT",
628
+ reason="recursion_limit",
629
+ completion_reason="recursion_limit",
630
+ meta={"error_type": "recursion_limit", "step_id": step.id},
631
+ )
632
+
633
+ branch_contexts = [
634
+ ExecutionContext(
635
+ run_id=context.run_id,
636
+ parent_context=context,
637
+ depth=new_depth,
638
+ max_depth=context.max_depth,
639
+ guards=context.guards,
640
+ cancel_token=context.cancel_token,
641
+ bag=MappingProxyType({**dict(context.bag), _WF_BRANCH_ID_KEY: f"{step.id}:{i}"}),
642
+ step_outputs=dict(context.step_outputs),
643
+ step_results=dict(context.step_results),
644
+ call_chain=list(context.call_chain),
645
+ )
646
+ for i, _ in enumerate(step.branches)
647
+ ]
648
+ tasks = [
649
+ self._execute_step(branch, context=branch_ctx, services=services)
650
+ for branch, branch_ctx in zip(step.branches, branch_contexts)
651
+ ]
652
+ raw_results = await asyncio.gather(*tasks, return_exceptions=True)
653
+
654
+ branch_results: List[CapabilityResult] = []
655
+ for result in raw_results:
656
+ if isinstance(result, BaseException):
657
+ if isinstance(result, asyncio.CancelledError):
658
+ report = services.build_fail_closed_report(
659
+ run_id=context.run_id,
660
+ status="incomplete",
661
+ reason="cancelled",
662
+ completion_reason="parallel_branch_cancelled",
663
+ meta={"step_id": step.id, "exception_type": type(result).__name__},
664
+ )
665
+ branch_results.append(
666
+ CapabilityResult(
667
+ status=CapabilityStatus.CANCELLED,
668
+ error="execution cancelled",
669
+ error_code="RUN_CANCELLED",
670
+ report=report,
671
+ node_report=report,
672
+ )
673
+ )
674
+ continue
675
+ msg = str(result).strip()
676
+ branch_results.append(
677
+ self._build_fail_closed_result(
678
+ services=services,
679
+ context=context,
680
+ workflow_id=step.id,
681
+ error=msg or type(result).__name__,
682
+ error_code="BRANCH_EXECUTION_ERROR",
683
+ reason="engine_error",
684
+ completion_reason="parallel_branch_exception",
685
+ meta={"step_id": step.id, "exception_type": type(result).__name__},
686
+ )
687
+ )
688
+ else:
689
+ branch_results.append(result)
690
+
691
+ if step.join_strategy == "all_success":
692
+ non_success = [result for result in branch_results if result.status != CapabilityStatus.SUCCESS]
693
+ if non_success:
694
+ statuses = {result.status for result in non_success}
695
+ if CapabilityStatus.FAILED in statuses:
696
+ overall = CapabilityStatus.FAILED
697
+ elif CapabilityStatus.CANCELLED in statuses:
698
+ overall = CapabilityStatus.CANCELLED
699
+ elif CapabilityStatus.PENDING in statuses:
700
+ overall = CapabilityStatus.PENDING
701
+ else:
702
+ overall = CapabilityStatus.RUNNING
703
+
704
+ context.step_outputs[step.id] = [result.output for result in branch_results]
705
+ branch_statuses = [
706
+ getattr(result.status, "value", str(result.status)) for result in branch_results
707
+ ]
708
+ aggregated = CapabilityResult(
709
+ status=overall,
710
+ output=[result.output for result in branch_results],
711
+ error=(
712
+ f"ParallelStep '{step.id}': "
713
+ f"{len(non_success)}/{len(branch_results)} branches not success"
714
+ )
715
+ if overall == CapabilityStatus.FAILED
716
+ else None,
717
+ error_code=(
718
+ "BRANCH_EXECUTION_ERROR"
719
+ if overall == CapabilityStatus.FAILED
720
+ and any(result.error_code == "BRANCH_EXECUTION_ERROR" for result in branch_results)
721
+ else ("RUN_CANCELLED" if overall == CapabilityStatus.CANCELLED else None)
722
+ ),
723
+ metadata={"branch_statuses": branch_statuses},
724
+ )
725
+ if overall != CapabilityStatus.SUCCESS:
726
+ report_status = "failed"
727
+ report_reason = "workflow_step_failed"
728
+ if overall == CapabilityStatus.CANCELLED:
729
+ report_status = "incomplete"
730
+ report_reason = "cancelled"
731
+ elif overall in (CapabilityStatus.PENDING, CapabilityStatus.RUNNING):
732
+ report_status = "incomplete"
733
+ report_reason = "parallel_branches_incomplete"
734
+ report = services.build_fail_closed_report(
735
+ run_id=context.run_id,
736
+ status=report_status,
737
+ reason=report_reason,
738
+ completion_reason="parallel_all_success_not_met",
739
+ meta={
740
+ "step_id": step.id,
741
+ "branch_statuses": branch_statuses,
742
+ },
743
+ )
744
+ aggregated.report = report
745
+ aggregated.node_report = report
746
+ context.step_results[step.id] = _to_step_result_dict(aggregated)
747
+ return aggregated
748
+ elif step.join_strategy == "any_success":
749
+ if not any(result.status == CapabilityStatus.SUCCESS for result in branch_results):
750
+ statuses = {result.status for result in branch_results}
751
+ if CapabilityStatus.FAILED in statuses:
752
+ overall = CapabilityStatus.FAILED
753
+ elif CapabilityStatus.CANCELLED in statuses:
754
+ overall = CapabilityStatus.CANCELLED
755
+ elif CapabilityStatus.PENDING in statuses:
756
+ overall = CapabilityStatus.PENDING
757
+ elif CapabilityStatus.RUNNING in statuses:
758
+ overall = CapabilityStatus.RUNNING
759
+ else:
760
+ overall = CapabilityStatus.FAILED
761
+
762
+ context.step_outputs[step.id] = [result.output for result in branch_results]
763
+ branch_statuses = [
764
+ getattr(result.status, "value", str(result.status)) for result in branch_results
765
+ ]
766
+ aggregated = CapabilityResult(
767
+ status=overall,
768
+ output=[result.output for result in branch_results],
769
+ error=f"ParallelStep '{step.id}': no branch succeeded"
770
+ if overall == CapabilityStatus.FAILED
771
+ else None,
772
+ error_code=(
773
+ "BRANCH_EXECUTION_ERROR"
774
+ if overall == CapabilityStatus.FAILED
775
+ and any(result.error_code == "BRANCH_EXECUTION_ERROR" for result in branch_results)
776
+ else ("RUN_CANCELLED" if overall == CapabilityStatus.CANCELLED else None)
777
+ ),
778
+ metadata={"branch_statuses": branch_statuses},
779
+ )
780
+ if overall != CapabilityStatus.SUCCESS:
781
+ report_status = "failed"
782
+ report_reason = "workflow_step_failed"
783
+ if overall == CapabilityStatus.CANCELLED:
784
+ report_status = "incomplete"
785
+ report_reason = "cancelled"
786
+ elif overall in (CapabilityStatus.PENDING, CapabilityStatus.RUNNING):
787
+ report_status = "incomplete"
788
+ report_reason = "parallel_branches_incomplete"
789
+ report = services.build_fail_closed_report(
790
+ run_id=context.run_id,
791
+ status=report_status,
792
+ reason=report_reason,
793
+ completion_reason="parallel_any_success_not_met",
794
+ meta={
795
+ "step_id": step.id,
796
+ "branch_statuses": branch_statuses,
797
+ },
798
+ )
799
+ aggregated.report = report
800
+ aggregated.node_report = report
801
+ context.step_results[step.id] = _to_step_result_dict(aggregated)
802
+ return aggregated
803
+
804
+ context.step_outputs[step.id] = [result.output for result in branch_results]
805
+ aggregated = CapabilityResult(
806
+ status=CapabilityStatus.SUCCESS,
807
+ output=[result.output for result in branch_results],
808
+ )
809
+ context.step_results[step.id] = _to_step_result_dict(aggregated)
810
+ return aggregated
811
+
812
+ async def _execute_conditional_step(
813
+ self,
814
+ step: ConditionalStep,
815
+ *,
816
+ context: ExecutionContext,
817
+ services: RuntimeServices,
818
+ ) -> CapabilityResult:
819
+ """执行条件步骤。"""
820
+
821
+ condition_value = context.resolve_mapping(step.condition_source)
822
+ condition_key = str(condition_value) if condition_value is not None else ""
823
+ branch = step.branches.get(condition_key, step.default)
824
+ if branch is None:
825
+ return self._build_fail_closed_result(
826
+ services=services,
827
+ context=context,
828
+ workflow_id=step.id,
829
+ error=(
830
+ f"ConditionalStep '{step.id}': no branch for "
831
+ f"condition '{condition_key}' and no default"
832
+ ),
833
+ error_code="CONDITIONAL_BRANCH_NOT_FOUND",
834
+ reason="invalid_workflow_step",
835
+ completion_reason="conditional_branch_not_found",
836
+ meta={"step_id": step.id, "condition_key": condition_key},
837
+ )
838
+
839
+ branch_context = context.with_bag_overlay(
840
+ **{_WF_BRANCH_ID_KEY: f"{step.id}:{condition_key or 'default'}"}
841
+ )
842
+ result = await self._execute_step(branch, context=branch_context, services=services)
843
+ context.step_outputs[step.id] = result.output
844
+ context.step_results[step.id] = _to_step_result_dict(result)
845
+ return result
846
+
847
+ @staticmethod
848
+ def _resolve_input_mappings(mappings: List[InputMapping], context: ExecutionContext) -> Dict[str, Any]:
849
+ """解析输入映射列表。"""
850
+
851
+ result: Dict[str, Any] = {}
852
+ for mapping in mappings:
853
+ result[mapping.target_field] = context.resolve_mapping(mapping.source)
854
+ return result
855
+
856
+ @staticmethod
857
+ def _resolve_output_mappings(mappings: List[InputMapping], context: ExecutionContext) -> Any:
858
+ """解析输出映射列表。"""
859
+
860
+ if not mappings:
861
+ return None
862
+ result: Dict[str, Any] = {}
863
+ for mapping in mappings:
864
+ result[mapping.target_field] = context.resolve_mapping(mapping.source)
865
+ return result