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,292 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Any, AsyncIterator, Dict, Optional, Set
|
|
6
|
+
|
|
7
|
+
from skills_runtime.core.contracts import AgentEvent
|
|
8
|
+
|
|
9
|
+
from ..logging_utils import log_suppressed_exception
|
|
10
|
+
from ..protocol.capability import CapabilityResult, CapabilityStatus
|
|
11
|
+
from ..protocol.context import ExecutionContext
|
|
12
|
+
from .projector import RuntimeUIEventProjector, _AgentCtx
|
|
13
|
+
from .store import AfterIdExpiredError, RuntimeEventStore
|
|
14
|
+
from .v1 import RuntimeEvent, StreamLevel
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class ResumeErrorInfo:
|
|
19
|
+
after_id: str
|
|
20
|
+
known_min_id: Optional[str]
|
|
21
|
+
known_max_id: Optional[str]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class RuntimeUIEventsSession:
|
|
25
|
+
"""
|
|
26
|
+
UI events 会话:一次 run 的事件投影与多订阅者消费。
|
|
27
|
+
|
|
28
|
+
目标:
|
|
29
|
+
- 不绑定任何 Web 框架;
|
|
30
|
+
- 支持 `rid/after_id` 断线续传(exclusive);
|
|
31
|
+
- heartbeat 保活;
|
|
32
|
+
- terminal status 收敛:确保订阅侧能收到终态事件。
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
*,
|
|
38
|
+
runtime: Any,
|
|
39
|
+
capability_id: str,
|
|
40
|
+
input: Dict[str, Any],
|
|
41
|
+
context: ExecutionContext,
|
|
42
|
+
level: StreamLevel,
|
|
43
|
+
store: RuntimeEventStore,
|
|
44
|
+
heartbeat_interval_s: float,
|
|
45
|
+
input_queue_maxsize: int = 4096,
|
|
46
|
+
subscriber_queue_maxsize: int = 1024,
|
|
47
|
+
) -> None:
|
|
48
|
+
self._runtime = runtime
|
|
49
|
+
self._capability_id = str(capability_id)
|
|
50
|
+
self._input = dict(input or {})
|
|
51
|
+
self._context = context
|
|
52
|
+
self._level = level
|
|
53
|
+
self._store = store
|
|
54
|
+
self._heartbeat_interval_s = float(heartbeat_interval_s)
|
|
55
|
+
if int(input_queue_maxsize) <= 0:
|
|
56
|
+
raise ValueError("input_queue_maxsize must be > 0")
|
|
57
|
+
if int(subscriber_queue_maxsize) <= 0:
|
|
58
|
+
raise ValueError("subscriber_queue_maxsize must be > 0")
|
|
59
|
+
self._input_queue_maxsize = int(input_queue_maxsize)
|
|
60
|
+
self._subscriber_queue_maxsize = int(subscriber_queue_maxsize)
|
|
61
|
+
|
|
62
|
+
self._projector = RuntimeUIEventProjector(run_id=self._context.run_id, level=self._level)
|
|
63
|
+
self._in_q: asyncio.Queue = asyncio.Queue(maxsize=self._input_queue_maxsize)
|
|
64
|
+
self._subs: Set[asyncio.Queue] = set()
|
|
65
|
+
self._started = False
|
|
66
|
+
self._done = asyncio.Event()
|
|
67
|
+
self._runner_task: asyncio.Task[None] | None = None
|
|
68
|
+
self._session_task: asyncio.Task[None] | None = None
|
|
69
|
+
self._background_error: BaseException | None = None
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def run_id(self) -> str:
|
|
73
|
+
return self._context.run_id
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def store(self) -> RuntimeEventStore:
|
|
77
|
+
return self._store
|
|
78
|
+
|
|
79
|
+
def _emit_subscriber_lagged(self, *, q: asyncio.Queue) -> RuntimeEvent:
|
|
80
|
+
err = self._projector.error(
|
|
81
|
+
kind="subscriber_lagged",
|
|
82
|
+
message="subscriber queue overflow; policy=disconnect",
|
|
83
|
+
data={"queue_maxsize": int(getattr(q, "maxsize", 0) or 0), "policy": "disconnect"},
|
|
84
|
+
)
|
|
85
|
+
# 该错误是“订阅者局部诊断信号”,不进入 store;避免客户端误用其 rid 作为 after_id 续传游标。
|
|
86
|
+
return err.model_copy(update={"rid": None})
|
|
87
|
+
|
|
88
|
+
def _cut_off_subscriber(self, *, q: asyncio.Queue) -> None:
|
|
89
|
+
self._subs.discard(q)
|
|
90
|
+
try:
|
|
91
|
+
while True:
|
|
92
|
+
q.get_nowait()
|
|
93
|
+
except asyncio.QueueEmpty:
|
|
94
|
+
pass
|
|
95
|
+
try:
|
|
96
|
+
q.put_nowait(self._emit_subscriber_lagged(q=q))
|
|
97
|
+
except Exception as exc:
|
|
98
|
+
# fail-open:断开慢订阅者必须不影响主事件流
|
|
99
|
+
log_suppressed_exception(
|
|
100
|
+
context="cut_off_subscriber_emit_lagged",
|
|
101
|
+
exc=exc,
|
|
102
|
+
run_id=self._context.run_id if self._context else None,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
def _publish_nowait(self, ev: RuntimeEvent) -> None:
|
|
106
|
+
self._store.append(ev)
|
|
107
|
+
for q in list(self._subs):
|
|
108
|
+
try:
|
|
109
|
+
q.put_nowait(ev)
|
|
110
|
+
except asyncio.QueueFull:
|
|
111
|
+
self._cut_off_subscriber(q=q)
|
|
112
|
+
except Exception as exc:
|
|
113
|
+
# fail-open:单个订阅者的异常不得影响主事件流
|
|
114
|
+
log_suppressed_exception(
|
|
115
|
+
context="publish_to_subscriber",
|
|
116
|
+
exc=exc,
|
|
117
|
+
extra={"event_type": getattr(ev, "type", None)},
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
def _emit_resume_error(self, *, info: ResumeErrorInfo) -> RuntimeEvent:
|
|
121
|
+
msg = (
|
|
122
|
+
f"after_id expired or not found: {info.after_id!r} "
|
|
123
|
+
f"(available: {info.known_min_id!r}..{info.known_max_id!r})"
|
|
124
|
+
)
|
|
125
|
+
return self._projector.error(
|
|
126
|
+
kind="after_id_expired",
|
|
127
|
+
message=msg,
|
|
128
|
+
data={
|
|
129
|
+
"after_id": info.after_id,
|
|
130
|
+
"known_min_id": info.known_min_id,
|
|
131
|
+
"known_max_id": info.known_max_id,
|
|
132
|
+
},
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
def _remember_task_failure(self, task: asyncio.Task[Any]) -> None:
|
|
136
|
+
"""提取后台任务异常,避免 fire-and-forget 把错误静默丢掉。"""
|
|
137
|
+
|
|
138
|
+
try:
|
|
139
|
+
exc = task.exception()
|
|
140
|
+
except asyncio.CancelledError:
|
|
141
|
+
return
|
|
142
|
+
if exc is not None and self._background_error is None:
|
|
143
|
+
self._background_error = exc
|
|
144
|
+
|
|
145
|
+
async def ensure_started(self) -> None:
|
|
146
|
+
if self._started:
|
|
147
|
+
return
|
|
148
|
+
self._started = True
|
|
149
|
+
|
|
150
|
+
def _tap(agent_ev: AgentEvent, tap_ctx: Dict[str, Any]) -> None:
|
|
151
|
+
try:
|
|
152
|
+
expected_run_id = self._context.run_id
|
|
153
|
+
ctx_run_id = tap_ctx.get("run_id")
|
|
154
|
+
if isinstance(ctx_run_id, str) and ctx_run_id != expected_run_id:
|
|
155
|
+
return
|
|
156
|
+
ev_run_id = getattr(agent_ev, "run_id", None)
|
|
157
|
+
if isinstance(ev_run_id, str) and ev_run_id != expected_run_id:
|
|
158
|
+
return
|
|
159
|
+
self._in_q.put_nowait(("agent_event", agent_ev, tap_ctx))
|
|
160
|
+
except asyncio.QueueFull:
|
|
161
|
+
# fail-open:旁路 tap 不得影响主流程;输入队列背压时丢弃旁路事件
|
|
162
|
+
pass
|
|
163
|
+
except Exception as exc:
|
|
164
|
+
# fail-open:旁路 tap 过滤/入队异常不得影响主流程
|
|
165
|
+
log_suppressed_exception(
|
|
166
|
+
context="ui_events_tap",
|
|
167
|
+
exc=exc,
|
|
168
|
+
run_id=self._context.run_id if self._context else None,
|
|
169
|
+
extra={"event_type": getattr(agent_ev, "type", None)},
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
self._runtime._register_agent_event_tap(_tap)
|
|
173
|
+
|
|
174
|
+
async def _runner() -> None:
|
|
175
|
+
try:
|
|
176
|
+
async for item in self._runtime.run_stream(self._capability_id, input=self._input, context=self._context):
|
|
177
|
+
if isinstance(item, dict):
|
|
178
|
+
await self._in_q.put(("workflow_event", item))
|
|
179
|
+
elif isinstance(item, CapabilityResult):
|
|
180
|
+
await self._in_q.put(("terminal", item))
|
|
181
|
+
return
|
|
182
|
+
else:
|
|
183
|
+
continue
|
|
184
|
+
await self._in_q.put(("error", RuntimeError("run_stream ended without terminal CapabilityResult")))
|
|
185
|
+
except Exception as exc:
|
|
186
|
+
await self._in_q.put(("error", exc))
|
|
187
|
+
|
|
188
|
+
task = asyncio.create_task(_runner())
|
|
189
|
+
task.add_done_callback(self._remember_task_failure)
|
|
190
|
+
self._runner_task = task
|
|
191
|
+
|
|
192
|
+
async def _loop() -> None:
|
|
193
|
+
try:
|
|
194
|
+
for ev in self._projector.start():
|
|
195
|
+
self._publish_nowait(ev)
|
|
196
|
+
|
|
197
|
+
done = False
|
|
198
|
+
while not done:
|
|
199
|
+
try:
|
|
200
|
+
item = await asyncio.wait_for(self._in_q.get(), timeout=self._heartbeat_interval_s)
|
|
201
|
+
except asyncio.TimeoutError:
|
|
202
|
+
if self._level != StreamLevel.LITE:
|
|
203
|
+
self._publish_nowait(self._projector.heartbeat())
|
|
204
|
+
continue
|
|
205
|
+
|
|
206
|
+
kind = item[0]
|
|
207
|
+
if kind == "agent_event":
|
|
208
|
+
agent_ev, tap_ctx = item[1], item[2]
|
|
209
|
+
agent_ctx = _AgentCtx(
|
|
210
|
+
run_id=str(tap_ctx.get("run_id") or self._context.run_id),
|
|
211
|
+
capability_id=str(tap_ctx.get("capability_id") or ""),
|
|
212
|
+
workflow_id=str(tap_ctx.get("workflow_id")) if isinstance(tap_ctx.get("workflow_id"), str) else None,
|
|
213
|
+
workflow_instance_id=str(tap_ctx.get("workflow_instance_id"))
|
|
214
|
+
if isinstance(tap_ctx.get("workflow_instance_id"), str)
|
|
215
|
+
else None,
|
|
216
|
+
step_id=str(tap_ctx.get("step_id")) if isinstance(tap_ctx.get("step_id"), str) else None,
|
|
217
|
+
branch_id=str(tap_ctx.get("branch_id")) if isinstance(tap_ctx.get("branch_id"), str) else None,
|
|
218
|
+
wf_frames=list(tap_ctx.get("wf_frames")) if isinstance(tap_ctx.get("wf_frames"), list) else None,
|
|
219
|
+
)
|
|
220
|
+
for out_ev in self._projector.on_agent_event(agent_ev, ctx=agent_ctx):
|
|
221
|
+
self._publish_nowait(out_ev)
|
|
222
|
+
elif kind == "workflow_event":
|
|
223
|
+
wf_ev = item[1]
|
|
224
|
+
for out_ev in self._projector.on_workflow_event(wf_ev):
|
|
225
|
+
self._publish_nowait(out_ev)
|
|
226
|
+
elif kind == "terminal":
|
|
227
|
+
terminal = item[1]
|
|
228
|
+
for out_ev in self._projector.on_terminal(terminal):
|
|
229
|
+
self._publish_nowait(out_ev)
|
|
230
|
+
done = True
|
|
231
|
+
elif kind == "error":
|
|
232
|
+
exc = item[1]
|
|
233
|
+
self._publish_nowait(self._projector.error(kind="runner_error", message=str(exc)))
|
|
234
|
+
for out_ev in self._projector.on_terminal(CapabilityResult(status=CapabilityStatus.FAILED, error=str(exc))):
|
|
235
|
+
self._publish_nowait(out_ev)
|
|
236
|
+
done = True
|
|
237
|
+
finally:
|
|
238
|
+
self._done.set()
|
|
239
|
+
await task
|
|
240
|
+
self._runtime._unregister_agent_event_tap(_tap)
|
|
241
|
+
|
|
242
|
+
session_task = asyncio.create_task(_loop())
|
|
243
|
+
session_task.add_done_callback(self._remember_task_failure)
|
|
244
|
+
self._session_task = session_task
|
|
245
|
+
|
|
246
|
+
async def wait_done(self) -> None:
|
|
247
|
+
await self._done.wait()
|
|
248
|
+
if self._session_task is not None:
|
|
249
|
+
await self._session_task
|
|
250
|
+
if self._background_error is not None:
|
|
251
|
+
raise self._background_error
|
|
252
|
+
|
|
253
|
+
async def subscribe(self, *, after_id: Optional[str]) -> AsyncIterator[RuntimeEvent]:
|
|
254
|
+
await self.ensure_started()
|
|
255
|
+
q: asyncio.Queue = asyncio.Queue(maxsize=self._subscriber_queue_maxsize)
|
|
256
|
+
self._subs.add(q)
|
|
257
|
+
try:
|
|
258
|
+
try:
|
|
259
|
+
replay = list(self._store.read_after(after_id=after_id))
|
|
260
|
+
except AfterIdExpiredError as exc:
|
|
261
|
+
err = self._emit_resume_error(
|
|
262
|
+
info=ResumeErrorInfo(after_id=exc.after_id, known_min_id=exc.min_rid, known_max_id=exc.max_rid)
|
|
263
|
+
)
|
|
264
|
+
yield err
|
|
265
|
+
return
|
|
266
|
+
|
|
267
|
+
last_seq = replay[-1].seq if replay else 0
|
|
268
|
+
for ev in replay:
|
|
269
|
+
yield ev
|
|
270
|
+
if ev.type == "run.status" and ev.data.get("status") != "running":
|
|
271
|
+
return
|
|
272
|
+
|
|
273
|
+
while True:
|
|
274
|
+
if self._done.is_set() and q.empty():
|
|
275
|
+
return
|
|
276
|
+
ev = await q.get()
|
|
277
|
+
if ev.type == "error" and ev.data.get("kind") == "subscriber_lagged":
|
|
278
|
+
yield ev
|
|
279
|
+
return
|
|
280
|
+
if ev.seq <= last_seq:
|
|
281
|
+
continue
|
|
282
|
+
last_seq = ev.seq
|
|
283
|
+
yield ev
|
|
284
|
+
if ev.type == "run.status" and ev.data.get("status") != "running":
|
|
285
|
+
return
|
|
286
|
+
finally:
|
|
287
|
+
self._subs.discard(q)
|
|
288
|
+
|
|
289
|
+
async def _ensure_started(self) -> None:
|
|
290
|
+
"""兼容旧调用方;新代码应使用公开的 `ensure_started()`。"""
|
|
291
|
+
|
|
292
|
+
await self.ensure_started()
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Runtime UI Events:断线续传(rid/after_id)的最小 in-memory store。
|
|
3
|
+
|
|
4
|
+
定位:
|
|
5
|
+
- 提供最小可复用能力:append + read_after(exclusive)+ 过期诊断;
|
|
6
|
+
- 不绑定任何 HTTP 框架或持久化实现;
|
|
7
|
+
- run 级别隔离由调用方保证(通常一个 store 对应一个 run)。
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from collections import deque
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
import threading
|
|
15
|
+
from typing import Deque, Dict, Iterable, Optional, Protocol
|
|
16
|
+
|
|
17
|
+
from .v1 import RuntimeEvent
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class RuntimeEventStore(Protocol):
|
|
21
|
+
"""
|
|
22
|
+
RuntimeEventStore:用于断线续传的最小 store 接口(可插拔)。
|
|
23
|
+
|
|
24
|
+
约束:
|
|
25
|
+
- `read_after` 为排他语义(exclusive:strictly after)
|
|
26
|
+
- after_id 过期/不在窗口内时,建议抛出 `AfterIdExpiredError`(可诊断)
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def min_rid(self) -> Optional[str]: ...
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def max_rid(self) -> Optional[str]: ...
|
|
34
|
+
|
|
35
|
+
def append(self, ev: RuntimeEvent) -> None: ...
|
|
36
|
+
|
|
37
|
+
def read_after(self, *, after_id: Optional[str]) -> Iterable[RuntimeEvent]: ...
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class AfterIdExpiredError(Exception):
|
|
42
|
+
"""
|
|
43
|
+
after_id 不在可用窗口内(可能是裁剪过期,也可能是未知/非法游标)。
|
|
44
|
+
|
|
45
|
+
字段:
|
|
46
|
+
- after_id:请求的游标
|
|
47
|
+
- min_rid/max_rid:当前窗口内的可用范围(用于诊断与提示客户端策略)
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
after_id: str
|
|
51
|
+
min_rid: Optional[str]
|
|
52
|
+
max_rid: Optional[str]
|
|
53
|
+
|
|
54
|
+
def __str__(self) -> str:
|
|
55
|
+
return f"after_id expired or not found: {self.after_id!r} (available: {self.min_rid!r}..{self.max_rid!r})"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class InMemoryRuntimeEventStore(RuntimeEventStore):
|
|
59
|
+
"""
|
|
60
|
+
in-memory 事件存储(ring buffer)。
|
|
61
|
+
|
|
62
|
+
约束:
|
|
63
|
+
- `read_after` 为排他语义(exclusive:strictly after)
|
|
64
|
+
- 当 after_id 不在窗口内时抛出 AfterIdExpiredError(可诊断)
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
def __init__(self, *, max_events: int = 10_000) -> None:
|
|
68
|
+
if max_events <= 0:
|
|
69
|
+
raise ValueError("max_events must be > 0")
|
|
70
|
+
self._max_events = int(max_events)
|
|
71
|
+
self._events: Deque[RuntimeEvent] = deque(maxlen=self._max_events)
|
|
72
|
+
self._rid_to_pos: Dict[str, int] = {}
|
|
73
|
+
self._start_pos = 0
|
|
74
|
+
self._lock = threading.Lock()
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def min_rid(self) -> Optional[str]:
|
|
78
|
+
with self._lock:
|
|
79
|
+
if not self._events:
|
|
80
|
+
return None
|
|
81
|
+
return self._events[0].rid
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def max_rid(self) -> Optional[str]:
|
|
85
|
+
with self._lock:
|
|
86
|
+
if not self._events:
|
|
87
|
+
return None
|
|
88
|
+
return self._events[-1].rid
|
|
89
|
+
|
|
90
|
+
def append(self, ev: RuntimeEvent) -> None:
|
|
91
|
+
if ev.rid is None:
|
|
92
|
+
raise ValueError("RuntimeEvent.rid is required for resume store")
|
|
93
|
+
with self._lock:
|
|
94
|
+
if len(self._events) == self._max_events:
|
|
95
|
+
evicted = self._events.popleft()
|
|
96
|
+
if evicted.rid is not None:
|
|
97
|
+
self._rid_to_pos.pop(evicted.rid, None)
|
|
98
|
+
self._start_pos += 1
|
|
99
|
+
pos = self._start_pos + len(self._events)
|
|
100
|
+
self._events.append(ev)
|
|
101
|
+
self._rid_to_pos[ev.rid] = pos
|
|
102
|
+
|
|
103
|
+
def read_after(self, *, after_id: Optional[str]) -> Iterable[RuntimeEvent]:
|
|
104
|
+
"""
|
|
105
|
+
读取 after_id 之后的事件(exclusive)。
|
|
106
|
+
|
|
107
|
+
参数:
|
|
108
|
+
- after_id:续传游标;None 表示从头读取当前窗口
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
with self._lock:
|
|
112
|
+
snapshot = list(self._events)
|
|
113
|
+
min_rid = snapshot[0].rid if snapshot else None
|
|
114
|
+
max_rid = snapshot[-1].rid if snapshot else None
|
|
115
|
+
if after_id is None:
|
|
116
|
+
return snapshot
|
|
117
|
+
if not snapshot:
|
|
118
|
+
return []
|
|
119
|
+
|
|
120
|
+
pos = self._rid_to_pos.get(str(after_id))
|
|
121
|
+
if pos is None:
|
|
122
|
+
raise AfterIdExpiredError(after_id=str(after_id), min_rid=min_rid, max_rid=max_rid)
|
|
123
|
+
|
|
124
|
+
idx = pos - self._start_pos
|
|
125
|
+
if idx < 0 or idx >= len(snapshot):
|
|
126
|
+
raise AfterIdExpiredError(after_id=str(after_id), min_rid=min_rid, max_rid=max_rid)
|
|
127
|
+
return snapshot[idx + 1 :]
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
"""
|
|
4
|
+
Runtime UI Events:通用传输适配(JSON Lines / SSE 子集)。
|
|
5
|
+
|
|
6
|
+
定位:
|
|
7
|
+
- 不绑定任何 Web 框架;
|
|
8
|
+
- 只负责 framing(把 RuntimeEvent 转成可流式发送的文本 chunk)。
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
|
|
13
|
+
from .v1 import RuntimeEvent
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def encode_json_line(ev: RuntimeEvent, *, prefix_data: bool = False) -> str:
|
|
17
|
+
"""
|
|
18
|
+
将单条 RuntimeEvent 编码为可流式发送的文本。
|
|
19
|
+
|
|
20
|
+
参数:
|
|
21
|
+
- ev:RuntimeEvent
|
|
22
|
+
- prefix_data:是否使用 `data: ` 前缀(SSE 子集兼容)
|
|
23
|
+
|
|
24
|
+
返回:
|
|
25
|
+
- JSONL:`<json>\\n`
|
|
26
|
+
- SSE 子集:`data: <json>\\n\\n`
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
payload = json.dumps(ev.model_dump(by_alias=True), ensure_ascii=False, separators=(",", ":"))
|
|
30
|
+
if prefix_data:
|
|
31
|
+
return f"data: {payload}\n\n"
|
|
32
|
+
return payload + "\n"
|
|
33
|
+
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
"""
|
|
4
|
+
Runtime UI Events v1(协议模型)。
|
|
5
|
+
|
|
6
|
+
定位:
|
|
7
|
+
- UI events 是“可观测/产品化投影层”,不是审计真相源;
|
|
8
|
+
- 真相源仍为 WAL/events/tool evidence → NodeReport;
|
|
9
|
+
- UI events 将(上游 `AgentEvent`、workflow 轻量事件、终态 `CapabilityResult`)投影为统一的 RuntimeEvent 信封;
|
|
10
|
+
- 本模块仅定义 v1 的稳定信封字段与最小结构约束。
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from enum import Enum
|
|
14
|
+
from typing import Any, Dict, List, Optional
|
|
15
|
+
|
|
16
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class StreamLevel(str, Enum):
|
|
20
|
+
"""事件输出层级。"""
|
|
21
|
+
|
|
22
|
+
LITE = "lite"
|
|
23
|
+
UI = "ui"
|
|
24
|
+
RAW = "raw"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class PathSegment(BaseModel):
|
|
28
|
+
"""PathSegment(图投影路径段)。"""
|
|
29
|
+
|
|
30
|
+
model_config = ConfigDict(extra="forbid")
|
|
31
|
+
|
|
32
|
+
kind: str = Field(min_length=1)
|
|
33
|
+
id: str = Field(min_length=1)
|
|
34
|
+
# v1 加法演进:用于多实例/嵌套消歧与展示(不要求每条事件都提供)
|
|
35
|
+
instance_id: Optional[str] = None
|
|
36
|
+
# `ref` 用于携带“逻辑身份”(例如 workflow spec id / capability id / tool name),避免 id 变为 opaque 后 UI 无法展示。
|
|
37
|
+
# 约束:保持通用性,只提供 kind/id 两个维度。
|
|
38
|
+
ref: Optional[Dict[str, str]] = None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class Evidence(BaseModel):
|
|
42
|
+
"""证据指针:用于回到 WAL/NodeReport/tool evidence 真相源。"""
|
|
43
|
+
|
|
44
|
+
model_config = ConfigDict(extra="forbid")
|
|
45
|
+
|
|
46
|
+
events_path: Optional[str] = None
|
|
47
|
+
call_id: Optional[str] = None
|
|
48
|
+
artifact_path: Optional[str] = None
|
|
49
|
+
node_report_schema: Optional[str] = None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class RuntimeEvent(BaseModel):
|
|
53
|
+
"""
|
|
54
|
+
RuntimeEvent v1:统一事件信封(Envelope)。
|
|
55
|
+
|
|
56
|
+
约束:
|
|
57
|
+
- `schema` 固定为 `capability-runtime.runtime_event.v1`
|
|
58
|
+
- `seq` 在单 run 内单调递增
|
|
59
|
+
- `rid` 为传输层游标(续传用),可与 `seq` 等价但语义不同
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
model_config = ConfigDict(extra="forbid", populate_by_name=True)
|
|
63
|
+
|
|
64
|
+
schema_id: str = Field(min_length=1, alias="schema")
|
|
65
|
+
type: str = Field(min_length=1)
|
|
66
|
+
run_id: str = Field(min_length=1)
|
|
67
|
+
|
|
68
|
+
seq: int = Field(ge=0)
|
|
69
|
+
ts_ms: int = Field(ge=0)
|
|
70
|
+
level: StreamLevel
|
|
71
|
+
|
|
72
|
+
path: List[PathSegment] = Field(default_factory=list)
|
|
73
|
+
data: Dict[str, Any] = Field(default_factory=dict)
|
|
74
|
+
|
|
75
|
+
rid: Optional[str] = None
|
|
76
|
+
evidence: Optional[Evidence] = None
|