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.
- capability_runtime/__init__.py +90 -0
- capability_runtime/adapters/__init__.py +13 -0
- capability_runtime/adapters/agent_adapter.py +439 -0
- capability_runtime/adapters/agently_backend.py +423 -0
- capability_runtime/adapters/triggerflow_workflow_engine.py +865 -0
- capability_runtime/adapters/workflow_engine.py +43 -0
- capability_runtime/config.py +172 -0
- capability_runtime/errors.py +20 -0
- capability_runtime/guards.py +150 -0
- capability_runtime/host_protocol.py +400 -0
- capability_runtime/host_toolkit/__init__.py +55 -0
- capability_runtime/host_toolkit/approvals_profiles.py +94 -0
- capability_runtime/host_toolkit/evidence_hooks.py +65 -0
- capability_runtime/host_toolkit/history.py +74 -0
- capability_runtime/host_toolkit/invoke_capability.py +409 -0
- capability_runtime/host_toolkit/resume.py +317 -0
- capability_runtime/host_toolkit/system_prompt.py +132 -0
- capability_runtime/host_toolkit/turn_delta.py +128 -0
- capability_runtime/logging_utils.py +94 -0
- capability_runtime/manifest.py +173 -0
- capability_runtime/output_validator.py +139 -0
- capability_runtime/protocol/__init__.py +43 -0
- capability_runtime/protocol/agent.py +62 -0
- capability_runtime/protocol/capability.py +98 -0
- capability_runtime/protocol/chat_backend.py +38 -0
- capability_runtime/protocol/context.py +244 -0
- capability_runtime/protocol/workflow.py +119 -0
- capability_runtime/registry.py +287 -0
- capability_runtime/reporting/__init__.py +2 -0
- capability_runtime/reporting/node_report.py +497 -0
- capability_runtime/runtime.py +930 -0
- capability_runtime/runtime_ui_events_mixin.py +310 -0
- capability_runtime/sdk_lifecycle.py +982 -0
- capability_runtime/service_facade.py +418 -0
- capability_runtime/services.py +181 -0
- capability_runtime/structured_output.py +208 -0
- capability_runtime/structured_stream.py +38 -0
- capability_runtime/types.py +103 -0
- capability_runtime/ui_events/__init__.py +19 -0
- capability_runtime/ui_events/projector.py +617 -0
- capability_runtime/ui_events/session.py +292 -0
- capability_runtime/ui_events/store.py +127 -0
- capability_runtime/ui_events/transport.py +33 -0
- capability_runtime/ui_events/v1.py +76 -0
- capability_runtime/upstream_compat.py +182 -0
- capability_runtime/utils/__init__.py +1 -0
- capability_runtime/utils/usage.py +65 -0
- capability_runtime/workflow_runtime.py +218 -0
- capability_runtime-0.1.0.dist-info/METADATA +232 -0
- capability_runtime-0.1.0.dist-info/RECORD +52 -0
- capability_runtime-0.1.0.dist-info/WHEEL +5 -0
- 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
|