edgeone 1.5.9 → 1.6.0

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 (47) hide show
  1. package/README.md +26 -26
  2. package/edgeone-bin/edgeone.js +3 -3
  3. package/edgeone-dist/cli.js +86879 -2307
  4. package/edgeone-dist/libs-pages-agent-toolkit/README.md +8 -0
  5. package/edgeone-dist/libs-pages-agent-toolkit/pages_agent_toolkit-0.1.40-py3-none-any.whl +0 -0
  6. package/edgeone-dist/libs-pages-blob-python/README.md +38 -0
  7. package/edgeone-dist/libs-pages-blob-python/pages_blob_python-0.11.0-py3-none-any.whl +0 -0
  8. package/edgeone-dist/pages/dev/runner-worker.js +86521 -2090
  9. package/edgeone-dist/pages/observability-python/__init__.py +32 -0
  10. package/edgeone-dist/pages/observability-python/_compat.py +69 -0
  11. package/edgeone-dist/pages/observability-python/apm/__init__.py +13 -0
  12. package/edgeone-dist/pages/observability-python/apm/config.py +85 -0
  13. package/edgeone-dist/pages/observability-python/apm/llm_semconv.py +53 -0
  14. package/edgeone-dist/pages/observability-python/apm/metrics_bridge.py +226 -0
  15. package/edgeone-dist/pages/observability-python/apm/span_exporter.py +384 -0
  16. package/edgeone-dist/pages/observability-python/bootstrap.py +158 -0
  17. package/edgeone-dist/pages/observability-python/build.py +119 -0
  18. package/edgeone-dist/pages/observability-python/context_patches.py +167 -0
  19. package/edgeone-dist/pages/observability-python/context_propagator.py +78 -0
  20. package/edgeone-dist/pages/observability-python/registry.json +95 -0
  21. package/edgeone-dist/pages/observability-python/registry.py +141 -0
  22. package/edgeone-dist/pages/observability-python/telemetry.py +214 -0
  23. package/edgeone-dist/pages/observability-python/tracer.py +165 -0
  24. package/edgeone-dist/pages/templates/agent-python/__init__.py +11 -0
  25. package/edgeone-dist/pages/templates/agent-python/adapter.py +908 -0
  26. package/edgeone-dist/pages/templates/agent-python/context.py +689 -0
  27. package/edgeone-dist/pages/templates/agent-python/local_blob_store.py +172 -0
  28. package/edgeone-dist/pages/templates/agent-python/memory.py +2301 -0
  29. package/edgeone-dist/pages/templates/agent-python/runtime.py +839 -0
  30. package/edgeone-dist/pages/templates/agent-python/store.py +204 -0
  31. package/edgeone-dist/studio/ui/assets/agent-obs-Dvi4IpEy.js +4 -0
  32. package/edgeone-dist/studio/ui/assets/agent-obs-qDJCE0TQ.css +1 -0
  33. package/edgeone-dist/studio/ui/assets/highlight-ClXAL37H.js +3 -0
  34. package/edgeone-dist/studio/ui/assets/index-Cz5oQnXW.css +1 -0
  35. package/edgeone-dist/studio/ui/assets/index-DD3d108t.js +1 -0
  36. package/edgeone-dist/studio/ui/assets/moment-BYRO94Ou.js +10 -0
  37. package/edgeone-dist/studio/ui/assets/react-dom-ZzBHVjtL.js +24 -0
  38. package/edgeone-dist/studio/ui/assets/react-hnpCyKql.js +17 -0
  39. package/edgeone-dist/studio/ui/assets/tea-CADagUwM.css +1 -0
  40. package/edgeone-dist/studio/ui/assets/tea-Slf_ajmf.js +334 -0
  41. package/edgeone-dist/studio/ui/favicon.ico +0 -0
  42. package/edgeone-dist/studio/ui/index.html +31 -0
  43. package/libs-pages-agent-toolkit/README.md +8 -0
  44. package/libs-pages-agent-toolkit/pages_agent_toolkit-0.1.40-py3-none-any.whl +0 -0
  45. package/libs-pages-blob-python/README.md +38 -0
  46. package/libs-pages-blob-python/pages_blob_python-0.11.0-py3-none-any.whl +0 -0
  47. package/package.json +33 -7
