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,497 @@
1
+ """
2
+ NodeReportBuilder:把 SDK `AgentEvent` 聚合为 NodeReport(schema v1)。
3
+
4
+ 对齐规格:
5
+ - `openspec/specs/evidence-chain/spec.md`
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import importlib.metadata
11
+ from dataclasses import dataclass
12
+ from typing import Any, Dict, Iterable, List, Optional
13
+
14
+ from skills_runtime.core.contracts import AgentEvent
15
+
16
+ from ..logging_utils import log_suppressed_exception
17
+ from ..types import NodeReport, NodeToolCallReport, NodeUsageReport
18
+ from ..utils.usage import extract_usage_metrics
19
+
20
+
21
+ def _get_skills_runtime_version() -> Optional[str]:
22
+ """读取 skills_runtime.__version__(若可用)。"""
23
+
24
+ try:
25
+ import skills_runtime
26
+
27
+ v = getattr(skills_runtime, "__version__", None)
28
+ if isinstance(v, str) and v.strip():
29
+ return v.strip()
30
+ except Exception as exc:
31
+ log_suppressed_exception(
32
+ context="get_skills_runtime_version",
33
+ exc=exc,
34
+ )
35
+ return None
36
+ return None
37
+
38
+
39
+ def _get_dist_version(dist_name: str) -> Optional[str]:
40
+ """读取已安装 distribution 的版本号;不存在则返回 None。"""
41
+
42
+ try:
43
+ return importlib.metadata.version(dist_name)
44
+ except Exception as exc:
45
+ log_suppressed_exception(
46
+ context="get_dist_version",
47
+ exc=exc,
48
+ extra={"dist_name": dist_name},
49
+ )
50
+ return None
51
+
52
+
53
+ def _get_first_dist_version(dist_names: List[str]) -> Optional[str]:
54
+ """
55
+ 读取一组候选 distribution 名称中的第一个可用版本号。
56
+
57
+ 参数:
58
+ - dist_names:候选 dist 名称列表(按优先级排序)
59
+
60
+ 返回:
61
+ - 第一个可读取的版本号;都不可用则返回 None
62
+ """
63
+
64
+ for name in dist_names:
65
+ v = _get_dist_version(name)
66
+ if v:
67
+ return v
68
+ return None
69
+
70
+
71
+ @dataclass
72
+ class NodeReportBuilder:
73
+ """
74
+ NodeReport 聚合器。
75
+
76
+ 约束:
77
+ - 以证据链为主(tool/approval/run_* 事件)
78
+ - 不推断 domain payload
79
+ - 不把 stdout/stderr 大段塞进 NodeReport(避免泄露与膨胀)
80
+ """
81
+
82
+ def build(self, *, events: List[AgentEvent]) -> NodeReport:
83
+ """
84
+ 从一次 run 的事件序列构造 NodeReport(schema v1)。
85
+
86
+ 参数:
87
+ - `events`:按发生顺序排列的 SDK AgentEvent 列表
88
+
89
+ 返回:
90
+ - NodeReport
91
+ """
92
+
93
+ if not events:
94
+ return NodeReport(
95
+ status="failed",
96
+ reason="no_events",
97
+ completion_reason="no_events",
98
+ engine={
99
+ "name": "skills-runtime-sdk-python",
100
+ "module": "skills_runtime",
101
+ "version": _get_skills_runtime_version()
102
+ or _get_first_dist_version(["skills-runtime-sdk", "skills-runtime-sdk-python"]),
103
+ },
104
+ bridge={"name": "capability-runtime", "version": _get_first_dist_version(["capability-runtime"])},
105
+ run_id="",
106
+ turn_id=None,
107
+ events_path=None,
108
+ activated_skills=[],
109
+ tool_calls=[],
110
+ artifacts=[],
111
+ meta={"missing_events_path": True, "final_message": None},
112
+ )
113
+
114
+ run_id = events[0].run_id
115
+ turn_id = None
116
+ for ev in events:
117
+ if ev.turn_id:
118
+ turn_id = ev.turn_id
119
+
120
+ activated_skills: List[str] = []
121
+ seen_skills: set[str] = set()
122
+
123
+ artifacts: List[str] = []
124
+ seen_artifacts: set[str] = set()
125
+
126
+ def _add_artifact(path: str) -> None:
127
+ """
128
+ 记录 artifact 路径(去重但保持首次出现顺序)。
129
+
130
+ 参数:
131
+ - path:artifact 路径字符串(必须为非空)
132
+ """
133
+
134
+ p = str(path or "").strip()
135
+ if not p or p in seen_artifacts:
136
+ return
137
+ artifacts.append(p)
138
+ seen_artifacts.add(p)
139
+
140
+ # call_id -> aggregated fields
141
+ tool_calls: Dict[str, Dict[str, Any]] = {}
142
+ approval_pending: set[str] = set()
143
+ requires_approval_inferred: set[str] = set()
144
+
145
+ # 兼容 SDK 默认 approvals 事件形态:
146
+ # - tool_call_requested payload 带 call_id/name
147
+ # - approval_requested/approval_decided payload 可能不带 call_id,仅能通过 step_id 关联到同一步的 tool_call_requested
148
+ step_to_call: Dict[str, Dict[str, str]] = {}
149
+ tool_safety: Dict[str, Dict[str, str]] = {}
150
+
151
+ completion_status: Optional[str] = None
152
+ completion_reason = ""
153
+ events_path: Optional[str] = None
154
+ final_error_kind: Optional[str] = None
155
+ final_message: Optional[str] = None
156
+
157
+ usage_model: Optional[str] = None
158
+ usage_input_total = 0
159
+ usage_output_total = 0
160
+ usage_total_total = 0
161
+ usage_input_seen = False
162
+ usage_output_seen = False
163
+ usage_total_seen = False
164
+
165
+ def _ensure_tool(call_id: str, *, name: str) -> Dict[str, Any]:
166
+ """获取/初始化工具调用聚合槽位(以 call_id 为主键)。"""
167
+
168
+ if call_id not in tool_calls:
169
+ tool_calls[call_id] = {
170
+ "call_id": call_id,
171
+ "name": name,
172
+ "requires_approval": False,
173
+ "approval_key": None,
174
+ "approval_decision": None,
175
+ "approval_reason": None,
176
+ "ok": False,
177
+ "error_kind": None,
178
+ "data": None,
179
+ }
180
+ return tool_calls[call_id]
181
+
182
+ def _record_tool_safety(*, call_id: str, payload: Dict[str, Any], source: str) -> None:
183
+ """记录最小 tool safety 摘要(仅保留枚举字符串与来源)。"""
184
+
185
+ args = payload.get("arguments")
186
+ if not isinstance(args, dict):
187
+ args = payload.get("args")
188
+ if not isinstance(args, dict):
189
+ args = payload.get("request")
190
+ if not isinstance(args, dict):
191
+ return
192
+ sandbox_permissions = args.get("sandbox_permissions")
193
+ if not isinstance(sandbox_permissions, str) or not sandbox_permissions.strip():
194
+ return
195
+ tool_safety.setdefault(
196
+ call_id,
197
+ {
198
+ "sandbox_permissions": sandbox_permissions.strip(),
199
+ "source": source,
200
+ },
201
+ )
202
+
203
+ for ev in events:
204
+ artifact_path = ev.payload.get("artifact_path")
205
+ if isinstance(artifact_path, str) and artifact_path.strip():
206
+ _add_artifact(artifact_path)
207
+
208
+ if ev.type == "skill_injected":
209
+ skill_name = str(ev.payload.get("skill_name") or "").strip()
210
+ if skill_name and skill_name not in seen_skills:
211
+ activated_skills.append(skill_name)
212
+ seen_skills.add(skill_name)
213
+
214
+ if ev.type == "llm_usage":
215
+ usage_summary = extract_usage_metrics(ev.payload)
216
+ model_name = usage_summary.get("model")
217
+ if isinstance(model_name, str) and model_name.strip():
218
+ usage_model = model_name.strip()
219
+
220
+ input_tokens = usage_summary.get("input_tokens")
221
+ if isinstance(input_tokens, int):
222
+ usage_input_total += input_tokens
223
+ usage_input_seen = True
224
+
225
+ output_tokens = usage_summary.get("output_tokens")
226
+ if isinstance(output_tokens, int):
227
+ usage_output_total += output_tokens
228
+ usage_output_seen = True
229
+
230
+ total_tokens = usage_summary.get("total_tokens")
231
+ if isinstance(total_tokens, int):
232
+ usage_total_total += total_tokens
233
+ usage_total_seen = True
234
+
235
+ if ev.type == "tool_call_requested":
236
+ call_id = str(ev.payload.get("call_id") or "").strip()
237
+ name = str(ev.payload.get("name") or "").strip()
238
+ if call_id and name:
239
+ _ensure_tool(call_id, name=name)
240
+ _record_tool_safety(call_id=call_id, payload=ev.payload, source="tool_call_requested")
241
+ if ev.step_id:
242
+ step_to_call[str(ev.step_id)] = {"call_id": call_id, "tool": name}
243
+
244
+ if ev.type == "approval_requested":
245
+ tool = str(ev.payload.get("tool") or "").strip()
246
+ approval_key = ev.payload.get("approval_key")
247
+ call_id = str(ev.payload.get("call_id") or "").strip()
248
+ if not call_id and ev.step_id:
249
+ mapped = step_to_call.get(str(ev.step_id))
250
+ if mapped and (not tool or tool == mapped.get("tool")):
251
+ call_id = mapped.get("call_id", "")
252
+ tool = tool or mapped.get("tool", "")
253
+ if call_id and tool:
254
+ t = _ensure_tool(call_id, name=tool)
255
+ t["requires_approval"] = True
256
+ # Bridge 无法稳定读取 tool spec 时,只能通过 approval_* 事件推断 requires_approval。
257
+ requires_approval_inferred.add(call_id)
258
+ _record_tool_safety(call_id=call_id, payload=ev.payload, source="approval_requested")
259
+ if isinstance(approval_key, str) and approval_key:
260
+ t["approval_key"] = approval_key
261
+ approval_pending.add(call_id)
262
+
263
+ if ev.type == "approval_decided":
264
+ tool = str(ev.payload.get("tool") or "").strip()
265
+ decision = ev.payload.get("decision")
266
+ reason = ev.payload.get("reason")
267
+ call_id = str(ev.payload.get("call_id") or "").strip()
268
+ if not call_id and ev.step_id:
269
+ mapped = step_to_call.get(str(ev.step_id))
270
+ if mapped and (not tool or tool == mapped.get("tool")):
271
+ call_id = mapped.get("call_id", "")
272
+ tool = tool or mapped.get("tool", "")
273
+ if call_id and tool:
274
+ t = _ensure_tool(call_id, name=tool)
275
+ t["requires_approval"] = True
276
+ requires_approval_inferred.add(call_id)
277
+ if isinstance(decision, str) and decision:
278
+ t["approval_decision"] = decision
279
+ if isinstance(reason, str) and reason:
280
+ t["approval_reason"] = reason
281
+ approval_pending.discard(call_id)
282
+
283
+ if ev.type == "tool_call_finished":
284
+ call_id = str(ev.payload.get("call_id") or "").strip()
285
+ tool = str(ev.payload.get("tool") or "").strip()
286
+ result = ev.payload.get("result") or {}
287
+ if call_id and tool:
288
+ t = _ensure_tool(call_id, name=tool)
289
+ if isinstance(result, dict):
290
+ ok = result.get("ok")
291
+ if isinstance(ok, bool):
292
+ t["ok"] = ok
293
+ error_kind = result.get("error_kind")
294
+ if isinstance(error_kind, str) and error_kind:
295
+ t["error_kind"] = error_kind
296
+ data = result.get("data")
297
+ if isinstance(data, dict):
298
+ t["data"] = data
299
+
300
+ if ev.type in ("run_completed", "run_failed", "run_cancelled", "run_waiting_human"):
301
+ # skills-runtime-sdk>=1.0 使用 `wal_locator` 作为 WAL/事件证据链定位符;
302
+ # 本仓对外仍沿用 `events_path` 字段名,语义上存放 locator 字符串(可能是文件路径,也可能是 wal://...)。
303
+ locator_raw = ev.payload.get("events_path")
304
+ if not (isinstance(locator_raw, str) and locator_raw.strip()):
305
+ locator_raw = ev.payload.get("wal_locator")
306
+ if isinstance(locator_raw, str) and locator_raw.strip():
307
+ events_path = locator_raw.strip()
308
+
309
+ # artifacts(run 级别产物列表)
310
+ artifacts_raw = ev.payload.get("artifacts")
311
+ if isinstance(artifacts_raw, list):
312
+ for item in artifacts_raw:
313
+ if isinstance(item, str) and item.strip():
314
+ _add_artifact(item)
315
+
316
+ completion_reason = ev.type
317
+ if ev.type == "run_completed":
318
+ completion_status = "success"
319
+ elif ev.type == "run_failed":
320
+ final_error_kind = ev.payload.get("error_kind") if isinstance(ev.payload.get("error_kind"), str) else None
321
+ final_message = ev.payload.get("message") if isinstance(ev.payload.get("message"), str) else None
322
+ # 对齐契约:预算耗尽属于“未完成”而非“失败”(Host 可能需要走补偿/降级)。
323
+ if final_error_kind in ("budget_exceeded", "terminated"):
324
+ completion_status = "incomplete"
325
+ else:
326
+ completion_status = "failed"
327
+ elif ev.type == "run_waiting_human":
328
+ completion_status = "needs_approval"
329
+ final_error_kind = ev.payload.get("error_kind") if isinstance(ev.payload.get("error_kind"), str) else None
330
+ final_message = ev.payload.get("message") if isinstance(ev.payload.get("message"), str) else None
331
+ else:
332
+ completion_status = "incomplete"
333
+ final_message = ev.payload.get("message") if isinstance(ev.payload.get("message"), str) else None
334
+
335
+ # 优先级:needs_approval(approval_requested / run_waiting_human) > run_failed > run_cancelled > run_completed
336
+ status = completion_status or "incomplete"
337
+ reason = None
338
+ if approval_pending:
339
+ status = "needs_approval"
340
+ reason = "approval_pending"
341
+ elif status == "needs_approval":
342
+ reason = "approval_pending"
343
+ elif status == "failed":
344
+ # 失败原因粗分类:优先 error_kind,其次 message
345
+ if final_error_kind:
346
+ if final_error_kind in ("permission", "policy", "approval_denied"):
347
+ reason = "tool_error"
348
+ elif final_error_kind in (
349
+ "validation",
350
+ "not_found",
351
+ "config_error",
352
+ "skill_config_error",
353
+ "missing_env_var",
354
+ "SKILL_PREFLIGHT_FAILED",
355
+ ):
356
+ reason = "skill_config_error"
357
+ elif final_error_kind.startswith("network_") or final_error_kind in (
358
+ "auth_error",
359
+ "rate_limited",
360
+ "server_error",
361
+ "http_error",
362
+ "context_length_exceeded",
363
+ ):
364
+ reason = "llm_error"
365
+ else:
366
+ reason = "unknown"
367
+ else:
368
+ reason = "unknown"
369
+ elif status == "incomplete":
370
+ if final_error_kind == "budget_exceeded":
371
+ reason = "budget_exceeded"
372
+ elif final_error_kind == "terminated":
373
+ reason = "cancelled"
374
+ else:
375
+ reason = "cancelled" if completion_reason == "run_cancelled" else "unknown"
376
+
377
+ tool_reports = [
378
+ NodeToolCallReport.model_validate(item) for item in tool_calls.values() if isinstance(item, dict)
379
+ ]
380
+ usage_report: Optional[NodeUsageReport] = None
381
+ if usage_model is not None or usage_input_seen or usage_output_seen or usage_total_seen:
382
+ usage_report = NodeUsageReport(
383
+ model=usage_model,
384
+ input_tokens=usage_input_total if usage_input_seen else None,
385
+ output_tokens=usage_output_total if usage_output_seen else None,
386
+ total_tokens=usage_total_total if usage_total_seen else None,
387
+ )
388
+
389
+ report = NodeReport(
390
+ status=status, # type: ignore[arg-type]
391
+ reason=reason,
392
+ completion_reason=completion_reason or "",
393
+ engine={
394
+ "name": "skills-runtime-sdk-python",
395
+ "module": "skills_runtime",
396
+ "version": _get_skills_runtime_version()
397
+ or _get_first_dist_version(["skills-runtime-sdk", "skills-runtime-sdk-python"]),
398
+ },
399
+ bridge={"name": "capability-runtime", "version": _get_first_dist_version(["capability-runtime"])},
400
+ run_id=run_id,
401
+ turn_id=turn_id,
402
+ events_path=events_path,
403
+ usage=usage_report,
404
+ activated_skills=activated_skills,
405
+ tool_calls=tool_reports,
406
+ artifacts=artifacts,
407
+ meta={
408
+ "missing_events_path": events_path is None,
409
+ "final_message": final_message,
410
+ **(
411
+ {
412
+ "approval_inference": {
413
+ "requires_approval_call_ids": sorted(requires_approval_inferred),
414
+ "source": "events_only",
415
+ }
416
+ }
417
+ if requires_approval_inferred
418
+ else {}
419
+ ),
420
+ **({"tool_safety": tool_safety} if tool_safety else {}),
421
+ },
422
+ )
423
+ return report
424
+
425
+
426
+ def build_node_report_from_events(events: Iterable[AgentEvent]) -> NodeReport:
427
+ """便捷函数:从事件迭代器构造 NodeReport(schema v1)。"""
428
+
429
+ builder = NodeReportBuilder()
430
+ return builder.build(events=list(events))
431
+
432
+
433
+ def build_fail_closed_report(
434
+ *,
435
+ run_id: str,
436
+ status: str,
437
+ reason: Optional[str],
438
+ completion_reason: str,
439
+ meta: Dict[str, Any],
440
+ ) -> NodeReport:
441
+ """
442
+ 构造不依赖事件流的最小 NodeReport(用于 fail-closed 分支)。
443
+
444
+ 说明:
445
+ - preflight/output validator 等 gate 可能在启动引擎前返回;
446
+ - 仍需产出稳定的 engine/bridge 身份信息,供编排与审计使用;
447
+ - events_path 在此分支为 None(不得伪造)。
448
+ """
449
+
450
+ def _get_version(names: List[str]) -> Optional[str]:
451
+ # 证据链上优先使用 skills_runtime.__version__(比 dist-info 更可靠,尤其在 editable 安装场景)。
452
+ if any(n in ("skills-runtime-sdk", "skills-runtime-sdk-python") for n in names):
453
+ try:
454
+ import skills_runtime
455
+
456
+ v = getattr(skills_runtime, "__version__", None)
457
+ if isinstance(v, str) and v.strip():
458
+ return v.strip()
459
+ except Exception as exc:
460
+ log_suppressed_exception(
461
+ context="node_report_get_version_skills_runtime",
462
+ exc=exc,
463
+ )
464
+ for n in names:
465
+ try:
466
+ return importlib.metadata.version(n)
467
+ except Exception as exc:
468
+ log_suppressed_exception(
469
+ context="node_report_get_version_dist",
470
+ exc=exc,
471
+ extra={"dist_name": n},
472
+ )
473
+ continue
474
+ return None
475
+
476
+ return NodeReport(
477
+ status=status, # type: ignore[arg-type]
478
+ reason=reason,
479
+ completion_reason=completion_reason,
480
+ engine={
481
+ "name": "skills-runtime-sdk-python",
482
+ "module": "skills_runtime",
483
+ "version": _get_version(["skills-runtime-sdk", "skills-runtime-sdk-python"]),
484
+ },
485
+ bridge={
486
+ "name": "capability-runtime",
487
+ "version": _get_version(["capability-runtime"]),
488
+ },
489
+ run_id=run_id,
490
+ turn_id=None,
491
+ events_path=None,
492
+ usage=None,
493
+ activated_skills=[],
494
+ tool_calls=[],
495
+ artifacts=[],
496
+ meta=dict(meta or {}),
497
+ )