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,982 @@
1
+ from __future__ import annotations
2
+
3
+ """SDK 生命周期组件:初始化、预检与 per-run Agent 创建。"""
4
+
5
+ import inspect
6
+ from datetime import datetime, timezone
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+ from typing import Any, AsyncGenerator, AsyncIterator, Dict, List, Optional
10
+
11
+ from skills_runtime.core.contracts import AgentEvent
12
+ from skills_runtime.core.errors import FrameworkError, FrameworkIssue
13
+ from skills_runtime.llm.chat_sse import ChatStreamEvent
14
+ from skills_runtime.llm.protocol import ChatRequest
15
+ from skills_runtime.skills.manager import SkillsManager
16
+
17
+ from .config import CustomTool, RuntimeConfig, RuntimeMode, normalize_workspace_root
18
+ from .logging_utils import log_suppressed_exception
19
+ from .protocol.chat_backend import ChatBackendProtocol
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class _SdkInitState:
24
+ """bridge/sdk_native 初始化期共享资源集合(run 期只读)。"""
25
+
26
+ workspace_root: Path
27
+ config_paths: List[Path]
28
+ skills_config: Any
29
+ skills_config_overlay_issues: List[FrameworkIssue]
30
+ skills_scan_issues: List[FrameworkIssue]
31
+ backend: Any
32
+ shared_skills_manager: SkillsManager
33
+
34
+
35
+ class SdkLifecycle:
36
+ """封装 SDK 初始化、preflight 与 Agent 实例创建。"""
37
+
38
+ def __init__(self, config: RuntimeConfig) -> None:
39
+ """
40
+ 初始化 SDK 生命周期状态。
41
+
42
+ 参数:
43
+ - config:Runtime 配置
44
+ """
45
+
46
+ mode = str(getattr(config, "mode", ""))
47
+ if mode not in ("bridge", "sdk_native"):
48
+ raise ValueError(f"SdkLifecycle only supports bridge/sdk_native, got: {mode!r}")
49
+
50
+ self._config = config
51
+ self._state = self._init_state(mode=mode) # type: ignore[arg-type]
52
+
53
+ @property
54
+ def state(self) -> _SdkInitState:
55
+ """返回初始化后的只读状态。"""
56
+
57
+ return self._state
58
+
59
+ def preflight(self) -> List[FrameworkIssue]:
60
+ """
61
+ 执行 skills preflight(零 I/O)。
62
+
63
+ 返回:
64
+ - FrameworkIssue 列表;空列表表示通过
65
+ """
66
+
67
+ try:
68
+ mgr = SkillsManager(
69
+ workspace_root=self._state.workspace_root,
70
+ skills_config=self._state.skills_config,
71
+ )
72
+ upstream_issues = mgr.preflight()
73
+ overlay_issues = list(getattr(self._state, "skills_config_overlay_issues", []) or [])
74
+ scan_issues = list(getattr(self._state, "skills_scan_issues", []) or [])
75
+ return overlay_issues + scan_issues + list(upstream_issues or [])
76
+ except Exception as exc:
77
+ # preflight 异常不得 fail-open:否则 preflight_mode="error" gate 会被绕过。
78
+ return [
79
+ FrameworkIssue(
80
+ code="SKILL_PREFLIGHT_EXCEPTION",
81
+ message="Skills preflight raised exception",
82
+ details={"exception_type": type(exc).__name__},
83
+ )
84
+ ]
85
+
86
+ def create_agent(self, *, custom_tools: List[CustomTool], llm_config: Optional[Dict[str, Any]] = None) -> Any:
87
+ """
88
+ 创建 per-run SDK Agent 实例(避免跨 run 共享可变状态)。
89
+
90
+ 参数:
91
+ - custom_tools:每次创建 Agent 时要注册的自定义工具列表
92
+ - llm_config:可选 LLM 覆写配置(当前支持 `model`、`tool_choice` 与 `response_format` 字段覆写)
93
+ """
94
+
95
+ from skills_runtime.core.agent import Agent
96
+
97
+ backend: Any = self._state.backend
98
+ override_model = _extract_model_override(llm_config)
99
+ if override_model is not None:
100
+ backend = _ModelOverrideBackend(backend=backend, model=override_model)
101
+
102
+ override_tool_choice = _extract_tool_choice_override(llm_config)
103
+ if override_tool_choice is not None:
104
+ backend = _ToolChoiceOverrideBackend(backend=backend, tool_choice=override_tool_choice)
105
+
106
+ override_response_format = _extract_response_format_override(llm_config)
107
+ if override_response_format is not None:
108
+ backend = _ResponseFormatOverrideBackend(backend=backend, response_format=override_response_format)
109
+ usage_bridge_backend = _UsageTapBackend(backend=backend)
110
+ backend = usage_bridge_backend
111
+
112
+ kwargs: Dict[str, Any] = {
113
+ "workspace_root": self._state.workspace_root,
114
+ "config_paths": list(self._state.config_paths),
115
+ "env_vars": dict(self._config.env_vars),
116
+ "backend": backend,
117
+ "human_io": self._config.human_io,
118
+ "approval_provider": self._config.approval_provider,
119
+ "cancel_checker": self._config.cancel_checker,
120
+ "exec_sessions": self._config.exec_sessions,
121
+ "collab_manager": self._config.collab_manager,
122
+ "skills_manager": self._state.shared_skills_manager,
123
+ # 建设期:直接依赖新版上游 WAL 抽象(不做旧版兼容探测)。
124
+ "wal_backend": self._config.wal_backend,
125
+ }
126
+
127
+ agent = Agent(**kwargs)
128
+ diagnostics: Dict[str, Dict[str, bool]] = {}
129
+ for t in custom_tools:
130
+ diagnostics[t.spec.name] = _register_custom_tool_compat(agent, t)
131
+ bridge = _AgentUsageEventBridge(agent=agent, usage_bridge_backend=usage_bridge_backend)
132
+ setattr(bridge, "_caprt_tool_registration_diagnostics", diagnostics)
133
+ return bridge
134
+
135
+ def _load_sdk_config(self, config_paths: List[Path]) -> tuple[Any, List[FrameworkIssue]]:
136
+ """
137
+ 加载 SDK 配置文件并合并 overlays。
138
+
139
+ 参数:
140
+ - config_paths:配置文件路径列表
141
+
142
+ 返回:
143
+ - (merged_config, overlay_issues)
144
+ """
145
+ from skills_runtime.config.defaults import load_default_config_dict
146
+ from skills_runtime.config.loader import load_config_dicts
147
+
148
+ overlays: List[Dict[str, Any]] = [load_default_config_dict()]
149
+ overlay_issues: List[FrameworkIssue] = []
150
+ for p in config_paths:
151
+ try:
152
+ raw = _load_yaml_dict(p)
153
+ sanitized, issues = _sanitize_sdk_overlay_dict_for_loader(raw)
154
+ overlays.append(sanitized)
155
+ overlay_issues.extend(issues)
156
+ except Exception as exc:
157
+ log_suppressed_exception(
158
+ context="load_sdk_config_overlay",
159
+ exc=exc,
160
+ extra={"config_path": str(p)},
161
+ )
162
+ overlay_issues.append(
163
+ FrameworkIssue(
164
+ code="SKILL_CONFIG_OVERLAY_LOAD_FAILED",
165
+ message="Failed to load SDK config overlay; overlay ignored.",
166
+ details={"path": str(p), "exception_type": type(exc).__name__},
167
+ )
168
+ )
169
+ if self._config.preflight_mode == "error":
170
+ raise FrameworkError(
171
+ code="SKILL_CONFIG_OVERLAY_LOAD_FAILED",
172
+ message="Failed to load SDK config overlay during Runtime initialization.",
173
+ details={"path": str(p), "exception_type": type(exc).__name__},
174
+ ) from exc
175
+ overlays.append({})
176
+ cfg = load_config_dicts(overlays)
177
+ return cfg, overlay_issues
178
+
179
+ def _resolve_skills_config(self, cfg: Any) -> tuple[Any, List[FrameworkIssue]]:
180
+ """
181
+ 解析 skills 配置(优先使用 RuntimeConfig.skills_config,否则使用 SDK config)。
182
+
183
+ 参数:
184
+ - cfg:已加载的 SDK 配置对象
185
+
186
+ 返回:
187
+ - (归一化后的 skills_config, 归一化阶段产生的 issues)
188
+ """
189
+ if self._config.skills_config is not None:
190
+ return _normalize_skills_config_for_skills_runtime(self._config.skills_config)
191
+ else:
192
+ return cfg.skills, []
193
+
194
+ def _build_backend(self, *, mode: RuntimeMode, cfg: Any) -> Any:
195
+ """
196
+ 构建 ChatBackend 实例。
197
+
198
+ 参数:
199
+ - mode:bridge 或 sdk_native
200
+ - cfg:已加载的 SDK 配置对象
201
+
202
+ 返回:
203
+ - ChatBackend 实例
204
+ """
205
+ if self._config.sdk_backend is not None:
206
+ # 离线/测试注入:允许用 FakeChatBackend 等驱动真实 Agent loop,产出完整证据链。
207
+ return self._config.sdk_backend
208
+ elif mode == "bridge":
209
+ if self._config.agently_agent is None:
210
+ raise ValueError("RuntimeConfig.agently_agent is required when mode='bridge'")
211
+ from .adapters.agently_backend import (
212
+ AgentlyBackendConfig,
213
+ AgentlyChatBackend,
214
+ build_openai_compatible_requester_factory,
215
+ )
216
+
217
+ requester_factory = build_openai_compatible_requester_factory(agently_agent=self._config.agently_agent)
218
+ return AgentlyChatBackend(config=AgentlyBackendConfig(requester_factory=requester_factory))
219
+ else:
220
+ from skills_runtime.llm.openai_chat import OpenAIChatCompletionsBackend
221
+
222
+ return OpenAIChatCompletionsBackend(cfg.llm)
223
+
224
+ def _build_skills_manager(self, *, workspace_root: Path, skills_config: Any) -> SkillsManager:
225
+ """
226
+ 构建 SkillsManager 实例。
227
+
228
+ 参数:
229
+ - workspace_root:工作区根目录
230
+ - skills_config:归一化后的 skills 配置
231
+
232
+ 返回:
233
+ - SkillsManager 实例
234
+ """
235
+ return SkillsManager(
236
+ workspace_root=workspace_root,
237
+ skills_config=skills_config,
238
+ in_memory_registry=self._config.in_memory_skills or {},
239
+ )
240
+
241
+ def _init_state(self, *, mode: RuntimeMode) -> _SdkInitState:
242
+ """
243
+ 初始化 bridge/sdk_native 的共享资源(backend + SkillsManager)。
244
+
245
+ 参数:
246
+ - mode:bridge 或 sdk_native
247
+ """
248
+
249
+ workspace_root = normalize_workspace_root(self._config.workspace_root)
250
+ config_paths = [Path(p).expanduser().resolve() for p in self._config.sdk_config_paths]
251
+
252
+ cfg, overlay_issues = self._load_sdk_config(config_paths)
253
+ skills_cfg, config_issues = self._resolve_skills_config(cfg)
254
+ overlay_issues.extend(config_issues)
255
+ backend = self._build_backend(mode=mode, cfg=cfg)
256
+ shared_skills_manager = self._build_skills_manager(workspace_root=workspace_root, skills_config=skills_cfg)
257
+
258
+ scan_issues: List[FrameworkIssue] = []
259
+ try:
260
+ report = shared_skills_manager.scan()
261
+ except Exception as exc:
262
+ log_suppressed_exception(
263
+ context="skills_manager_scan",
264
+ exc=exc,
265
+ extra={"workspace_root": str(workspace_root)},
266
+ )
267
+ scan_issues.append(
268
+ FrameworkIssue(
269
+ code="SKILL_SCAN_EXCEPTION",
270
+ message="Skills scan raised exception during Runtime initialization.",
271
+ details={"workspace_root": str(workspace_root), "exception_type": type(exc).__name__},
272
+ )
273
+ )
274
+ if self._config.preflight_mode == "error":
275
+ raise FrameworkError(
276
+ code="SKILL_SCAN_EXCEPTION",
277
+ message="Skills scan raised exception during Runtime initialization.",
278
+ details={"workspace_root": str(workspace_root), "exception_type": type(exc).__name__},
279
+ ) from exc
280
+ report = None
281
+
282
+ if report is not None and getattr(report, "errors", None):
283
+ errors = list(getattr(report, "errors", []) or [])
284
+ scan_issues.append(
285
+ FrameworkIssue(
286
+ code="SKILL_SCAN_FAILED",
287
+ message="Skills scan reported errors during Runtime initialization.",
288
+ details={"errors": errors},
289
+ )
290
+ )
291
+ if self._config.preflight_mode == "error":
292
+ raise FrameworkError(
293
+ code="SKILL_SCAN_FAILED",
294
+ message="Skills scan failed during Runtime initialization.",
295
+ details={"errors": errors},
296
+ )
297
+
298
+ return _SdkInitState(
299
+ workspace_root=workspace_root,
300
+ config_paths=config_paths,
301
+ skills_config=skills_cfg,
302
+ skills_config_overlay_issues=list(overlay_issues),
303
+ skills_scan_issues=list(scan_issues),
304
+ backend=backend,
305
+ shared_skills_manager=shared_skills_manager,
306
+ )
307
+
308
+
309
+ def _extract_model_override(llm_config: Optional[Dict[str, Any]]) -> Optional[str]:
310
+ """
311
+ 从 llm_config 中提取 model 覆写值。
312
+
313
+ 约束:
314
+ - 本期仅识别 `model` 字段;
315
+ - 空字符串/全空白视为"未设置"。
316
+ """
317
+
318
+ if not isinstance(llm_config, dict):
319
+ return None
320
+ raw = llm_config.get("model")
321
+ if not isinstance(raw, str):
322
+ return None
323
+ model = raw.strip()
324
+ return model or None
325
+
326
+
327
+ def _extract_tool_choice_override(llm_config: Optional[Dict[str, Any]]) -> Optional[Any]:
328
+ """
329
+ 从 llm_config 中提取 tool_choice 覆写值。
330
+
331
+ 约束:
332
+ - 仅识别 `tool_choice` 字段;
333
+ - 值必须为 string 或 dict;
334
+ - 必须可 JSON 序列化(JSON-able),否则 fail-closed 抛异常。
335
+ """
336
+
337
+ if not isinstance(llm_config, dict):
338
+ return None
339
+
340
+ raw = llm_config.get("tool_choice")
341
+ if raw is None:
342
+ return None
343
+
344
+ tool_choice: Any
345
+ if isinstance(raw, str):
346
+ v = raw.strip()
347
+ tool_choice = v or None
348
+ elif isinstance(raw, dict):
349
+ tool_choice = raw
350
+ else:
351
+ return None
352
+
353
+ if tool_choice is None:
354
+ return None
355
+
356
+ import json
357
+
358
+ try:
359
+ json.dumps(tool_choice)
360
+ except TypeError as exc:
361
+ raise ValueError("llm_config.tool_choice must be JSON-serializable") from exc
362
+
363
+ return tool_choice
364
+
365
+
366
+ def _extract_response_format_override(llm_config: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
367
+ """
368
+ 从 llm_config 中提取 response_format 覆写值。
369
+
370
+ 约束:
371
+ - 仅识别 `response_format` 字段;
372
+ - 值必须为 dict;
373
+ - 必须可 JSON 序列化(JSON-able),否则 fail-closed 抛异常。
374
+ """
375
+
376
+ if not isinstance(llm_config, dict):
377
+ return None
378
+
379
+ raw = llm_config.get("response_format")
380
+ if raw is None:
381
+ return None
382
+ if not isinstance(raw, dict):
383
+ return None
384
+
385
+ import json
386
+
387
+ try:
388
+ json.dumps(raw)
389
+ except TypeError as exc:
390
+ raise ValueError("llm_config.response_format must be JSON-serializable") from exc
391
+
392
+ return dict(raw)
393
+
394
+
395
+ def _clone_request_with_field_update(
396
+ request: ChatRequest,
397
+ *,
398
+ field_name: str,
399
+ value: Any,
400
+ dataclasses_context: str,
401
+ clone_context: str,
402
+ ) -> ChatRequest:
403
+ """
404
+ 以“安全复制 + 单字段覆写”的方式构造转发 request。
405
+
406
+ 约束:
407
+ - 覆写必须真实生效,不能在 clone 失败后静默回退原 request;
408
+ - 支持 dict / dataclass / pydantic(v1/v2) 常见形态;
409
+ - 若对象暴露了 clone 能力但全部失败,必须 fail-closed 抛异常。
410
+ """
411
+
412
+ if isinstance(request, dict):
413
+ forwarded = dict(request)
414
+ forwarded[field_name] = value
415
+ return forwarded
416
+
417
+ try:
418
+ import dataclasses
419
+
420
+ if dataclasses.is_dataclass(request):
421
+ return dataclasses.replace(request, **{field_name: value}) # type: ignore[type-var,call-overload]
422
+ except Exception as exc:
423
+ log_suppressed_exception(
424
+ context=dataclasses_context,
425
+ exc=exc,
426
+ extra={"target_field": field_name},
427
+ )
428
+
429
+ clone_exc: Optional[Exception] = None
430
+ if hasattr(request, "model_copy"):
431
+ try:
432
+ return request.model_copy(update={field_name: value})
433
+ except Exception as exc:
434
+ log_suppressed_exception(
435
+ context=clone_context,
436
+ exc=exc,
437
+ extra={"method": "model_copy", "target_field": field_name},
438
+ )
439
+ clone_exc = exc
440
+ if hasattr(request, "copy"):
441
+ try:
442
+ return request.copy(update={field_name: value})
443
+ except Exception as exc:
444
+ log_suppressed_exception(
445
+ context=clone_context,
446
+ exc=exc,
447
+ extra={"method": "copy", "target_field": field_name},
448
+ )
449
+ clone_exc = exc
450
+
451
+ if clone_exc is not None:
452
+ raise RuntimeError(f"request 对象无法安全覆写字段: {field_name}") from clone_exc
453
+
454
+ raise TypeError(
455
+ f"request 对象不支持 {field_name} 覆写:既非 dict,也不支持 dataclasses.replace / model_copy / copy"
456
+ )
457
+
458
+
459
+ class _ModelOverrideBackend:
460
+ """
461
+ ChatBackend 薄代理:仅覆写 request.model,然后委托给底层 backend。
462
+
463
+ 说明:
464
+ - 该包装仅在 per-run 创建 Agent 时生效(不修改 runtime-wide backend 实例);
465
+ - 不强依赖 request 的具体实现(pydantic v1/v2 / dict / 普通对象 best-effort 兼容)。
466
+ """
467
+
468
+ def __init__(self, *, backend: ChatBackendProtocol, model: str) -> None:
469
+ self._backend: ChatBackendProtocol = backend
470
+ self._model: str = model
471
+
472
+ async def stream_chat(self, request: ChatRequest) -> AsyncGenerator[ChatStreamEvent, None]:
473
+ """
474
+ 覆写 request.model 并转发 `stream_chat`。
475
+
476
+ 参数:
477
+ - request:上游 SDK 生成的 ChatRequest(或兼容对象)
478
+ """
479
+
480
+ forwarded = _clone_request_with_field_update(
481
+ request,
482
+ field_name="model",
483
+ value=self._model,
484
+ dataclasses_context="model_override_dataclasses_replace",
485
+ clone_context="model_copy_override",
486
+ )
487
+
488
+ async for ev in self._backend.stream_chat(forwarded):
489
+ yield ev
490
+
491
+
492
+ class _ToolChoiceOverrideBackend:
493
+ """
494
+ ChatBackend 薄代理:仅覆写 request.extra["tool_choice"],然后委托给底层 backend。
495
+
496
+ 说明:
497
+ - 覆写以"拷贝 + 更新"为主,避免修改上游 request 对象的原始 extra 引用;
498
+ - 不强依赖 request 的具体实现(pydantic v1/v2 / dict / 普通对象 best-effort 兼容)。
499
+ """
500
+
501
+ def __init__(self, *, backend: ChatBackendProtocol, tool_choice: Any) -> None:
502
+ self._backend: ChatBackendProtocol = backend
503
+ self._tool_choice: Any = tool_choice
504
+
505
+ async def stream_chat(self, request: ChatRequest) -> AsyncGenerator[ChatStreamEvent, None]:
506
+ """
507
+ 覆写 request.extra["tool_choice"] 并转发 `stream_chat`。
508
+
509
+ 参数:
510
+ - request:上游 SDK 生成的 ChatRequest(或兼容对象)
511
+ """
512
+
513
+ raw_extra = getattr(request, "extra", None)
514
+ extra = dict(raw_extra) if isinstance(raw_extra, dict) else {}
515
+ extra["tool_choice"] = self._tool_choice
516
+ forwarded = _clone_request_with_field_update(
517
+ request,
518
+ field_name="extra",
519
+ value=extra,
520
+ dataclasses_context="tool_choice_override_dataclasses_replace",
521
+ clone_context="tool_choice_override",
522
+ )
523
+
524
+ async for ev in self._backend.stream_chat(forwarded):
525
+ yield ev
526
+
527
+
528
+ class _ResponseFormatOverrideBackend:
529
+ """
530
+ ChatBackend 薄代理:仅覆写 request.response_format,然后委托给底层 backend。
531
+
532
+ 说明:
533
+ - 覆写以"拷贝 + 更新"为主,避免修改上游 request 对象的原始 response_format 引用;
534
+ - 不强依赖 request 的具体实现(pydantic v1/v2 / dict / 普通对象 best-effort 兼容)。
535
+ """
536
+
537
+ def __init__(self, *, backend: ChatBackendProtocol, response_format: Dict[str, Any]) -> None:
538
+ self._backend: ChatBackendProtocol = backend
539
+ self._response_format: Dict[str, Any] = dict(response_format)
540
+
541
+ async def stream_chat(self, request: ChatRequest) -> AsyncGenerator[ChatStreamEvent, None]:
542
+ """
543
+ 覆写 request.response_format 并转发 `stream_chat`。
544
+
545
+ 参数:
546
+ - request:上游 SDK 生成的 ChatRequest(或兼容对象)
547
+ """
548
+
549
+ forwarded = _clone_request_with_field_update(
550
+ request,
551
+ field_name="response_format",
552
+ value=dict(self._response_format),
553
+ dataclasses_context="response_format_override_dataclasses_replace",
554
+ clone_context="response_format_override",
555
+ )
556
+
557
+ async for ev in self._backend.stream_chat(forwarded):
558
+ yield ev
559
+
560
+
561
+ class _UsageTapBackend:
562
+ """
563
+ ChatBackend 薄代理:通过 request.extra 注入 usage sink,best-effort 收集 bridge usage。
564
+
565
+ 说明:
566
+ - 不修改上游 SDK/Agently;
567
+ - request.extra 中的回调不会透传到 wire payload(Agently backend 会过滤 callable);
568
+ - 若底层 backend 自己能产出 `llm_usage` ChatStreamEvent,本层也会被动收集。
569
+ """
570
+
571
+ def __init__(self, *, backend: ChatBackendProtocol) -> None:
572
+ self._backend: ChatBackendProtocol = backend
573
+ self._usage_payloads: List[Dict[str, Any]] = []
574
+
575
+ def _capture_usage(self, payload: Any) -> None:
576
+ """记录一次 usage 摘要(fail-open;仅保留 dict 载荷)。"""
577
+
578
+ if isinstance(payload, dict):
579
+ self._usage_payloads.append(dict(payload))
580
+
581
+ def drain_usage_payloads(self) -> List[Dict[str, Any]]:
582
+ """取出并清空当前 run 累积的 usage 载荷。"""
583
+
584
+ out = list(self._usage_payloads)
585
+ self._usage_payloads.clear()
586
+ return out
587
+
588
+ async def stream_chat(self, request: ChatRequest) -> AsyncGenerator[ChatStreamEvent, None]:
589
+ """注入 `_caprt_usage_sink` 后转发到底层 backend。"""
590
+
591
+ forwarded = _clone_request_with_extra(
592
+ request,
593
+ lambda extra: _merge_usage_sink(extra=extra, sink=self._capture_usage),
594
+ )
595
+ async for ev in self._backend.stream_chat(forwarded):
596
+ if getattr(ev, "type", None) == "llm_usage":
597
+ payload = getattr(ev, "payload", None)
598
+ if isinstance(payload, dict):
599
+ self._capture_usage(payload)
600
+ yield ev
601
+
602
+
603
+ class _AgentUsageEventBridge:
604
+ """
605
+ Agent 薄代理:在底层未主动发出 `llm_usage` 时,补发 bridge 收集到的 usage AgentEvent。
606
+ """
607
+
608
+ def __init__(self, *, agent: Any, usage_bridge_backend: _UsageTapBackend) -> None:
609
+ self._agent = agent
610
+ self._usage_bridge_backend = usage_bridge_backend
611
+
612
+ async def run_stream_async(
613
+ self,
614
+ task: str,
615
+ *,
616
+ run_id: Optional[str] = None,
617
+ initial_history: Optional[List[Dict[str, Any]]] = None,
618
+ ) -> AsyncIterator[AgentEvent]:
619
+ """
620
+ 转发 SDK Agent 事件流;若未见上游 `llm_usage`,在流结束后补发 bridge usage 事件。
621
+ """
622
+
623
+ saw_llm_usage = False
624
+ last_run_id = run_id or ""
625
+ last_turn_id: Optional[str] = None
626
+
627
+ async for ev in self._agent.run_stream_async(task, run_id=run_id, initial_history=initial_history):
628
+ if ev.type == "llm_usage":
629
+ saw_llm_usage = True
630
+ if isinstance(ev.run_id, str) and ev.run_id:
631
+ last_run_id = ev.run_id
632
+ if isinstance(ev.turn_id, str) and ev.turn_id:
633
+ last_turn_id = ev.turn_id
634
+ yield ev
635
+
636
+ if saw_llm_usage:
637
+ self._usage_bridge_backend.drain_usage_payloads()
638
+ return
639
+
640
+ for payload in self._usage_bridge_backend.drain_usage_payloads():
641
+ yield AgentEvent(
642
+ type="llm_usage",
643
+ timestamp=_now_rfc3339(),
644
+ run_id=last_run_id,
645
+ turn_id=last_turn_id,
646
+ payload=dict(payload),
647
+ )
648
+
649
+
650
+ def _now_rfc3339() -> str:
651
+ """生成 UTC RFC3339 时间戳(秒级 suffices for synthetic llm_usage events)。"""
652
+
653
+ return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
654
+
655
+
656
+ def _merge_usage_sink(*, extra: Dict[str, Any], sink: Any) -> Dict[str, Any]:
657
+ """
658
+ 合并 `_caprt_usage_sink` 回调,保留已有 sink 并保证两者都被调用。
659
+ """
660
+
661
+ merged = dict(extra or {})
662
+ previous = merged.get("_caprt_usage_sink")
663
+
664
+ def _combined(payload: Any) -> None:
665
+ sink(payload)
666
+ if callable(previous):
667
+ previous(payload)
668
+
669
+ merged["_caprt_usage_sink"] = _combined
670
+ return merged
671
+
672
+
673
+ def _clone_request_with_extra(request: Any, update_extra: Any) -> Any:
674
+ """
675
+ 以"拷贝 + 覆写 extra"的方式构造转发 request(兼容 dict/dataclass/pydantic)。
676
+ """
677
+
678
+ if isinstance(request, dict):
679
+ forwarded = dict(request)
680
+ raw_extra = forwarded.get("extra")
681
+ extra = dict(raw_extra) if isinstance(raw_extra, dict) else {}
682
+ forwarded["extra"] = update_extra(extra)
683
+ return forwarded
684
+
685
+ raw_extra = getattr(request, "extra", None)
686
+ extra = dict(raw_extra) if isinstance(raw_extra, dict) else {}
687
+ updated_extra = update_extra(extra)
688
+
689
+ replaced = False
690
+ forwarded = request
691
+ clone_exc: Optional[Exception] = None
692
+ try:
693
+ import dataclasses
694
+
695
+ if dataclasses.is_dataclass(request):
696
+ forwarded = dataclasses.replace(request, extra=updated_extra) # type: ignore[type-var,assignment]
697
+ replaced = True
698
+ except Exception as exc:
699
+ log_suppressed_exception(
700
+ context="clone_request_with_extra_dataclasses_replace",
701
+ exc=exc,
702
+ extra={"target_field": "extra"},
703
+ )
704
+ replaced = False
705
+
706
+ if not replaced:
707
+ if hasattr(request, "model_copy"):
708
+ try:
709
+ forwarded = request.model_copy(update={"extra": updated_extra})
710
+ replaced = True
711
+ except Exception as exc:
712
+ log_suppressed_exception(
713
+ context="clone_request_with_extra_model_copy",
714
+ exc=exc,
715
+ extra={"target_field": "extra"},
716
+ )
717
+ replaced = False
718
+ clone_exc = exc
719
+ if not replaced and hasattr(request, "copy"):
720
+ try:
721
+ forwarded = request.copy(update={"extra": updated_extra})
722
+ replaced = True
723
+ except Exception as exc:
724
+ log_suppressed_exception(
725
+ context="clone_request_with_extra_copy",
726
+ exc=exc,
727
+ extra={"target_field": "extra"},
728
+ )
729
+ replaced = False
730
+ clone_exc = exc
731
+
732
+ if not replaced:
733
+ if clone_exc is not None:
734
+ raise RuntimeError("request 对象无法安全覆写 extra 字段") from clone_exc
735
+ raise TypeError("request 对象不支持 extra 覆写:既非 dict,也不支持 dataclasses.replace / model_copy / copy")
736
+
737
+ return forwarded
738
+
739
+
740
+ def _load_yaml_dict(path: Path) -> Dict[str, Any]:
741
+ """
742
+ 读取 YAML 文件并返回 dict。
743
+
744
+ 说明:
745
+ - 该函数仅用于加载 SDK overlays;
746
+ - 解析失败时抛异常,由调用方决定容错策略。
747
+ """
748
+
749
+ import yaml
750
+
751
+ obj = yaml.safe_load(Path(path).read_text(encoding="utf-8")) or {}
752
+ if not isinstance(obj, dict):
753
+ raise ValueError("YAML root must be a mapping")
754
+ return obj
755
+
756
+
757
+ def _register_custom_tool_compat(agent: Any, tool: CustomTool) -> dict[str, bool]:
758
+ """
759
+ 兼容上游 `register_tool` 的 descriptor 新旧签名。
760
+
761
+ 返回值中的布尔位仅用于内部诊断与测试,不应作为生产逻辑分支条件。
762
+ """
763
+
764
+ descriptor_requested = tool.descriptor is not None
765
+ register_tool = agent.register_tool
766
+ parameters = inspect.signature(register_tool).parameters
767
+ supports_descriptor = "descriptor" in parameters
768
+
769
+ if supports_descriptor:
770
+ register_tool(
771
+ tool.spec,
772
+ tool.handler,
773
+ override=bool(tool.override),
774
+ descriptor=tool.descriptor,
775
+ )
776
+ return {
777
+ "descriptor_requested": descriptor_requested,
778
+ "descriptor_supported": True,
779
+ "descriptor_applied": descriptor_requested,
780
+ }
781
+
782
+ register_tool(tool.spec, tool.handler, override=bool(tool.override))
783
+ return {
784
+ "descriptor_requested": descriptor_requested,
785
+ "descriptor_supported": False,
786
+ "descriptor_applied": False,
787
+ }
788
+
789
+
790
+ def _normalize_skills_config_for_skills_runtime(skills_config: Any) -> tuple[Any, List[FrameworkIssue]]:
791
+ """
792
+ 将 RuntimeConfig.skills_config 归一为上游 skills config 可接受的形态(dict 或 model)。
793
+
794
+ 背景:
795
+ - `skills-runtime-sdk>=1.0` 对 skills 配置 schema 采用 `extra=forbid`,未知字段会直接导致校验异常;
796
+ - 本仓历史上允许在 `skills_config` dict 里包含一些旧字段(例如 `roots/mode/max_auto`),需要在桥接层做最小兼容,
797
+ 以便"warn 模式可继续跑、error 模式可 fail-closed"由 preflight gate 决定,而不是初始化期直接崩溃。
798
+
799
+ 参数:
800
+ - skills_config:可能为 dict / pydantic model / 其它对象
801
+
802
+ 返回:
803
+ - (归一后的 skills_config, 归一化阶段产生的 issues)
804
+ """
805
+
806
+ if not isinstance(skills_config, dict):
807
+ return skills_config, []
808
+
809
+ # 兼容:允许传入完整 SDK config(包含 skills 根节点)
810
+ path_prefix = ""
811
+ if isinstance(skills_config.get("skills"), dict):
812
+ skills_config = dict(skills_config["skills"])
813
+ path_prefix = "skills."
814
+
815
+ allowed_keys = {
816
+ "env_var_missing_policy",
817
+ "versioning",
818
+ "strictness",
819
+ "spaces",
820
+ "sources",
821
+ "scan",
822
+ "injection",
823
+ "actions",
824
+ "references",
825
+ }
826
+ # 兼容:历史字段(上游 1.x 不再支持),由本仓 preflight/文档提示用户迁移
827
+ legacy_keys = {"roots", "mode", "max_auto"}
828
+
829
+ out: Dict[str, Any] = {}
830
+ issues: List[FrameworkIssue] = []
831
+ for k, v in dict(skills_config).items():
832
+ if k in allowed_keys:
833
+ out[k] = v
834
+ elif k in legacy_keys:
835
+ issue_code = "SKILL_CONFIG_LEGACY_ROOTS_UNSUPPORTED" if k == "roots" else "SKILL_CONFIG_LEGACY_OPTION_UNSUPPORTED"
836
+ issues.append(
837
+ FrameworkIssue(
838
+ code=issue_code,
839
+ message=f"{path_prefix}{k} is a legacy option and is not supported by skills-runtime-sdk>=1.x",
840
+ details={"path": f"{path_prefix}{k}", "key": k},
841
+ )
842
+ )
843
+ continue
844
+ else:
845
+ # 未知字段:过滤掉避免上游 loader/model_validate 直接异常,
846
+ # 但必须生成 issue,交给 preflight gate 决定 warn 还是 fail-closed。
847
+ issues.append(
848
+ FrameworkIssue(
849
+ code="SKILL_CONFIG_UNKNOWN_OPTION",
850
+ message="Unknown skills config option is not supported.",
851
+ details={"path": f"{path_prefix}{k}", "key": k},
852
+ )
853
+ )
854
+ continue
855
+
856
+ # === v0.1.5 兼容:skills.spaces schema(account/domain ↔ namespace)===
857
+ spaces = out.get("spaces")
858
+ if spaces is not None:
859
+ from .upstream_compat import detect_skills_space_schema, normalize_spaces_for_upstream
860
+
861
+ target_schema = detect_skills_space_schema()
862
+ normalized, warnings = normalize_spaces_for_upstream(spaces=spaces, target_schema=target_schema)
863
+ if normalized is not None:
864
+ out["spaces"] = normalized
865
+ for warning in warnings:
866
+ issues.append(
867
+ FrameworkIssue(
868
+ code="SKILL_CONFIG_SPACES_SCHEMA_NORMALIZED",
869
+ message="skills.spaces schema normalized for upstream compatibility",
870
+ details={"path": f"{path_prefix}spaces", "target_schema": target_schema, "warning": warning},
871
+ )
872
+ )
873
+ elif warnings:
874
+ raise FrameworkError(
875
+ code="SKILL_CONFIG_SPACES_SCHEMA_INCOMPATIBLE",
876
+ message="skills.spaces schema is incompatible with installed skills-runtime-sdk",
877
+ details={"path": f"{path_prefix}spaces", "target_schema": target_schema, "warnings": warnings},
878
+ )
879
+
880
+ versioning = out.get("versioning")
881
+ if isinstance(versioning, dict) and versioning.get("strategy") == "TODO":
882
+ issues.append(
883
+ FrameworkIssue(
884
+ code="SKILL_CONFIG_VERSIONING_STRATEGY_DRIFT",
885
+ message="skills.versioning.strategy='TODO' is a stale value; use '' or an explicit upstream-supported strategy.",
886
+ details={"path": f"{path_prefix}versioning.strategy", "value": "TODO"},
887
+ )
888
+ )
889
+ return out, issues
890
+
891
+
892
+ def _sanitize_sdk_overlay_dict_for_loader(overlay: Dict[str, Any]) -> tuple[Dict[str, Any], List[FrameworkIssue]]:
893
+ """
894
+ 在调用上游 `load_config_dicts()` 前,对 overlay 做"最小清洗",避免未知字段导致初始化期直接崩溃。
895
+
896
+ 说明:
897
+ - 上游 `AgentSdkConfig/AgentSdkSkillsConfig` 默认 `extra=forbid`,因此 overlay 内出现未知字段会直接抛校验异常;
898
+ - 本仓需要在 preflight gate 中把问题以 `FrameworkIssue` 可观测化,并允许 warn 模式继续执行。
899
+
900
+ 当前清洗范围(最小集合,覆盖本仓离线回归用例):
901
+ - `skills.roots`:历史字段,产生 `SKILL_CONFIG_LEGACY_ROOTS_UNSUPPORTED` 并移除;
902
+ - `skills.scan` 下未知字段:产生 `SKILL_CONFIG_UNKNOWN_SCAN_OPTION` 并移除未知 key。
903
+ """
904
+
905
+ if not isinstance(overlay, dict):
906
+ return {}, []
907
+
908
+ issues: List[FrameworkIssue] = []
909
+ sanitized: Dict[str, Any] = dict(overlay)
910
+
911
+ skills = sanitized.get("skills")
912
+ if not isinstance(skills, dict):
913
+ return sanitized, issues
914
+
915
+ skills_obj: Dict[str, Any] = dict(skills)
916
+
917
+ if "roots" in skills_obj:
918
+ issues.append(
919
+ FrameworkIssue(
920
+ code="SKILL_CONFIG_LEGACY_ROOTS_UNSUPPORTED",
921
+ message="skills.roots is a legacy option and is not supported by skills-runtime-sdk>=1.x",
922
+ details={"path": "skills.roots"},
923
+ )
924
+ )
925
+ skills_obj.pop("roots", None)
926
+
927
+ # v0.1.5 兼容:spaces schema(account/domain ↔ namespace)
928
+ spaces = skills_obj.get("spaces")
929
+ if spaces is not None:
930
+ from .upstream_compat import detect_skills_space_schema, normalize_spaces_for_upstream
931
+
932
+ target_schema = detect_skills_space_schema()
933
+ normalized, warnings = normalize_spaces_for_upstream(spaces=spaces, target_schema=target_schema)
934
+ if normalized is not None:
935
+ skills_obj["spaces"] = normalized
936
+ for w in warnings:
937
+ issues.append(
938
+ FrameworkIssue(
939
+ code="SKILL_CONFIG_SPACES_SCHEMA_NORMALIZED",
940
+ message="skills.spaces schema normalized for upstream compatibility",
941
+ details={"path": "skills.spaces", "target_schema": target_schema, "warning": w},
942
+ )
943
+ )
944
+ elif warnings:
945
+ # overlay 的目标是"不让初始化期直接崩",因此这里做 best-effort:丢弃 spaces 并把原因写入 issues。
946
+ skills_obj.pop("spaces", None)
947
+ issues.append(
948
+ FrameworkIssue(
949
+ code="SKILL_CONFIG_SPACES_SCHEMA_INCOMPATIBLE_DROPPED",
950
+ message="skills.spaces is incompatible with installed skills-runtime-sdk; dropped from overlay",
951
+ details={"path": "skills.spaces", "target_schema": target_schema, "warnings": warnings},
952
+ )
953
+ )
954
+
955
+ scan = skills_obj.get("scan")
956
+ if isinstance(scan, dict):
957
+ scan_obj: Dict[str, Any] = dict(scan)
958
+ allowed_scan_keys = {
959
+ "ignore_dot_entries",
960
+ "max_depth",
961
+ "max_dirs_per_root",
962
+ "max_frontmatter_bytes",
963
+ "refresh_policy",
964
+ "ttl_sec",
965
+ }
966
+ unknown = [k for k in scan_obj.keys() if k not in allowed_scan_keys]
967
+ for k in unknown:
968
+ issues.append(
969
+ FrameworkIssue(
970
+ code="SKILL_CONFIG_UNKNOWN_SCAN_OPTION",
971
+ message="Unknown skills.scan option is not supported.",
972
+ details={"path": f"skills.scan.{k}", "key": k},
973
+ )
974
+ )
975
+ scan_obj.pop(k, None)
976
+ skills_obj["scan"] = scan_obj
977
+
978
+ sanitized["skills"] = skills_obj
979
+ return sanitized, issues
980
+
981
+
982
+ __all__ = ["SdkLifecycle"]