@@ -0,0 +1,689 @@
1
+ # src/pages/builder/templates/agent-python/context.py
2
+ """AgentContext — the single argument passed to every agent handler.
3
+
4
+ 也定义流式响应所需的公共类型 StreamResponse + SSE 便捷构造。
5
+
6
+ 推荐用法(通过 ctx.utils,无需任何 import):
7
+
8
+ async def handler(ctx):
9
+ async def gen():
10
+ yield ctx.utils.sse({"event": "start"})
11
+ yield ctx.utils.sse({"token": "hi"}, event="delta")
12
+ return ctx.utils.stream_sse(gen())
13
+
14
+ 向后兼容:旧的显式 import 方式仍然有效:
15
+
16
+ from _platform.context import StreamResponse, sse
17
+
18
+ 之所以放在 context.py 而非 runtime.py:业务 handler 只 import 这个模块就够了,
19
+ runtime.py 是 adapter 内部 dispatch 用的,业务无需感知。
20
+ """
21
+ from __future__ import annotations
22
+
23
+ import asyncio
24
+ import hashlib
25
+ import json
26
+ import os
27
+ import re
28
+ from dataclasses import dataclass, field
29
+ from typing import Any, AsyncIterator, Callable, Iterable, Mapping, Optional, TYPE_CHECKING, Union
30
+
31
+ if TYPE_CHECKING:
32
+ from .store import InMemoryStore
33
+ from .runtime import AgentsApi
34
+ from .memory import ConversationMemory
35
+
36
+
37
+ PAGES_SANDBOX_SEALED_TOKEN = "{{PAGES_SANDBOX_SEALED_TOKEN}}"
38
+
39
+
40
+ class SandboxRuntimeError(RuntimeError):
41
+ def __init__(self, message: str, code: str, operation: str = "init", retryable: bool = False):
42
+ super().__init__(message)
43
+ self.code = code
44
+ self.operation = operation
45
+ self.retryable = retryable
46
+
47
+
48
+ def _is_placeholder_token(value: str) -> bool:
49
+ return value.startswith("{{") and value.endswith("}}")
50
+
51
+
52
+ def _is_valid_sandbox_sealed_token(value: str) -> bool:
53
+ token = str(value or "").strip()
54
+ return len(token) <= 4096 and re.fullmatch(r"sandbox\.v1\.[A-Za-z0-9._~-]+", token) is not None
55
+
56
+
57
+ def _resolve_local_login_token_from_persisted_storage() -> str:
58
+ try:
59
+ storage_file = os.path.join(
60
+ os.path.expanduser("~"),
61
+ ".edgeone",
62
+ hashlib.sha256("eo_token".encode("utf-8")).hexdigest(),
63
+ )
64
+ with open(storage_file, "r", encoding="utf-8") as file:
65
+ datum = json.load(file)
66
+ return str(((datum or {}).get("value") or {}).get("Token") or "").strip()
67
+ except Exception:
68
+ return ""
69
+
70
+
71
+ def _resolve_sandbox_auth_token(env: Mapping[str, str], mode: str) -> str:
72
+ sealed_token = str(PAGES_SANDBOX_SEALED_TOKEN or "").strip()
73
+ if _is_valid_sandbox_sealed_token(sealed_token):
74
+ return sealed_token
75
+ if sealed_token and not _is_placeholder_token(sealed_token):
76
+ raise SandboxRuntimeError(
77
+ "Invalid sandbox sealed token. Expected sandbox.v1.* token generated by Pages deployment.",
78
+ "SANDBOX_INVALID_AUTH_TOKEN",
79
+ )
80
+ if mode == "dev":
81
+ dev_token = str(env.get("EDGEONE_PAGES_API_TOKEN", "") or "").strip()
82
+ if dev_token:
83
+ return dev_token
84
+ local_login_token = _resolve_local_login_token_from_persisted_storage()
85
+ if local_login_token:
86
+ return local_login_token
87
+ raise SandboxRuntimeError(
88
+ "Sandbox auth token is missing. Please run `edgeone login` or set EDGEONE_PAGES_API_TOKEN in dev mode.",
89
+ "SANDBOX_AUTH_TOKEN_MISSING",
90
+ )
91
+ raise SandboxRuntimeError(
92
+ "Sandbox sealed token is missing. Ensure CI runs `edgeone pages replace` with sandbox sealed token before deployment.",
93
+ "SANDBOX_AUTH_TOKEN_MISSING",
94
+ )
95
+
96
+
97
+ def _resolve_sandbox_api_env(env: Mapping[str, str], configured_api_env: str = "") -> str:
98
+ for value in (
99
+ env.get("SANDBOX_API_ENV"),
100
+ configured_api_env,
101
+ env.get("API_ENV"),
102
+ ):
103
+ if isinstance(value, str) and value.strip():
104
+ return value.strip()
105
+ return ""
106
+
107
+
108
+ @dataclass
109
+ class RequestInfo:
110
+ body: dict
111
+ headers: dict
112
+ query: dict # URL query parameters, e.g. ?foo=bar → {"foo": "bar"}
113
+ signal: asyncio.Event # is_set() → cancelled
114
+
115
+ @property
116
+ def is_cancelled(self) -> bool:
117
+ return self.signal.is_set()
118
+
119
+
120
+ @dataclass
121
+ class AbortActiveRunResult:
122
+ """``ctx.utils.abortActiveRun`` 的返回值。
123
+
124
+ 与 Node ``AbortActiveRunResult`` 字段命名对齐(snake_case 版本)。
125
+
126
+ Attributes:
127
+ aborted: 是否真的取消了一个活跃 run。
128
+ conversation_id: 被取消的 run 所属 conversation(仅 aborted=True 时有值)。
129
+ run_id: 被取消的 run id(仅 aborted=True 时有值)。
130
+ """
131
+
132
+ aborted: bool
133
+ conversation_id: Optional[str] = None
134
+ run_id: Optional[str] = None
135
+
136
+
137
+ @dataclass
138
+ class AgentContext:
139
+ conversation_id: str
140
+ run_id: str
141
+ parent_run_id: str | None
142
+ request: RequestInfo
143
+ env: dict
144
+ kv: Any # Per-route KV store (BlobBackedStore or InMemoryStore)
145
+ agents: Any # AgentsApi (avoid circular import)
146
+ # 同 conversation 的活跃 run_id(如果有)。供业务自管并发场景使用:
147
+ # 对齐 Node `AgentContext.active_run_id`,业务在自定义 stop / cancel handler 中
148
+ # 可以根据这个字段判断是否需要主动 abort 旧 run;非 index 路由不会自动 409。
149
+ active_run_id: str | None = None
150
+ _store_blob: Any = None # Raw blob store for ctx.store (set by runtime)
151
+ _tracer: Any = None # AgentTracer or NoOpTracer, injected by runtime
152
+ _deadline: float = 0.0
153
+ _depth: int = 0
154
+ _call_chain: set[str] = field(default_factory=set)
155
+ # 当前 handler 所属的 route_path(仅用于 invoke 时检测「自调用自己」),
156
+ # 与 Node `executeRoute(input.currentRoutePath)` 的 self-recursion 检查对齐。
157
+ _call_chain_self: str = ""
158
+ # runtime 注入的 abortActiveRun 回调。已自动排除当前 run_id 自身,避免误取消自己。
159
+ # 业务侧通过 ctx.utils.abortActiveRun(conversation_id) 调用,与 Node
160
+ # `ctx.utils.abortActiveRun` 一致。runtime 没注入时(极少见,比如单元测试
161
+ # 手搓 ctx)调用会得到 aborted=False。
162
+ _abort_active_run_fn: Optional[Callable[[str], "AbortActiveRunResult"]] = field(
163
+ default=None, repr=False
164
+ )
165
+ _loop: Any = field(default=None, repr=False) # asyncio event loop for sync wrapper
166
+ _sandbox: Any = field(default=None, init=False, repr=False)
167
+ _tools: Any = field(default=None, init=False, repr=False)
168
+
169
+ @property
170
+ def sandbox(self):
171
+ """沙箱原子 API(懒加载:首次访问时触发 acquire)"""
172
+ if self._sandbox is None:
173
+ from pages_agent_toolkit import build_sandbox
174
+ try:
175
+ from .runtime_config import SANDBOX_TIMEOUT, AGENT_MODE, SANDBOX_API_ENV
176
+ except Exception:
177
+ SANDBOX_TIMEOUT = ""
178
+ AGENT_MODE = "prod"
179
+ SANDBOX_API_ENV = ""
180
+ import logging as _logging
181
+ _logging.warning(
182
+ "[sandbox-debug] build_sandbox env: "
183
+ "SANDBOX_SITE=%r, EDGEONE_PAGES_API_REGION=%r, "
184
+ "SANDBOX_API_BASE=%r, SANDBOX_REGION=%r, "
185
+ "TENCENTCLOUD_REGION=%r, SANDBOX_API_ENV=%r, "
186
+ "API_ENV=%r, resolved_site=%r, resolved_api_env=%r",
187
+ self.env.get("SANDBOX_SITE"),
188
+ self.env.get("EDGEONE_PAGES_API_REGION"),
189
+ self.env.get("SANDBOX_API_BASE"),
190
+ self.env.get("SANDBOX_REGION"),
191
+ self.env.get("TENCENTCLOUD_REGION"),
192
+ self.env.get("SANDBOX_API_ENV"),
193
+ self.env.get("API_ENV"),
194
+ self.env.get("SANDBOX_SITE") or self.env.get("EDGEONE_PAGES_API_REGION"),
195
+ _resolve_sandbox_api_env(self.env, SANDBOX_API_ENV),
196
+ )
197
+ object.__setattr__(self, "_sandbox", build_sandbox(
198
+ conversation_id=self.conversation_id,
199
+ api_base=self.env.get("SANDBOX_API_BASE", ""),
200
+ auth_token=_resolve_sandbox_auth_token(self.env, AGENT_MODE),
201
+ project_id=(
202
+ self.env.get("PROJECT_ID")
203
+ or self.env.get("EDGEONE_PROJECT_ID")
204
+ or self.env.get("ProjectId")
205
+ or self.env.get("PAGES_PROJECT_ID")
206
+ ),
207
+ deployment_id=(
208
+ self.env.get("DEPLOYMENT_ID")
209
+ or self.env.get("EDGEONE_DEPLOYMENT_ID")
210
+ or self.env.get("PAGES_DEPLOYMENT_ID")
211
+ ),
212
+ region=self.env.get("SANDBOX_REGION") or self.env.get("TENCENTCLOUD_REGION"),
213
+ site=self.env.get("SANDBOX_SITE") or self.env.get("EDGEONE_PAGES_API_REGION"),
214
+ timeout=SANDBOX_TIMEOUT or None,
215
+ sandbox_domain=self.env.get("SANDBOX_DOMAIN"),
216
+ api_env=_resolve_sandbox_api_env(self.env, SANDBOX_API_ENV),
217
+ ))
218
+ return self._sandbox
219
+
220
+ @property
221
+ def tools(self):
222
+ """预注册工具(按 framework 自动包装)"""
223
+ if self._tools is None:
224
+ from pages_agent_toolkit import build_tools
225
+ try:
226
+ from .runtime_config import AGENT_FRAMEWORK
227
+ except Exception:
228
+ AGENT_FRAMEWORK = ""
229
+ # 不在此硬编码 framework 默认值:留空时由 pages_agent_toolkit 的
230
+ # build_tools 负责应用其规范默认值,避免 CLI 与 toolkit 默认值漂移、
231
+ # 也避免与已安装 toolkit 版本耦合。
232
+ framework = AGENT_FRAMEWORK or self.env.get("AGENT_FRAMEWORK") or ""
233
+ object.__setattr__(self, "_tools", build_tools(framework, self.sandbox))
234
+ return self._tools
235
+
236
+ @property
237
+ def kv_sync(self) -> Any:
238
+ """同步版 KV store —— 在 run_in_executor 等非 event-loop 线程中使用。
239
+
240
+ 首次访问时懒创建 SyncStoreWrapper 并缓存。
241
+
242
+ 典型用法::
243
+
244
+ async def handler(ctx):
245
+ import asyncio
246
+ def blocking_work():
247
+ data = ctx.kv_sync.get("key") # 同步调用
248
+ ctx.kv_sync.set("result", data)
249
+ return data
250
+ result = await asyncio.get_running_loop().run_in_executor(None, blocking_work)
251
+ """
252
+ cached = getattr(self, '_kv_sync_cached', None)
253
+ if cached is not None:
254
+ return cached
255
+ if self._loop is None:
256
+ raise RuntimeError(
257
+ "kv_sync 不可用:event loop 引用缺失。"
258
+ "通常是因为 AgentContext 构造时未传入 _loop 参数。"
259
+ )
260
+ from .store import SyncStoreWrapper
261
+ wrapper = SyncStoreWrapper(self.kv, self._loop)
262
+ object.__setattr__(self, '_kv_sync_cached', wrapper)
263
+ return wrapper
264
+
265
+ @property
266
+ def store(self) -> "ConversationMemory":
267
+ """会话存储 — 消息历史 CRUD、LangGraph checkpointer/store 适配器。
268
+
269
+ 首次访问时惰性构造,使用独立的 blob store(与 per-route 的 ctx.kv 分开)。
270
+
271
+ 典型用法::
272
+
273
+ async def handler(ctx):
274
+ # 追加 user 消息
275
+ msg_id = await ctx.store.append_message(
276
+ ctx.conversation_id, "user", "Hello!"
277
+ )
278
+ # 获取对话历史(默认时间升序,方便直接拼 prompt)
279
+ messages = await ctx.store.get_messages(ctx.conversation_id)
280
+ # 转换为 OpenAI 格式
281
+ openai_msgs = ctx.store.to_openai_input(messages)
282
+ # LangGraph 适配器
283
+ checkpointer = ctx.store.langgraph_checkpointer
284
+ store = ctx.store.langgraph_store
285
+ """
286
+ cached = getattr(self, '_store_cached', None)
287
+ if cached is not None:
288
+ return cached
289
+ if self._store_blob is None:
290
+ raise RuntimeError(
291
+ "ctx.store 不可用:blob store 未配置。"
292
+ "通常是因为运行时未设置 store。"
293
+ )
294
+ from .memory import ConversationMemory
295
+ instance = ConversationMemory(self._store_blob, self.run_id)
296
+ object.__setattr__(self, '_store_cached', instance)
297
+ return instance
298
+
299
+ @property
300
+ def memory(self) -> "ConversationMemory":
301
+ """.. deprecated:: Use ctx.store instead.
302
+
303
+ 会话存储的旧入口,请迁移至 ctx.store。首次访问时会打印一次 deprecation warning。
304
+ """
305
+ if not getattr(self, '_memory_deprecated_warned', False):
306
+ import warnings
307
+ warnings.warn(
308
+ "ctx.memory is deprecated, use ctx.store instead. "
309
+ "ctx.memory will be removed in a future version.",
310
+ DeprecationWarning,
311
+ stacklevel=2,
312
+ )
313
+ object.__setattr__(self, '_memory_deprecated_warned', True)
314
+ return self.store
315
+
316
+ @property
317
+ def store_sync(self) -> Any:
318
+ """.. deprecated:: Use ctx.kv_sync instead.
319
+
320
+ 旧版同步 KV store 入口,请迁移至 ctx.kv_sync。
321
+ """
322
+ if not getattr(self, '_store_sync_deprecated_warned', False):
323
+ import warnings
324
+ warnings.warn(
325
+ "ctx.store_sync is deprecated, use ctx.kv_sync instead. "
326
+ "ctx.store_sync will be removed in a future version.",
327
+ DeprecationWarning,
328
+ stacklevel=2,
329
+ )
330
+ object.__setattr__(self, '_store_sync_deprecated_warned', True)
331
+ return self.kv_sync
332
+
333
+ @property
334
+ def utils(self) -> "ContextUtils":
335
+ """平台工具函数命名空间,无需任何 import 即可使用。
336
+
337
+ 典型用法(SSE 流式响应)::
338
+
339
+ async def handler(ctx):
340
+ async def gen():
341
+ yield ctx.utils.sse({"prompt": prompt}, event="start")
342
+ for token in stream_llm():
343
+ yield ctx.utils.sse({"token": token}, event="delta")
344
+ yield ctx.utils.sse({"done": True}, event="end")
345
+ return ctx.utils.stream_sse(gen())
346
+
347
+ 典型用法(中断目标 conversation 的活跃 run,与 Node
348
+ ``ctx.utils.abortActiveRun`` 命名一致)::
349
+
350
+ async def stop_handler(ctx):
351
+ target = ctx.request.body.get("conversation_id") or ""
352
+ result = ctx.utils.abortActiveRun(target)
353
+ return {
354
+ "status": "aborted" if result.aborted else "idle",
355
+ "conversation_id": result.conversation_id,
356
+ "run_id": result.run_id,
357
+ }
358
+
359
+ 可用方法:
360
+ - ctx.utils.sse(data, *, event, id, retry) → bytes 构造一帧 SSE bytes
361
+ - ctx.utils.stream_sse(gen, **kwargs) → StreamResponse SSE 流式响应
362
+ - ctx.utils.stream(gen, **kwargs) → StreamResponse 自定义流式响应
363
+ - ctx.utils.abortActiveRun(conversation_id) → AbortActiveRunResult
364
+ (alias: ctx.utils.abort_active_run)
365
+ """
366
+ cached = getattr(self, '_utils_cached', None)
367
+ if cached is not None:
368
+ return cached
369
+ instance = ContextUtils(abort_callback=self._abort_active_run_fn)
370
+ object.__setattr__(self, '_utils_cached', instance)
371
+ return instance
372
+
373
+ @property
374
+ def tracer(self) -> Any:
375
+ """手动埋点 API — 对齐 Node context.tracer。
376
+
377
+ 典型用法::
378
+
379
+ async def handler(ctx):
380
+ async def do_work(span):
381
+ span.event("start", {"input": user_input})
382
+ # ... LLM call (auto-instrumented spans become children) ...
383
+ return result
384
+ return await ctx.tracer.span("my-task", do_work)
385
+
386
+ 可用方法:
387
+ - ctx.tracer.start_span(name, attrs) -> TracerSpan
388
+ - ctx.tracer.span(name, fn, attrs) -> result
389
+ - ctx.tracer.event(name, attrs)
390
+ - ctx.tracer.set_attributes(attrs)
391
+ - ctx.tracer.record_exception(err, attrs)
392
+ """
393
+ cached = getattr(self, '_tracer_cached', None)
394
+ if cached is not None:
395
+ return cached
396
+ tracer = self._tracer
397
+ if tracer is None:
398
+ try:
399
+ from ._observability.tracer import get_agent_tracer
400
+ tracer = get_agent_tracer()
401
+ except (ImportError, Exception):
402
+ tracer = _NoOpTracerFallback()
403
+ object.__setattr__(self, '_tracer_cached', tracer)
404
+ return tracer
405
+
406
+
407
+ # ─── Streaming response types ───
408
+ #
409
+ # 设计原则:
410
+ # 1. handler 既可以返回普通对象(一次性响应,旧行为),也可以返回
411
+ # async generator / StreamResponse(流式响应,新行为)。runtime
412
+ # 用 isinstance + inspect.isasyncgen 区分。
413
+ # 2. StreamResponse 显式包装时允许设置 status / headers / content_type,
414
+ # 用于 SSE / NDJSON / 自定义二进制等场景。
415
+ # 3. async generator 是最简形式:handler 直接 `yield chunk`,runtime
416
+ # 用默认 200 + content-type=application/octet-stream(除非 chunk
417
+ # 本身是 str,那就 text/plain)。
418
+ # 4. chunk 类型:bytes 直接发,str 自动 utf-8 编码,dict/list 自动
419
+ # `json.dumps + \n` 包成 NDJSON 帧(业务无需手动序列化)。
420
+ # 5. SSE 帮一把:sse(data, event=..., id=...) 返回符合 SSE 规范的
421
+ # bytes 帧(`data: xxx\n\n`),业务在 generator 里 `yield sse(...)`
422
+ # 就行。
423
+
424
+ StreamChunk = Union[bytes, str, Mapping[str, Any], Iterable[Any]]
425
+
426
+
427
+ @dataclass
428
+ class StreamResponse:
429
+ """显式流式响应 —— 用于需要自定义 status/headers/content-type 的场景。
430
+
431
+ body 必须是 async iterator (`async def gen(): yield ...`),runtime 会
432
+ 逐 chunk 发到 ASGI send。
433
+
434
+ 典型用法(SSE):
435
+
436
+ from _platform.context import StreamResponse, sse
437
+
438
+ async def handler(ctx):
439
+ async def gen():
440
+ for token in stream_llm():
441
+ yield sse({"token": token})
442
+ yield sse({"done": True})
443
+ return StreamResponse(gen(), content_type="text/event-stream")
444
+
445
+ 若只需要默认 SSE,可以直接 `return StreamResponse.sse(gen())`。
446
+ """
447
+
448
+ body: AsyncIterator[StreamChunk]
449
+ status: int = 200
450
+ headers: Optional[dict[str, str]] = None
451
+ content_type: Optional[str] = None
452
+
453
+ @classmethod
454
+ def sse(
455
+ cls,
456
+ body: AsyncIterator[StreamChunk],
457
+ *,
458
+ status: int = 200,
459
+ extra_headers: Optional[dict[str, str]] = None,
460
+ ) -> "StreamResponse":
461
+ """构造 SSE 响应:自动设 content-type=text/event-stream + 反缓冲 headers。
462
+
463
+ body 里 yield 的 chunk 应该是 sse(...) 输出的 bytes(已含 `data:` 前缀
464
+ 和 `\\n\\n` 分隔)。如果 yield 的是 dict / str,runtime 会按 SSE 规范
465
+ 自动包装一次(防呆)。
466
+ """
467
+ # 反缓冲:禁止网关 / 反代 / 浏览器中间层做 buffering,否则 SSE
468
+ # 实时性会被中间层拖死。X-Accel-Buffering 是 nginx 的非标但事实
469
+ # 标准;Cache-Control: no-cache 防 CDN 缓存;Connection: keep-alive
470
+ # 大多数中间件已默认,写出来更明确。
471
+ headers = {
472
+ "cache-control": "no-cache, no-transform",
473
+ "x-accel-buffering": "no",
474
+ "connection": "keep-alive",
475
+ }
476
+ if extra_headers:
477
+ headers.update(extra_headers)
478
+ return cls(
479
+ body=body,
480
+ status=status,
481
+ headers=headers,
482
+ content_type="text/event-stream; charset=utf-8",
483
+ )
484
+
485
+
486
+ def sse(
487
+ data: Any,
488
+ *,
489
+ event: Optional[str] = None,
490
+ id: Optional[str] = None,
491
+ retry: Optional[int] = None,
492
+ ) -> bytes:
493
+ """构造一帧符合 SSE 规范的 bytes 帧。
494
+
495
+ SSE 帧格式(每行一个字段,空行结束一帧):
496
+ event: <name>
497
+ id: <id>
498
+ retry: <ms>
499
+ data: <line1>
500
+ data: <line2>
501
+ <empty line>
502
+
503
+ data 自动序列化:
504
+ - str → 直接作为 data 行(按 \\n 拆成多 data: 行)
505
+ - dict / list / 其他 → json.dumps(ensure_ascii=False)
506
+
507
+ 多行 data:SSE 规范要求每行单独 `data: ` 前缀,浏览器收到后再用 \\n 拼。
508
+
509
+ 返回 bytes 而不是 str,便于直接 yield 进 generator。
510
+ """
511
+ parts: list[str] = []
512
+ if event is not None:
513
+ parts.append(f"event: {event}")
514
+ if id is not None:
515
+ parts.append(f"id: {id}")
516
+ if retry is not None:
517
+ parts.append(f"retry: {int(retry)}")
518
+
519
+ # data 序列化
520
+ if isinstance(data, str):
521
+ text = data
522
+ elif isinstance(data, (bytes, bytearray)):
523
+ # bytes 形式:要求是 utf-8 文本数据;二进制内容 SSE 本身不支持,
524
+ # 只能让用户自行 base64。
525
+ text = bytes(data).decode("utf-8", errors="replace")
526
+ else:
527
+ try:
528
+ text = json.dumps(data, ensure_ascii=False, separators=(",", ":"))
529
+ except (TypeError, ValueError):
530
+ text = str(data)
531
+
532
+ # SSE 规范:data 字段按 \n 拆行,每行加 data: 前缀
533
+ for line in text.split("\n"):
534
+ parts.append(f"data: {line}")
535
+
536
+ # 帧之间用空行分隔
537
+ frame = "\n".join(parts) + "\n\n"
538
+ return frame.encode("utf-8")
539
+
540
+
541
+ # ─── ContextUtils — ctx.utils 命名空间 ───
542
+
543
+ class ContextUtils:
544
+ """挂在 AgentContext.utils 上的平台工具集合。
545
+
546
+ 业务侧通过 ``ctx.utils.<method>`` 调用,无需任何额外 import。
547
+ 所有方法都是对模块级 ``sse`` / ``StreamResponse`` 等的薄封装,零额外开销。
548
+
549
+ 与 Node agent 的 ``ctx.utils`` 入口一一对齐:
550
+ - ``abortActiveRun(conversation_id)``:取消同实例内目标 conversation 的活跃 run。
551
+ runtime 注入回调时已自动排除当前 run_id 自身,不会误取消自己。
552
+ Python 这边额外保留 snake_case 别名 ``abort_active_run`` 以贴合 Python 风格。
553
+
554
+ ``abort_callback`` 由 runtime 在构建 context 时注入;如果为 None(极少见,
555
+ 比如单测里手搓 AgentContext),调用 ``abortActiveRun`` 会得到
556
+ ``AbortActiveRunResult(aborted=False)``,不会抛异常。
557
+ """
558
+
559
+ def __init__(
560
+ self,
561
+ abort_callback: Optional[Callable[[str], "AbortActiveRunResult"]] = None,
562
+ ) -> None:
563
+ self._abort_callback = abort_callback
564
+
565
+ # ── SSE ──
566
+
567
+ def sse(
568
+ self,
569
+ data: Any,
570
+ *,
571
+ event: Optional[str] = None,
572
+ id: Optional[str] = None,
573
+ retry: Optional[int] = None,
574
+ ) -> bytes:
575
+ """构造一帧符合 SSE 规范的 bytes,在 async generator 里 yield 使用。
576
+
577
+ 等价于模块级 ``sse(data, event=..., id=..., retry=...)``。
578
+
579
+ 示例::
580
+
581
+ yield ctx.utils.sse({"token": "hi"}, event="delta")
582
+ yield ctx.utils.sse("[DONE]", event="end")
583
+ """
584
+ return sse(data, event=event, id=id, retry=retry)
585
+
586
+ # ── StreamResponse 工厂 ──
587
+
588
+ def stream_sse(
589
+ self,
590
+ body: AsyncIterator[StreamChunk],
591
+ *,
592
+ status: int = 200,
593
+ extra_headers: Optional[dict[str, str]] = None,
594
+ ) -> StreamResponse:
595
+ """构造 SSE 流式响应(自动设 content-type + 反缓冲 headers)。
596
+
597
+ 等价于 ``StreamResponse.sse(body, ...)``.
598
+
599
+ 示例::
600
+
601
+ return ctx.utils.stream_sse(gen())
602
+ """
603
+ return StreamResponse.sse(body, status=status, extra_headers=extra_headers)
604
+
605
+ def stream(
606
+ self,
607
+ body: AsyncIterator[StreamChunk],
608
+ *,
609
+ status: int = 200,
610
+ content_type: Optional[str] = None,
611
+ headers: Optional[dict[str, str]] = None,
612
+ ) -> StreamResponse:
613
+ """构造自定义流式响应(可指定 status / content_type / headers)。
614
+
615
+ 等价于 ``StreamResponse(body, ...)``.
616
+
617
+ 示例::
618
+
619
+ return ctx.utils.stream(gen(), content_type="application/x-ndjson")
620
+ """
621
+ return StreamResponse(body=body, status=status, content_type=content_type, headers=headers)
622
+
623
+ # ── Abort ──
624
+
625
+ def abortActiveRun(self, conversation_id: str) -> "AbortActiveRunResult": # noqa: N802 — 与 Node 命名对齐
626
+ """主动取消同实例内 **目标 conversation** 的活跃 run。
627
+
628
+ 与 Node ``ctx.utils.abortActiveRun(conversation_id)`` 命名/语义一致:
629
+ - 必须显式传 ``conversation_id``,避免同实例多会话场景下误取消其他会话;
630
+ - 调用方自身的 run **不会被取消**(runtime 注入回调时排除了 current run_id);
631
+ - 找不到匹配的活跃 run 时返回 ``aborted=False``,不视为错误。
632
+
633
+ Python 风格的别名见 :meth:`abort_active_run`。
634
+
635
+ 示例(业务自定义 ``/stop`` 路由)::
636
+
637
+ async def stop(ctx):
638
+ target = ctx.request.body.get("conversation_id") or ""
639
+ r = ctx.utils.abortActiveRun(target)
640
+ return {"status": "aborted" if r.aborted else "idle",
641
+ "conversation_id": r.conversation_id,
642
+ "run_id": r.run_id}
643
+ """
644
+ if not conversation_id or self._abort_callback is None:
645
+ return AbortActiveRunResult(aborted=False)
646
+ return self._abort_callback(conversation_id)
647
+
648
+ def abort_active_run(self, conversation_id: str) -> "AbortActiveRunResult":
649
+ """:meth:`abortActiveRun` 的 snake_case 别名(Python 风格)。
650
+
651
+ 两者完全等价;推荐 ``ctx.utils.abortActiveRun(...)`` 与 Node 端命名保持一致,
652
+ 在以 Python 为主的代码库里也可以用 ``ctx.utils.abort_active_run(...)``。
653
+ """
654
+ return self.abortActiveRun(conversation_id)
655
+
656
+
657
+ # ─── No-op tracer fallback (when _observability/ not present) ───
658
+
659
+ class _NoOpTracerFallback:
660
+ """Minimal no-op tracer for when _observability/ is not deployed."""
661
+
662
+ def start_span(self, name: str, attributes=None):
663
+ return self
664
+
665
+ async def span(self, name, fn, attributes=None):
666
+ import asyncio
667
+ result = fn(self)
668
+ if asyncio.iscoroutine(result):
669
+ return await result
670
+ return result
671
+
672
+ def event(self, name: str, attributes=None) -> None:
673
+ pass
674
+
675
+ def set_attribute(self, key: str, value) -> None:
676
+ pass
677
+
678
+ def set_attributes(self, attributes) -> None:
679
+ pass
680
+
681
+ def record_exception(self, error, attributes=None) -> None:
682
+ pass
683
+
684
+ def end(self) -> None:
685
+ pass
686
+
687
+ @property
688
+ def span_id(self) -> str:
689
+ return "0" * 16