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,423 @@
1
+ """
2
+ Agently → Skills Runtime SDK 的 LLM backend 适配器。
3
+
4
+ 设计要点(非常重要):
5
+ - SDK agent loop 需要完整的 OpenAI wire `messages[]`(含 tool_call_id/tool_calls 等字段)。
6
+ - 因此桥接层不能使用 Agently 的 PromptGenerator(Prompt.to_messages)做映射,否则会丢字段导致 tool 闭环失败。
7
+ - 本模块复用 Agently builtins 的 OpenAICompatible ModelRequester 作为“网络/SSE 传输层”,直接发送 wire payload。
8
+ - 解析阶段复用 SDK `ChatCompletionsSseParser`,确保 tool_calls delta 拼接口径不分叉。
9
+
10
+ 对齐规格:
11
+ - `docs/specs/agently-backend-stream-event-ordering-v1.md`
12
+ - `docs/specs/per-capability-llm-config-v1.md`
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ from dataclasses import dataclass
19
+ from typing import Any, AsyncIterator, Dict, List, Optional, Protocol, cast
20
+
21
+ from skills_runtime.llm.chat_sse import ChatCompletionsSseParser, ChatStreamEvent
22
+ from skills_runtime.llm.protocol import ChatBackend, ChatRequest
23
+ from skills_runtime.tools.protocol import ToolSpec, tool_spec_to_openai_tool
24
+
25
+ from ..logging_utils import log_suppressed_exception
26
+ from ..utils.usage import _usage_int
27
+
28
+
29
+ class AgentlyRequester(Protocol):
30
+ """
31
+ requester 抽象(用于测试注入)。
32
+
33
+ 约束:
34
+ - `generate_request_data()` 返回一个具备 `.data/.request_options/.request_url/...` 字段的对象
35
+ - `request_model(request_data)` yield `(event, data)`,其中 data 为 SSE `data` 字符串或异常
36
+ """
37
+
38
+ def generate_request_data(self) -> Any:
39
+ """生成请求载体对象(需包含 `.data` 与 `.request_options` 字段)。"""
40
+
41
+ ...
42
+
43
+ async def request_model(self, request_data: Any) -> AsyncIterator[tuple[str, Any]]:
44
+ """发起流式请求并返回 `(event, data)` 迭代。"""
45
+
46
+ ...
47
+
48
+
49
+ class AgentlyRequesterFactory(Protocol):
50
+ """创建 requester 的工厂(用于测试注入与未来扩展)。"""
51
+
52
+ def __call__(self) -> AgentlyRequester:
53
+ """创建并返回一个 requester 实例。"""
54
+
55
+ ...
56
+
57
+
58
+ @dataclass(frozen=True)
59
+ class AgentlyBackendConfig:
60
+ """
61
+ AgentlyChatBackend 的最小配置。
62
+
63
+ 参数:
64
+ - `requester_factory`:默认使用 Agently OpenAICompatible;测试可注入 FakeRequester。
65
+ """
66
+
67
+ requester_factory: AgentlyRequesterFactory
68
+
69
+ def _normalize_usage_payload(*, usage: Any, model: Any = None) -> Optional[Dict[str, Any]]:
70
+ """
71
+ 把 provider usage 归一为 capability-runtime 的 `llm_usage` payload 形状。
72
+
73
+ 返回:
74
+ - `None`:无法提取任何有效 usage 字段
75
+ - `dict`:`model/input_tokens/output_tokens/total_tokens`
76
+ """
77
+
78
+ if not isinstance(usage, dict):
79
+ return None
80
+
81
+ model_text = model.strip() if isinstance(model, str) and model.strip() else None
82
+ input_tokens = _usage_int(usage.get("input_tokens"))
83
+ if input_tokens is None:
84
+ input_tokens = _usage_int(usage.get("prompt_tokens"))
85
+
86
+ output_tokens = _usage_int(usage.get("output_tokens"))
87
+ if output_tokens is None:
88
+ output_tokens = _usage_int(usage.get("completion_tokens"))
89
+
90
+ total_tokens = _usage_int(usage.get("total_tokens"))
91
+ payload = {
92
+ "model": model_text,
93
+ "input_tokens": input_tokens,
94
+ "output_tokens": output_tokens,
95
+ "total_tokens": total_tokens,
96
+ }
97
+ return payload if any(value is not None for value in payload.values()) else None
98
+
99
+
100
+ def _extract_usage_payload_from_sse_data(data: str) -> Optional[Dict[str, Any]]:
101
+ """
102
+ 从原始 SSE `data` 字符串中提取 usage 摘要。
103
+
104
+ 说明:
105
+ - bridge 模式仅做 best-effort;
106
+ - 解析失败/无 usage 时返回 None,不影响主链。
107
+ """
108
+
109
+ raw = str(data or "").strip()
110
+ if not raw or raw in ("[DONE]", "DONE"):
111
+ return None
112
+ try:
113
+ obj = json.loads(raw)
114
+ except Exception as exc:
115
+ log_suppressed_exception(
116
+ context="parse_usage_payload_json",
117
+ exc=exc,
118
+ extra={"raw_len": len(raw)},
119
+ )
120
+ return None
121
+ if not isinstance(obj, dict):
122
+ return None
123
+ return _normalize_usage_payload(usage=obj.get("usage"), model=obj.get("model"))
124
+
125
+
126
+ def _merge_stream_options_for_usage(value: Any) -> Dict[str, Any]:
127
+ """
128
+ 为 streaming 请求补齐 `include_usage=true`,同时保留已有 stream_options。
129
+
130
+ 说明:
131
+ - OpenAICompatible provider 若不支持该字段,应在 provider/requester 侧 fail-open;
132
+ - 本函数只负责把请求事实补齐,不在此处做兼容分支判断。
133
+ """
134
+
135
+ merged = dict(value) if isinstance(value, dict) else {}
136
+ merged.setdefault("include_usage", True)
137
+ return merged
138
+
139
+
140
+ def _should_retry_without_stream_options(error: Any) -> bool:
141
+ """判断 provider 拒绝 `stream_options/include_usage` 时是否应 fail-open 重试。"""
142
+
143
+ message = str(error or "").lower()
144
+ if not message:
145
+ return False
146
+ mentions_stream_options = "stream_options" in message or "include_usage" in message
147
+ mentions_unsupported = any(
148
+ token in message
149
+ for token in (
150
+ "unknown parameter",
151
+ "unsupported",
152
+ "not support",
153
+ "not supported",
154
+ "invalid parameter",
155
+ "extra inputs are not permitted",
156
+ "400",
157
+ "422",
158
+ )
159
+ )
160
+ return mentions_stream_options and mentions_unsupported
161
+
162
+
163
+ class AgentlyChatBackend(ChatBackend):
164
+ """
165
+ SDK `ChatBackend` 的 Agently 适配实现。
166
+
167
+ 说明:
168
+ - `stream_chat` 输入为 OpenAI wire `messages[]` 与 `tools[]`(ToolSpec)。
169
+ - 输出为 SDK 的 `ChatStreamEvent`(text_delta/tool_calls/completed)。
170
+ """
171
+
172
+ def __init__(self, *, config: AgentlyBackendConfig) -> None:
173
+ """创建 backend。"""
174
+
175
+ self._config = config
176
+
177
+ async def stream_chat(self, request: ChatRequest) -> AsyncIterator[ChatStreamEvent]:
178
+ """
179
+ 发起一次 chat.completions streaming 并 yield `ChatStreamEvent`。
180
+
181
+ 参数:
182
+ - `request`:上游 `ChatRequest` 参数包(包含 model/messages/tools 与可选推理参数)
183
+ """
184
+
185
+ usage_sink = None
186
+ if isinstance(getattr(request, "extra", None), dict):
187
+ candidate_sink = request.extra.get("_caprt_usage_sink")
188
+ if callable(candidate_sink):
189
+ usage_sink = candidate_sink
190
+
191
+ if not isinstance(request.messages, list):
192
+ raise TypeError("messages must be a list[dict]")
193
+ include_usage_enabled = True
194
+ for attempt in range(2):
195
+ requester = self._config.requester_factory()
196
+ request_data = requester.generate_request_data()
197
+ request_data.data["messages"] = request.messages
198
+ request_data.request_options["model"] = request.model
199
+ request_data.request_options["stream"] = True
200
+ request_data.stream = True
201
+
202
+ tool_specs: List[ToolSpec] = list(request.tools or [])
203
+ tool_choice_target_tool_name: Optional[str] = None
204
+
205
+ if request.temperature is not None:
206
+ request_data.request_options["temperature"] = float(request.temperature)
207
+ if request.max_tokens is not None:
208
+ request_data.request_options["max_tokens"] = int(request.max_tokens)
209
+ if request.top_p is not None:
210
+ request_data.request_options["top_p"] = float(request.top_p)
211
+ if request.response_format is not None:
212
+ request_data.request_options["response_format"] = dict(request.response_format)
213
+
214
+ # provider 特有扩展字段(best-effort 透传;冲突时以 request_options 显式字段为准)
215
+ #
216
+ # 重要:
217
+ # - request.extra 可能包含“运行时回调/非 JSON 值”(例如 on_retry=function),它们不属于 wire payload;
218
+ # - 这些值若被透传到 requester,可能导致 JSON 序列化失败并让 real 模式不可用。
219
+ if isinstance(request.extra, dict) and request.extra:
220
+
221
+ def _is_jsonable(value: Any, *, _seen: set[int] | None = None) -> bool:
222
+ """
223
+ 判断 value 是否可 JSON 序列化(最小、保守、避免热路径重复 dumps)。
224
+
225
+ 说明:
226
+ - 我们不尝试做自定义 default 编码(避免改变 wire 契约语义);
227
+ - 不可序列化的字段将被跳过(fail-closed),避免 real 模式因 requester 序列化失败而崩溃。
228
+ """
229
+
230
+ if value is None or isinstance(value, (str, int, float, bool)):
231
+ return True
232
+ if isinstance(value, dict):
233
+ seen = _seen if _seen is not None else set()
234
+ oid = id(value)
235
+ if oid in seen:
236
+ return False
237
+ seen.add(oid)
238
+ try:
239
+ return all(isinstance(k, str) and _is_jsonable(v, _seen=seen) for k, v in value.items())
240
+ finally:
241
+ seen.remove(oid)
242
+ if isinstance(value, (list, tuple)):
243
+ seen = _seen if _seen is not None else set()
244
+ oid = id(value)
245
+ if oid in seen:
246
+ return False
247
+ seen.add(oid)
248
+ try:
249
+ return all(_is_jsonable(v, _seen=seen) for v in value)
250
+ finally:
251
+ seen.remove(oid)
252
+ return False
253
+
254
+ for k, v in request.extra.items():
255
+ # 过滤明显的非 wire 字段(以及所有不可 JSON 序列化值)
256
+ if k == "on_retry":
257
+ continue
258
+ if callable(v) or not _is_jsonable(v):
259
+ continue
260
+
261
+ # 覆写优先级(spec 要求):
262
+ # - per-run llm_config 会写入 request.extra["tool_choice"]
263
+ # - 即使底层 requester/backend 已预置 tool_choice(例如默认 "auto"),也必须被本覆写覆盖
264
+ if k == "tool_choice":
265
+ if isinstance(v, dict):
266
+ # OpenAI 新格式:{"type":"function","function":{"name":"..."}}
267
+ # 某些 OpenAICompatible server 不支持 tool_choice.function,会直接 400;
268
+ # 兼容策略:归一化为 tool_choice="required",并(若可定位)过滤 tools 以避免选错工具。
269
+ function = v.get("function") if isinstance(v.get("function"), dict) else None
270
+ if function is not None:
271
+ name = function.get("name")
272
+ if isinstance(name, str) and name:
273
+ tool_choice_target_tool_name = name
274
+ request_data.request_options[k] = "required"
275
+ else:
276
+ request_data.request_options[k] = v
277
+ continue
278
+
279
+ if k not in request_data.request_options:
280
+ request_data.request_options[k] = v
281
+
282
+ if include_usage_enabled:
283
+ request_data.request_options["stream_options"] = _merge_stream_options_for_usage(
284
+ request_data.request_options.get("stream_options")
285
+ )
286
+ else:
287
+ request_data.request_options.pop("stream_options", None)
288
+
289
+ if tool_choice_target_tool_name:
290
+ matched = [spec for spec in tool_specs if spec.name == tool_choice_target_tool_name]
291
+ if not matched:
292
+ raise ValueError(f"tool_choice target tool not found: {tool_choice_target_tool_name}")
293
+ tool_specs = matched
294
+
295
+ tools_wire = [tool_spec_to_openai_tool(spec) for spec in tool_specs]
296
+ if tools_wire:
297
+ request_data.request_options["tools"] = tools_wire
298
+ request_data.request_options.setdefault("tool_choice", "auto")
299
+ else:
300
+ # 某些 provider 对 tools=[] 敏感;无工具时直接移除该字段。
301
+ request_data.request_options.pop("tools", None)
302
+
303
+ parser = ChatCompletionsSseParser()
304
+ deferred_completed: Optional[ChatStreamEvent] = None
305
+
306
+ # 兼容:不同版本/实现的 requester 可能返回:
307
+ # - async iterator(可直接 async for)
308
+ # - coroutine -> async iterator(需要 await 一次再 async for)
309
+ stream_or_coro = requester.request_model(request_data)
310
+ stream: AsyncIterator[tuple[str, Any]]
311
+ retry_without_stream_options = False
312
+ try:
313
+ if hasattr(stream_or_coro, "__aiter__"):
314
+ stream = cast(AsyncIterator[tuple[str, Any]], stream_or_coro)
315
+ else:
316
+ stream = await stream_or_coro
317
+
318
+ async for event, data in stream:
319
+ if event == "error":
320
+ error = data if isinstance(data, BaseException) else RuntimeError(f"Agently requester error: {data!r}")
321
+ if include_usage_enabled and attempt == 0 and _should_retry_without_stream_options(error):
322
+ retry_without_stream_options = True
323
+ log_suppressed_exception(
324
+ context="agently_backend_include_usage_retry",
325
+ exc=error,
326
+ extra={"retry_without_stream_options": True},
327
+ )
328
+ break
329
+ raise error
330
+
331
+ # OpenAICompatible requester 通常 yield ("message", <sse.data>)
332
+ if not isinstance(data, str):
333
+ continue
334
+
335
+ usage_payload = _extract_usage_payload_from_sse_data(data)
336
+ if usage_payload is not None and usage_sink is not None:
337
+ try:
338
+ usage_sink(dict(usage_payload))
339
+ except Exception as sink_exc:
340
+ log_suppressed_exception(
341
+ context="usage_sink_callback",
342
+ exc=sink_exc,
343
+ extra={"source": "agently_backend"},
344
+ )
345
+
346
+ for ev in parser.feed_data(data):
347
+ # 关键不变量:
348
+ # 某些 OpenAICompatible server 的 SSE 序列可能是:
349
+ # - delta.tool_calls ...(累计中)
350
+ # - finish_reason="stop" → parser 先 emit completed
351
+ # - [DONE] → parser 才 flush tool_calls
352
+ #
353
+ # 若我们把 completed 立即 yield,上游消费端可能在 completed 后停止消费,
354
+ # 从而错过后续 tool_calls,最终表现为 NodeReport.tool_calls 为空。
355
+ #
356
+ # 因此:completed 事件必须延迟到“确认不会再有 tool_calls”后再产出。
357
+ if ev.type == "completed":
358
+ deferred_completed = ev
359
+ continue
360
+ yield ev
361
+
362
+ if data.strip() in ("[DONE]", "DONE"):
363
+ break
364
+ except Exception as error:
365
+ if include_usage_enabled and attempt == 0 and _should_retry_without_stream_options(error):
366
+ retry_without_stream_options = True
367
+ log_suppressed_exception(
368
+ context="agently_backend_include_usage_retry",
369
+ exc=error,
370
+ extra={"retry_without_stream_options": True, "raised_directly": True},
371
+ )
372
+ else:
373
+ raise
374
+
375
+ if retry_without_stream_options:
376
+ include_usage_enabled = False
377
+ continue
378
+
379
+ # 注意:即使已看到 [DONE],也必须调用 parser.finish():
380
+ # - 某些实现可能不会在 feed_data("[DONE]") 时 flush tool_calls;
381
+ # - 若跳过 finish(),会出现“tool_calls 丢失/NodeReport.tool_calls 为空”的假阴性。
382
+ for ev in parser.finish():
383
+ if ev.type == "completed":
384
+ # 不用 finish() 的 eof 覆盖真实 stop(若已看到 stop completed)
385
+ if deferred_completed is None:
386
+ deferred_completed = ev
387
+ continue
388
+ yield ev
389
+
390
+ if deferred_completed is not None:
391
+ yield deferred_completed
392
+ return
393
+
394
+
395
+ def build_openai_compatible_requester_factory(*, agently_agent: Any) -> AgentlyRequesterFactory:
396
+ """
397
+ 构造默认 requester_factory(复用 Agently OpenAICompatible builtins)。
398
+
399
+ 参数:
400
+ - `agently_agent`:宿主提供的 Agently agent(需包含 `plugin_manager` 与 `settings`)。
401
+
402
+ 返回:
403
+ - requester_factory:无参可调用对象,返回 OpenAICompatible requester 实例。
404
+ """
405
+
406
+ from agently.core import Prompt
407
+ from agently.builtins.plugins.ModelRequester.OpenAICompatible import OpenAICompatible
408
+
409
+ plugin_manager = getattr(agently_agent, "plugin_manager", None)
410
+ settings = getattr(agently_agent, "settings", None)
411
+ if plugin_manager is None or settings is None:
412
+ raise TypeError("agently_agent must provide .plugin_manager and .settings")
413
+
414
+ def _factory() -> AgentlyRequester:
415
+ """按当前 agently settings 构建一次 requester。"""
416
+
417
+ # Prompt 仅用于让 OpenAICompatible 读取 settings/plugin 配置并生成 request_data;
418
+ # 真实 wire messages 将在 backend 层覆盖到 request_data.data["messages"]。
419
+ prompt = Prompt(plugin_manager=plugin_manager, parent_settings=settings, name="capability-runtime-backend")
420
+ prompt.set("input", "bridge") # 避免 prompt 全空触发校验
421
+ return OpenAICompatible(prompt, settings)
422
+
423
+ return _factory