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,617 @@
1
+ from __future__ import annotations
2
+
3
+ """
4
+ Runtime UI Events:把底层事件投影为 RuntimeEvent v1(ui-friendly)。
5
+
6
+ 输入(事实源):
7
+ - skills_runtime.AgentEvent(tool/approval/run_* 等)
8
+ - WorkflowStreamEvent(workflow.* 轻量事件 dict)
9
+ - CapabilityResult(终态)
10
+
11
+ 输出:
12
+ - RuntimeEvent v1(Envelope + path + level)
13
+
14
+ 约束:
15
+ - 不把 UI events 当作审计真相源;证据链指针只引用 WAL/NodeReport/tool evidence。
16
+ - 最小披露:不输出 tool args/outputs 明文,只输出摘要(top_keys/bytes/sha256)。
17
+ """
18
+
19
+ import hashlib
20
+ import json
21
+ import time
22
+ from dataclasses import dataclass
23
+ from typing import Any, Dict, List, Optional
24
+
25
+ from skills_runtime.core.contracts import AgentEvent
26
+
27
+ from ..host_protocol import project_host_runtime_data
28
+ from ..logging_utils import log_suppressed_exception
29
+ from ..protocol.capability import CapabilityResult, CapabilityStatus
30
+ from ..types import NodeReport
31
+ from ..utils.usage import extract_usage_metrics
32
+ from .v1 import Evidence, PathSegment, RuntimeEvent, StreamLevel
33
+
34
+
35
+ _SCHEMA = "capability-runtime.runtime_event.v1"
36
+
37
+
38
+ def _now_ms() -> int:
39
+ return int(time.time() * 1000)
40
+
41
+
42
+ def _sha256_text(s: str) -> str:
43
+ return hashlib.sha256(s.encode("utf-8")).hexdigest()
44
+
45
+
46
+ def _summarize_dict(obj: Any) -> Optional[Dict[str, Any]]:
47
+ """
48
+ 将 dict 归一为最小披露摘要。
49
+
50
+ 返回:
51
+ - None:无法摘要(非 dict)
52
+ - dict:包含 top_keys/bytes/sha256(不含明文 values)
53
+ """
54
+
55
+ if not isinstance(obj, dict):
56
+ return None
57
+ try:
58
+ raw = json.dumps(obj, ensure_ascii=False, separators=(",", ":"), sort_keys=True)
59
+ except Exception as exc:
60
+ log_suppressed_exception(
61
+ context="summarize_dict_json_encode",
62
+ exc=exc,
63
+ extra={"obj_type": type(obj).__name__},
64
+ )
65
+ raw = "{}"
66
+ return {
67
+ "top_keys": sorted([str(k) for k in obj.keys()])[:50],
68
+ "bytes": len(raw.encode("utf-8")),
69
+ "sha256": _sha256_text(raw),
70
+ }
71
+
72
+
73
+ def _normalize_terminal_status(status: CapabilityStatus) -> str:
74
+ if status == CapabilityStatus.SUCCESS:
75
+ return "completed"
76
+ if status == CapabilityStatus.FAILED:
77
+ return "failed"
78
+ if status == CapabilityStatus.CANCELLED:
79
+ return "cancelled"
80
+ return "pending"
81
+
82
+
83
+ @dataclass
84
+ class _AgentCtx:
85
+ run_id: str
86
+ capability_id: str
87
+ workflow_id: Optional[str] = None
88
+ workflow_instance_id: Optional[str] = None
89
+ step_id: Optional[str] = None
90
+ branch_id: Optional[str] = None
91
+ # outer → inner 的嵌套链提示(best-effort);若存在则优先用于生成 path
92
+ wf_frames: Optional[List[Dict[str, str]]] = None
93
+
94
+
95
+ class RuntimeUIEventProjector:
96
+ """
97
+ 投影器(per-run)。
98
+
99
+ 说明:
100
+ - `seq`/`rid` 在投影器内生成:单 run 单调递增;
101
+ - `rid` 默认等于 `seq` 的字符串(满足断线续传 exclusive 语义的最小实现)。
102
+ """
103
+
104
+ def __init__(self, *, run_id: str, level: StreamLevel) -> None:
105
+ self._run_id = run_id
106
+ self._level = level
107
+ self._seq = 0
108
+ self._skill_mention_to_locator: Dict[str, str] = {}
109
+ self._skill_name_to_locator: Dict[str, str] = {}
110
+ # call_id -> origin path(用于“哪来哪去”,避免 tool/approval 生命周期事件归属漂移)
111
+ self._call_origin_path: Dict[str, List[PathSegment]] = {}
112
+ # approvals 可能缺 call_id:best-effort 用 step_id 恢复归属(见 docs/specs/runtime-ui-events-v1.md)
113
+ self._step_scope_to_call_id: Dict[str, str] = {}
114
+ self._step_scope_tool_to_call_id: Dict[tuple[str, str], str] = {}
115
+
116
+ def _step_scope_key(self, *, ctx: _AgentCtx) -> Optional[str]:
117
+ """
118
+ 生成“step 作用域键”(best-effort 消歧)。
119
+
120
+ 说明:
121
+ - approvals 的 step_id 关联是 best-effort:仅在 run 内临时使用;
122
+ - 为避免多个 workflow/agent 复用相同 step_id 造成互相污染,这里把 workflow/branch/agent 信息纳入 key;
123
+ - 若 ctx 无 step_id,返回 None。
124
+ """
125
+
126
+ step_id = str(ctx.step_id or "").strip()
127
+ if not step_id:
128
+ return None
129
+ workflow_scope = str(ctx.workflow_instance_id or ctx.workflow_id or "").strip()
130
+ branch_scope = str(ctx.branch_id or "").strip()
131
+ agent_scope = str(ctx.capability_id or "").strip()
132
+ return f"{workflow_scope}::{branch_scope}::{agent_scope}::{step_id}"
133
+
134
+ def _best_effort_call_id_from_step(self, *, ctx: _AgentCtx, tool: str) -> Optional[str]:
135
+ """
136
+ best-effort 通过 step_id(可选加 tool)恢复 call_id。
137
+
138
+ 说明:
139
+ - 优先用 `(step_id, tool)` 精确匹配;
140
+ - 再回退到 `step_id` 的最近一次 call_id;
141
+ - 若 ctx 无 step_id 或未命中,返回 None。
142
+ """
143
+
144
+ step_scope_key = self._step_scope_key(ctx=ctx)
145
+ if step_scope_key is None:
146
+ return None
147
+ tool = str(tool or "").strip()
148
+ if tool:
149
+ call_id = self._step_scope_tool_to_call_id.get((step_scope_key, tool))
150
+ if call_id:
151
+ return call_id
152
+ return self._step_scope_to_call_id.get(step_scope_key)
153
+
154
+ def _next_seq(self) -> int:
155
+ self._seq += 1
156
+ return self._seq
157
+
158
+ def _base_path(self, *, ctx: Optional[_AgentCtx] = None) -> List[PathSegment]:
159
+ segs: List[PathSegment] = [PathSegment(kind="run", id=self._run_id)]
160
+ if ctx is None:
161
+ return segs
162
+ if ctx.wf_frames:
163
+ for frame in ctx.wf_frames:
164
+ wf_id = frame.get("workflow_id")
165
+ wf_inst = frame.get("workflow_instance_id") or wf_id
166
+ if wf_inst:
167
+ segs.append(
168
+ PathSegment(
169
+ kind="workflow",
170
+ id=wf_inst,
171
+ instance_id=wf_inst,
172
+ ref={"kind": "workflow", "id": wf_id} if wf_id else None,
173
+ )
174
+ )
175
+ step_id = frame.get("step_id")
176
+ if step_id:
177
+ segs.append(PathSegment(kind="step", id=step_id))
178
+ branch_id = frame.get("branch_id")
179
+ if branch_id:
180
+ segs.append(PathSegment(kind="branch", id=branch_id))
181
+ else:
182
+ if ctx.workflow_id:
183
+ # legacy:仍提供 ref,便于消费端按逻辑 workflow_id 过滤
184
+ segs.append(
185
+ PathSegment(
186
+ kind="workflow",
187
+ id=ctx.workflow_instance_id or ctx.workflow_id,
188
+ instance_id=ctx.workflow_instance_id or ctx.workflow_id,
189
+ ref={"kind": "workflow", "id": ctx.workflow_id},
190
+ )
191
+ )
192
+ if ctx.step_id:
193
+ segs.append(PathSegment(kind="step", id=ctx.step_id))
194
+ if ctx.branch_id:
195
+ segs.append(PathSegment(kind="branch", id=ctx.branch_id))
196
+ if ctx.capability_id:
197
+ segs.append(PathSegment(kind="agent", id=ctx.capability_id))
198
+ return segs
199
+
200
+ def _emit(
201
+ self,
202
+ *,
203
+ type: str,
204
+ path: List[PathSegment],
205
+ data: Dict[str, Any],
206
+ evidence: Optional[Evidence] = None,
207
+ ) -> RuntimeEvent:
208
+ seq = self._next_seq()
209
+ return RuntimeEvent(
210
+ schema=_SCHEMA,
211
+ type=type,
212
+ run_id=self._run_id,
213
+ seq=seq,
214
+ ts_ms=_now_ms(),
215
+ level=self._level,
216
+ path=path,
217
+ data=dict(data or {}),
218
+ rid=str(seq),
219
+ evidence=evidence,
220
+ )
221
+
222
+ def start(self) -> List[RuntimeEvent]:
223
+ return [self._emit(type="run.status", path=self._base_path(), data={"status": "running"})]
224
+
225
+ def heartbeat(self) -> RuntimeEvent:
226
+ return self._emit(type="heartbeat", path=self._base_path(), data={})
227
+
228
+ def error(self, *, kind: str, message: str, data: Optional[Dict[str, Any]] = None) -> RuntimeEvent:
229
+ payload: Dict[str, Any] = {"kind": str(kind), "message": str(message)}
230
+ if data:
231
+ for k, v in dict(data).items():
232
+ if k in {"kind", "message"}:
233
+ continue
234
+ payload[k] = v
235
+ return self._emit(type="error", path=self._base_path(), data=payload)
236
+
237
+ def on_workflow_event(self, ev: Dict[str, Any]) -> List[RuntimeEvent]:
238
+ typ = str(ev.get("type") or "")
239
+ run_id = str(ev.get("run_id") or "")
240
+ if run_id and run_id != self._run_id:
241
+ return []
242
+
243
+ workflow_id = str(ev.get("workflow_id") or "").strip() or None
244
+ workflow_instance_id = str(ev.get("workflow_instance_id") or "").strip() or None
245
+ step_id = str(ev.get("step_id") or "").strip() or None
246
+
247
+ out: List[RuntimeEvent] = []
248
+ if typ == "workflow.started" and workflow_id:
249
+ wf_seg = PathSegment(
250
+ kind="workflow",
251
+ id=workflow_instance_id or workflow_id,
252
+ instance_id=workflow_instance_id or workflow_id,
253
+ ref={"kind": "workflow", "id": workflow_id},
254
+ )
255
+ out.append(
256
+ self._emit(
257
+ type="node.started",
258
+ path=[PathSegment(kind="run", id=self._run_id), wf_seg],
259
+ data={"node_kind": "workflow", "workflow_id": workflow_id, "workflow_instance_id": workflow_instance_id},
260
+ )
261
+ )
262
+ if self._level != StreamLevel.LITE:
263
+ out.append(
264
+ self._emit(
265
+ type="node.phase",
266
+ path=[PathSegment(kind="run", id=self._run_id), wf_seg],
267
+ data={"phase": "RUNNING"},
268
+ )
269
+ )
270
+ return out
271
+
272
+ if typ == "workflow.step.started" and workflow_id and step_id:
273
+ wf_seg = PathSegment(
274
+ kind="workflow",
275
+ id=workflow_instance_id or workflow_id,
276
+ instance_id=workflow_instance_id or workflow_id,
277
+ ref={"kind": "workflow", "id": workflow_id},
278
+ )
279
+ path = [
280
+ PathSegment(kind="run", id=self._run_id),
281
+ wf_seg,
282
+ PathSegment(kind="step", id=step_id),
283
+ ]
284
+ out.append(
285
+ self._emit(
286
+ type="node.started",
287
+ path=path,
288
+ data={"node_kind": "step", "workflow_id": workflow_id, "workflow_instance_id": workflow_instance_id, "step_id": step_id},
289
+ )
290
+ )
291
+ if self._level != StreamLevel.LITE:
292
+ out.append(self._emit(type="node.phase", path=path, data={"phase": "RUNNING"}))
293
+ return out
294
+
295
+ if typ == "workflow.step.finished" and workflow_id and step_id:
296
+ status_raw = str(ev.get("status") or "").strip()
297
+ status = status_raw if status_raw in {"success", "failed", "pending", "cancelled"} else "pending"
298
+ wf_seg = PathSegment(
299
+ kind="workflow",
300
+ id=workflow_instance_id or workflow_id,
301
+ instance_id=workflow_instance_id or workflow_id,
302
+ ref={"kind": "workflow", "id": workflow_id},
303
+ )
304
+ path = [
305
+ PathSegment(kind="run", id=self._run_id),
306
+ wf_seg,
307
+ PathSegment(kind="step", id=step_id),
308
+ ]
309
+ if self._level != StreamLevel.LITE:
310
+ out.append(self._emit(type="node.phase", path=path, data={"phase": "DONE"}))
311
+ out.append(
312
+ self._emit(
313
+ type="node.finished",
314
+ path=path,
315
+ data={"status": status, "workflow_id": workflow_id, "workflow_instance_id": workflow_instance_id, "step_id": step_id},
316
+ )
317
+ )
318
+ return out
319
+
320
+ if typ == "workflow.finished" and workflow_id:
321
+ status_raw = str(ev.get("status") or "").strip()
322
+ status = status_raw if status_raw in {"success", "failed", "pending", "cancelled"} else "pending"
323
+ wf_seg = PathSegment(
324
+ kind="workflow",
325
+ id=workflow_instance_id or workflow_id,
326
+ instance_id=workflow_instance_id or workflow_id,
327
+ ref={"kind": "workflow", "id": workflow_id},
328
+ )
329
+ path = [PathSegment(kind="run", id=self._run_id), wf_seg]
330
+ if self._level != StreamLevel.LITE:
331
+ out.append(self._emit(type="node.phase", path=path, data={"phase": "DONE"}))
332
+ out.append(
333
+ self._emit(
334
+ type="node.finished",
335
+ path=path,
336
+ data={"status": status, "workflow_id": workflow_id, "workflow_instance_id": workflow_instance_id},
337
+ )
338
+ )
339
+ return out
340
+
341
+ return []
342
+
343
+ def on_agent_event(self, ev: AgentEvent, *, ctx: _AgentCtx) -> List[RuntimeEvent]:
344
+ if self._level == StreamLevel.LITE:
345
+ return []
346
+ if ev.run_id != self._run_id:
347
+ return []
348
+
349
+ out: List[RuntimeEvent] = []
350
+ base_path = self._base_path(ctx=ctx)
351
+
352
+ if self._level == StreamLevel.RAW:
353
+ out.append(
354
+ self._emit(
355
+ type="raw.agent_event",
356
+ path=base_path,
357
+ data={
358
+ "agent_event_type": str(ev.type),
359
+ "payload_summary": _summarize_dict(ev.payload) or {},
360
+ },
361
+ )
362
+ )
363
+
364
+ if ev.type == "skill_injected":
365
+ locator = ev.payload.get("skill_locator")
366
+ skill_name = ev.payload.get("skill_name")
367
+ mention_text = ev.payload.get("mention_text")
368
+ if isinstance(locator, str) and locator.strip():
369
+ if isinstance(mention_text, str) and mention_text.strip():
370
+ self._skill_mention_to_locator[mention_text.strip()] = locator.strip()
371
+ if isinstance(skill_name, str) and skill_name.strip():
372
+ self._skill_name_to_locator[skill_name.strip()] = locator.strip()
373
+ return out
374
+
375
+ if ev.type == "run_started":
376
+ out.append(self._emit(type="node.started", path=base_path, data={"node_kind": "agent"}))
377
+ out.append(self._emit(type="node.phase", path=base_path, data={"phase": "THINKING"}))
378
+ return out
379
+
380
+ if ev.type == "llm_usage":
381
+ out.append(self._emit(type="metrics", path=base_path, data=extract_usage_metrics(ev.payload)))
382
+ return out
383
+
384
+ if ev.type in ("run_completed", "run_failed", "run_cancelled", "run_waiting_human"):
385
+ if ev.type == "run_completed":
386
+ status = "success"
387
+ # best-effort:run_* 事件出现后通常意味着进入“产出/收敛”阶段
388
+ out.append(self._emit(type="node.phase", path=base_path, data={"phase": "REPORTING"}))
389
+ out.append(self._emit(type="node.phase", path=base_path, data={"phase": "DONE"}))
390
+ out.append(self._emit(type="node.finished", path=base_path, data={"status": status}))
391
+ return out
392
+
393
+ if ev.type == "run_failed":
394
+ status = "failed"
395
+ data: Dict[str, Any] = {"status": status}
396
+ error_kind = ev.payload.get("error_kind")
397
+ if isinstance(error_kind, str) and error_kind.strip():
398
+ data["error_kind"] = error_kind.strip()
399
+ out.append(self._emit(type="node.phase", path=base_path, data={"phase": "REPORTING"}))
400
+ out.append(self._emit(type="node.phase", path=base_path, data={"phase": "DONE"}))
401
+ out.append(self._emit(type="node.finished", path=base_path, data=data))
402
+ return out
403
+
404
+ if ev.type == "run_waiting_human":
405
+ data: Dict[str, Any] = {"status": "pending"}
406
+ error_kind = ev.payload.get("error_kind")
407
+ if isinstance(error_kind, str) and error_kind.strip():
408
+ data["error_kind"] = error_kind.strip()
409
+ out.append(self._emit(type="node.phase", path=base_path, data={"phase": "REPORTING"}))
410
+ out.append(self._emit(type="node.phase", path=base_path, data={"phase": "DONE"}))
411
+ out.append(self._emit(type="node.finished", path=base_path, data=data))
412
+ return out
413
+
414
+ # run_cancelled:在本仓多数语义用于“中断等待”(例如审批挂起),UI 层用 pending 表达更稳妥。
415
+ status = "pending"
416
+ out.append(self._emit(type="node.phase", path=base_path, data={"phase": "REPORTING"}))
417
+ out.append(self._emit(type="node.phase", path=base_path, data={"phase": "DONE"}))
418
+ out.append(self._emit(type="node.finished", path=base_path, data={"status": status}))
419
+ return out
420
+
421
+ if ev.type == "tool_call_requested":
422
+ call_id = str(ev.payload.get("call_id") or "").strip()
423
+ tool = str(ev.payload.get("name") or "").strip()
424
+ args_summary = _summarize_dict(ev.payload.get("args")) or _summarize_dict(ev.payload.get("arguments"))
425
+ path = list(base_path)
426
+ if tool in {"skill_exec", "skill_ref_read"}:
427
+ args = ev.payload.get("args") if isinstance(ev.payload.get("args"), dict) else None
428
+ if args is None:
429
+ args = ev.payload.get("arguments") if isinstance(ev.payload.get("arguments"), dict) else None
430
+
431
+ locator = None
432
+ if isinstance(ev.payload.get("skill_locator"), str) and str(ev.payload.get("skill_locator")).strip():
433
+ locator = str(ev.payload.get("skill_locator")).strip()
434
+ if locator is None and isinstance(args, dict):
435
+ if isinstance(args.get("skill_locator"), str) and str(args.get("skill_locator")).strip():
436
+ locator = str(args.get("skill_locator")).strip()
437
+ if locator is None and isinstance(args, dict):
438
+ mention = args.get("mention_text") or args.get("skill_mention")
439
+ if isinstance(mention, str) and mention.strip():
440
+ locator = self._skill_mention_to_locator.get(mention.strip())
441
+ if locator is None and isinstance(args, dict):
442
+ sn = args.get("skill_name")
443
+ if isinstance(sn, str) and sn.strip():
444
+ locator = self._skill_name_to_locator.get(sn.strip())
445
+ if isinstance(locator, str) and locator.strip():
446
+ path.append(PathSegment(kind="skill", id=locator.strip()))
447
+ if tool:
448
+ path.append(PathSegment(kind="tool", id=tool))
449
+ if call_id:
450
+ path.append(PathSegment(kind="call", id=call_id))
451
+ # 绑定 call origin:后续 finished/approval 必须复用该归属(哪来哪去)
452
+ self._call_origin_path.setdefault(call_id, list(path))
453
+ # 记录 step_id -> call_id(approvals 事件可能缺 call_id)
454
+ step_scope_key = self._step_scope_key(ctx=ctx)
455
+ if step_scope_key:
456
+ self._step_scope_to_call_id[step_scope_key] = call_id
457
+ if tool:
458
+ self._step_scope_tool_to_call_id[(step_scope_key, tool)] = call_id
459
+ out.append(self._emit(type="node.phase", path=base_path, data={"phase": "TOOL_RUNNING"}))
460
+ data = {"tool": tool, "call_id": call_id}
461
+ if args_summary is not None:
462
+ data["args_summary"] = args_summary
463
+ out.append(self._emit(type="tool.requested", path=path, data=data, evidence=Evidence(call_id=call_id)))
464
+ return out
465
+
466
+ if ev.type == "approval_requested":
467
+ tool = str(ev.payload.get("tool") or "").strip()
468
+ call_id = str(ev.payload.get("call_id") or "").strip()
469
+ approval_key = ev.payload.get("approval_key")
470
+ unresolved = False
471
+ if not call_id:
472
+ recovered = self._best_effort_call_id_from_step(ctx=ctx, tool=tool)
473
+ if recovered:
474
+ call_id = recovered
475
+ else:
476
+ unresolved = True
477
+
478
+ origin = self._call_origin_path.get(call_id) if call_id else None
479
+ path = list(origin) if origin is not None else list(base_path)
480
+ if origin is None:
481
+ if tool:
482
+ path.append(PathSegment(kind="tool", id=tool))
483
+ if call_id:
484
+ path.append(PathSegment(kind="call", id=call_id))
485
+ # best-effort:即便 tool.requested 缺失,也先绑定 origin,避免后续归属漂移
486
+ self._call_origin_path.setdefault(call_id, list(path))
487
+ path.append(PathSegment(kind="approval", id=str(approval_key or call_id or "approval")))
488
+ out.append(self._emit(type="node.phase", path=base_path, data={"phase": "WAITING_APPROVAL"}))
489
+ data = {"tool": tool}
490
+ if call_id:
491
+ data["call_id"] = call_id
492
+ if approval_key is not None:
493
+ data["approval_key"] = str(approval_key)
494
+ if unresolved:
495
+ # 按 D4 的最小可诊断信号:fail-open,但不得静默
496
+ data["correlation"] = "missing_call_id"
497
+ data["correlation_error"] = {
498
+ "kind": "missing_call_id",
499
+ "strategy": "step_id",
500
+ "step_id": str(ctx.step_id or ""),
501
+ "tool": tool,
502
+ }
503
+ out.append(
504
+ self._emit(
505
+ type="approval.requested",
506
+ path=path,
507
+ data=data,
508
+ evidence=Evidence(call_id=call_id) if call_id else None,
509
+ )
510
+ )
511
+ return out
512
+
513
+ if ev.type == "approval_decided":
514
+ tool = str(ev.payload.get("tool") or "").strip()
515
+ call_id = str(ev.payload.get("call_id") or "").strip()
516
+ decision = str(ev.payload.get("decision") or "").strip()
517
+ reason = ev.payload.get("reason")
518
+ approval_key = ev.payload.get("approval_key")
519
+ unresolved = False
520
+ if not call_id:
521
+ recovered = self._best_effort_call_id_from_step(ctx=ctx, tool=tool)
522
+ if recovered:
523
+ call_id = recovered
524
+ else:
525
+ unresolved = True
526
+
527
+ origin = self._call_origin_path.get(call_id) if call_id else None
528
+ path = list(origin) if origin is not None else list(base_path)
529
+ if origin is None:
530
+ if tool:
531
+ path.append(PathSegment(kind="tool", id=tool))
532
+ if call_id:
533
+ path.append(PathSegment(kind="call", id=call_id))
534
+ # best-effort:即便 tool.requested 缺失,也先绑定 origin,避免后续归属漂移
535
+ self._call_origin_path.setdefault(call_id, list(path))
536
+ path.append(PathSegment(kind="approval", id=str(approval_key or call_id or "approval")))
537
+ out.append(self._emit(type="node.phase", path=base_path, data={"phase": "TOOL_RUNNING"}))
538
+ data = {"tool": tool, "decision": decision or "unknown"}
539
+ if call_id:
540
+ data["call_id"] = call_id
541
+ if reason is not None:
542
+ data["reason"] = str(reason)
543
+ if unresolved:
544
+ data["correlation"] = "missing_call_id"
545
+ data["correlation_error"] = {
546
+ "kind": "missing_call_id",
547
+ "strategy": "step_id",
548
+ "step_id": str(ctx.step_id or ""),
549
+ "tool": tool,
550
+ }
551
+ out.append(
552
+ self._emit(
553
+ type="approval.decided",
554
+ path=path,
555
+ data=data,
556
+ evidence=Evidence(call_id=call_id) if call_id else None,
557
+ )
558
+ )
559
+ return out
560
+
561
+ if ev.type == "tool_call_finished":
562
+ call_id = str(ev.payload.get("call_id") or "").strip()
563
+ tool = str(ev.payload.get("tool") or "").strip()
564
+ result = ev.payload.get("result") if isinstance(ev.payload.get("result"), dict) else {}
565
+ ok = result.get("ok") if isinstance(result.get("ok"), bool) else None
566
+ error_kind = result.get("error_kind") if isinstance(result.get("error_kind"), str) else None
567
+ result_summary = _summarize_dict(result.get("data"))
568
+ origin = self._call_origin_path.get(call_id) if call_id else None
569
+ path = list(origin) if origin is not None else list(base_path)
570
+ if origin is None:
571
+ if tool:
572
+ path.append(PathSegment(kind="tool", id=tool))
573
+ if call_id:
574
+ path.append(PathSegment(kind="call", id=call_id))
575
+ out.append(self._emit(type="node.phase", path=base_path, data={"phase": "THINKING"}))
576
+ data = {"tool": tool, "call_id": call_id, "ok": bool(ok)}
577
+ if error_kind:
578
+ data["error_kind"] = error_kind
579
+ if result_summary is not None:
580
+ data["result_summary"] = result_summary
581
+ out.append(self._emit(type="tool.finished", path=path, data=data, evidence=Evidence(call_id=call_id)))
582
+ return out
583
+
584
+ return []
585
+
586
+ def on_terminal(self, result: CapabilityResult) -> List[RuntimeEvent]:
587
+ terminal_status = _normalize_terminal_status(result.status)
588
+ evidence = self._evidence_from_node_report(result.node_report)
589
+ data: Dict[str, Any] = {"status": terminal_status}
590
+ if result.node_report is not None:
591
+ structured_output = result.node_report.meta.get("structured_output")
592
+ if isinstance(structured_output, dict):
593
+ data["structured_output"] = dict(structured_output)
594
+ output_validation = result.node_report.meta.get("output_validation")
595
+ if isinstance(output_validation, dict):
596
+ data["output_validation"] = dict(output_validation)
597
+ host_runtime = project_host_runtime_data(result)
598
+ if isinstance(host_runtime, dict):
599
+ data["host_runtime"] = host_runtime
600
+ return [
601
+ self._emit(
602
+ type="run.status",
603
+ path=self._base_path(),
604
+ data=data,
605
+ evidence=evidence,
606
+ )
607
+ ]
608
+
609
+ def _evidence_from_node_report(self, report: Optional[NodeReport]) -> Optional[Evidence]:
610
+ if report is None:
611
+ return None
612
+ ev = Evidence(node_report_schema=getattr(report, "schema_id", None))
613
+ if isinstance(report.events_path, str) and report.events_path:
614
+ ev.events_path = report.events_path
615
+ if report.artifacts and isinstance(report.artifacts[0], str) and report.artifacts[0].strip():
616
+ ev.artifact_path = report.artifacts[0].strip()
617
+ return ev