edgeone 1.5.8 → 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.
- package/README.md +26 -26
- package/edgeone-bin/edgeone.js +3 -3
- package/edgeone-dist/cli.js +86879 -2294
- package/edgeone-dist/libs-pages-agent-toolkit/README.md +8 -0
- package/edgeone-dist/libs-pages-agent-toolkit/pages_agent_toolkit-0.1.40-py3-none-any.whl +0 -0
- package/edgeone-dist/libs-pages-blob-python/README.md +38 -0
- package/edgeone-dist/libs-pages-blob-python/pages_blob_python-0.11.0-py3-none-any.whl +0 -0
- package/edgeone-dist/pages/dev/runner-worker.js +86519 -2075
- package/edgeone-dist/pages/observability-python/__init__.py +32 -0
- package/edgeone-dist/pages/observability-python/_compat.py +69 -0
- package/edgeone-dist/pages/observability-python/apm/__init__.py +13 -0
- package/edgeone-dist/pages/observability-python/apm/config.py +85 -0
- package/edgeone-dist/pages/observability-python/apm/llm_semconv.py +53 -0
- package/edgeone-dist/pages/observability-python/apm/metrics_bridge.py +226 -0
- package/edgeone-dist/pages/observability-python/apm/span_exporter.py +384 -0
- package/edgeone-dist/pages/observability-python/bootstrap.py +158 -0
- package/edgeone-dist/pages/observability-python/build.py +119 -0
- package/edgeone-dist/pages/observability-python/context_patches.py +167 -0
- package/edgeone-dist/pages/observability-python/context_propagator.py +78 -0
- package/edgeone-dist/pages/observability-python/registry.json +95 -0
- package/edgeone-dist/pages/observability-python/registry.py +141 -0
- package/edgeone-dist/pages/observability-python/telemetry.py +214 -0
- package/edgeone-dist/pages/observability-python/tracer.py +165 -0
- package/edgeone-dist/pages/templates/agent-python/__init__.py +11 -0
- package/edgeone-dist/pages/templates/agent-python/adapter.py +908 -0
- package/edgeone-dist/pages/templates/agent-python/context.py +689 -0
- package/edgeone-dist/pages/templates/agent-python/local_blob_store.py +172 -0
- package/edgeone-dist/pages/templates/agent-python/memory.py +2301 -0
- package/edgeone-dist/pages/templates/agent-python/runtime.py +839 -0
- package/edgeone-dist/pages/templates/agent-python/store.py +204 -0
- package/edgeone-dist/studio/ui/assets/agent-obs-Dvi4IpEy.js +4 -0
- package/edgeone-dist/studio/ui/assets/agent-obs-qDJCE0TQ.css +1 -0
- package/edgeone-dist/studio/ui/assets/highlight-ClXAL37H.js +3 -0
- package/edgeone-dist/studio/ui/assets/index-Cz5oQnXW.css +1 -0
- package/edgeone-dist/studio/ui/assets/index-DD3d108t.js +1 -0
- package/edgeone-dist/studio/ui/assets/moment-BYRO94Ou.js +10 -0
- package/edgeone-dist/studio/ui/assets/react-dom-ZzBHVjtL.js +24 -0
- package/edgeone-dist/studio/ui/assets/react-hnpCyKql.js +17 -0
- package/edgeone-dist/studio/ui/assets/tea-CADagUwM.css +1 -0
- package/edgeone-dist/studio/ui/assets/tea-Slf_ajmf.js +334 -0
- package/edgeone-dist/studio/ui/favicon.ico +0 -0
- package/edgeone-dist/studio/ui/index.html +31 -0
- package/libs-pages-agent-toolkit/README.md +8 -0
- package/libs-pages-agent-toolkit/pages_agent_toolkit-0.1.40-py3-none-any.whl +0 -0
- package/libs-pages-blob-python/README.md +38 -0
- package/libs-pages-blob-python/pages_blob_python-0.11.0-py3-none-any.whl +0 -0
- package/package.json +33 -7
|
@@ -0,0 +1,839 @@
|
|
|
1
|
+
# src/pages/builder/templates/agent-python/runtime.py
|
|
2
|
+
"""Agent runtime — timeout, cancellation, invoke, active-run tracking."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import inspect
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import time
|
|
10
|
+
import sys
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import Any, AsyncIterator, Callable, Awaitable, Optional
|
|
13
|
+
from uuid import uuid4
|
|
14
|
+
|
|
15
|
+
from .context import AgentContext, AbortActiveRunResult, RequestInfo, SandboxRuntimeError, StreamResponse
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# LangGraph 通过抛出这些异常实现 graph 暂停 / 控制流跳转,并非业务错误。
|
|
19
|
+
# 非流式 ainvoke 命中 interrupt() 时会抛出 GraphInterrupt(Exception 子类),
|
|
20
|
+
# 不能当 AGENT_RUN_ERROR 返回 500,而应作为「已暂停等待人工」的正常响应。
|
|
21
|
+
_INTERRUPT_EXCEPTION_NAMES = frozenset({
|
|
22
|
+
"GraphInterrupt",
|
|
23
|
+
"NodeInterrupt",
|
|
24
|
+
"ParentCommand",
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _is_interrupt_exception(err: BaseException) -> bool:
|
|
29
|
+
"""按类继承链(MRO)匹配 LangGraph 控制流异常名。"""
|
|
30
|
+
for cls in type(err).__mro__:
|
|
31
|
+
if cls.__name__ in _INTERRUPT_EXCEPTION_NAMES:
|
|
32
|
+
return True
|
|
33
|
+
return False
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _json_safe(value: Any) -> Any:
|
|
37
|
+
"""确保对象可被 json 序列化,不行就退化为 default=str。"""
|
|
38
|
+
try:
|
|
39
|
+
json.dumps(value)
|
|
40
|
+
return value
|
|
41
|
+
except (TypeError, ValueError):
|
|
42
|
+
try:
|
|
43
|
+
return json.loads(json.dumps(value, default=str))
|
|
44
|
+
except Exception:
|
|
45
|
+
return str(value)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _serialize_interrupt(err: BaseException) -> Any:
|
|
49
|
+
"""尽力从 GraphInterrupt 中提取 interrupt payload 透传给前端。
|
|
50
|
+
|
|
51
|
+
GraphInterrupt 通常把一组 Interrupt 对象放在 ``err.args[0]``,每个对象有
|
|
52
|
+
``value`` / ``id``(或 ``interrupt_id``)字段。提取失败时退化为 str。
|
|
53
|
+
"""
|
|
54
|
+
try:
|
|
55
|
+
args = getattr(err, "args", None) or ()
|
|
56
|
+
if len(args) == 1 and isinstance(args[0], (list, tuple)):
|
|
57
|
+
items = list(args[0])
|
|
58
|
+
elif args:
|
|
59
|
+
items = list(args)
|
|
60
|
+
else:
|
|
61
|
+
return None
|
|
62
|
+
out = []
|
|
63
|
+
for it in items:
|
|
64
|
+
value = getattr(it, "value", it)
|
|
65
|
+
ident = getattr(it, "id", None) or getattr(it, "interrupt_id", None)
|
|
66
|
+
entry: dict[str, Any] = {"value": _json_safe(value)}
|
|
67
|
+
if ident is not None:
|
|
68
|
+
entry["id"] = _json_safe(ident)
|
|
69
|
+
out.append(entry)
|
|
70
|
+
return out
|
|
71
|
+
except Exception:
|
|
72
|
+
return str(err)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# --- Observability tracer (lazy, no-op if unavailable) ---
|
|
76
|
+
def _get_obs_tracer():
|
|
77
|
+
"""Get the agent tracer (or None if observability not active)."""
|
|
78
|
+
try:
|
|
79
|
+
from _platform._observability import get_tracer
|
|
80
|
+
return get_tracer()
|
|
81
|
+
except (ImportError, Exception):
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass
|
|
86
|
+
class RouteEntry:
|
|
87
|
+
handler: Callable[[AgentContext], Awaitable[Any]]
|
|
88
|
+
is_index: bool
|
|
89
|
+
module_path: str
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass
|
|
93
|
+
class RuntimeResult:
|
|
94
|
+
"""Runtime → adapter 的统一结果对象。
|
|
95
|
+
|
|
96
|
+
body 可以是:
|
|
97
|
+
- None / str / dict / list 等 → adapter 一次性发送(旧行为)
|
|
98
|
+
- StreamResponse 实例 → adapter 多次 send chunk(流式)
|
|
99
|
+
|
|
100
|
+
流式响应的 status / headers 必须在第一个 chunk 发送之前就确定,
|
|
101
|
+
因为 ASGI 的 http.response.start 只能发一次。所以即使 body 是
|
|
102
|
+
streaming,status/headers 仍然在 RuntimeResult 顶层固定。
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
status: int
|
|
106
|
+
body: Any
|
|
107
|
+
headers: dict
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def is_streaming(self) -> bool:
|
|
111
|
+
return isinstance(self.body, StreamResponse)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class InvokeError(Exception):
|
|
115
|
+
def __init__(self, code: str, message: str = "", status: int = 400):
|
|
116
|
+
self.code = code
|
|
117
|
+
self.message = message or code
|
|
118
|
+
self.status = status
|
|
119
|
+
super().__init__(message)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
MAX_INVOKE_DEPTH = 6
|
|
123
|
+
|
|
124
|
+
# 流式响应内部信号头:与 Node `INTERNAL_STREAM_SIGNAL_HEADER` 保持一致,
|
|
125
|
+
# 让上层(CDN / 网关 / 反代)能用 header 而不是 content-type 来判断当前
|
|
126
|
+
# 响应是否为流式,从而做正确的反缓冲处理。业务自定义 headers 不会覆盖
|
|
127
|
+
# 这个值(除非显式重置)。
|
|
128
|
+
INTERNAL_STREAM_SIGNAL_HEADER = "x-content-type-stream"
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
async def _invoke_handler(handler: Callable, context: AgentContext) -> Any:
|
|
132
|
+
"""调用用户 handler,兼容 coroutine function 和 async generator function。
|
|
133
|
+
|
|
134
|
+
async generator function 调用后返回 async_generator(不是 coroutine),
|
|
135
|
+
不能直接传给 asyncio.create_task / asyncio.wait_for。
|
|
136
|
+
这里统一检测:如果结果是 async generator,直接返回它(让调用方的
|
|
137
|
+
_coerce_to_stream 识别为流式响应);否则 await 原始 coroutine 拿到结果。
|
|
138
|
+
"""
|
|
139
|
+
result = handler(context)
|
|
140
|
+
if inspect.isasyncgen(result):
|
|
141
|
+
return result
|
|
142
|
+
return await result
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class AgentsApi:
|
|
146
|
+
def __init__(
|
|
147
|
+
self,
|
|
148
|
+
registry: dict[str, RouteEntry],
|
|
149
|
+
store_resolver: Callable[[str], Any],
|
|
150
|
+
cancel_fn: Callable[[str], bool] | None = None,
|
|
151
|
+
store_blob: Any = None,
|
|
152
|
+
abort_except_fn: Callable[[str, str], AbortActiveRunResult] | None = None,
|
|
153
|
+
):
|
|
154
|
+
self._registry = registry
|
|
155
|
+
self._store_resolver = store_resolver
|
|
156
|
+
# runtime.request_cancel 的绑定;AgentsApi 不直接持 AgentRuntime 引用
|
|
157
|
+
# 避免循环依赖。业务 agent 通过 ctx.agents.abort_active_run(id) 使用。
|
|
158
|
+
self._cancel_fn = cancel_fn
|
|
159
|
+
# runtime.abort_active_run_except 的绑定,用于注入 child context 的
|
|
160
|
+
# ctx.utils.abortActiveRun,确保 child 调用时也能排除自身 run_id。
|
|
161
|
+
self._abort_except_fn = abort_except_fn
|
|
162
|
+
self._store_blob = store_blob
|
|
163
|
+
|
|
164
|
+
def abort_active_run(self, conversation_id: str) -> bool:
|
|
165
|
+
"""主动取消另一个 conversation 的活跃 run。
|
|
166
|
+
|
|
167
|
+
.. deprecated::
|
|
168
|
+
请改用 ``ctx.utils.abortActiveRun(conversation_id)``,与 Node agent 的
|
|
169
|
+
``ctx.utils.abortActiveRun`` 入口/命名一致,返回结构化
|
|
170
|
+
``AbortActiveRunResult`` 而非 bool。本方法仅为向后兼容保留,未来移除。
|
|
171
|
+
|
|
172
|
+
返回 True 表示确实取消了一个正在运行的任务;False 表示该
|
|
173
|
+
conversation_id 下没有活跃任务(已完成/id 错/未注册)。
|
|
174
|
+
|
|
175
|
+
典型用法(旧)::
|
|
176
|
+
|
|
177
|
+
async def handler(ctx):
|
|
178
|
+
target = ctx.request.body.get("conversation_id")
|
|
179
|
+
ok = ctx.agents.abort_active_run(target)
|
|
180
|
+
return {"status": "aborted" if ok else "idle",
|
|
181
|
+
"conversation_id": target}
|
|
182
|
+
|
|
183
|
+
推荐写法(新,与 Node 一致)::
|
|
184
|
+
|
|
185
|
+
async def handler(ctx):
|
|
186
|
+
target = ctx.request.body.get("conversation_id") or ""
|
|
187
|
+
r = ctx.utils.abortActiveRun(target)
|
|
188
|
+
return {"status": "aborted" if r.aborted else "idle",
|
|
189
|
+
"conversation_id": r.conversation_id,
|
|
190
|
+
"run_id": r.run_id}
|
|
191
|
+
"""
|
|
192
|
+
if not self._cancel_fn or not conversation_id:
|
|
193
|
+
return False
|
|
194
|
+
return self._cancel_fn(conversation_id)
|
|
195
|
+
|
|
196
|
+
async def invoke(
|
|
197
|
+
self,
|
|
198
|
+
route_path: str,
|
|
199
|
+
payload: dict,
|
|
200
|
+
*,
|
|
201
|
+
_context: AgentContext,
|
|
202
|
+
timeout: Optional[float] = None,
|
|
203
|
+
) -> Any:
|
|
204
|
+
# 与 Node `agents.invoke` 对齐:仅禁止「直接调用自身路由」的递归。
|
|
205
|
+
# A→B→A 这种环不在此处拦截,靠 MAX_INVOKE_DEPTH 兜底(避免误伤合理
|
|
206
|
+
# 场景,例如 router agent 反向回调入口路由处理子任务)。
|
|
207
|
+
if route_path == _context._call_chain_self:
|
|
208
|
+
raise InvokeError(
|
|
209
|
+
"AGENT_INVOKE_SELF_RECURSION",
|
|
210
|
+
f"Self recursion is not allowed for route: {route_path}",
|
|
211
|
+
)
|
|
212
|
+
if _context._depth >= MAX_INVOKE_DEPTH:
|
|
213
|
+
raise InvokeError("AGENT_INVOKE_DEPTH_EXCEEDED", f"Max depth {MAX_INVOKE_DEPTH} exceeded")
|
|
214
|
+
remaining = _context._deadline - time.monotonic()
|
|
215
|
+
if remaining <= 0:
|
|
216
|
+
raise InvokeError("AGENT_INVOKE_TIMEOUT_EXHAUSTED", "No remaining timeout budget", status=408)
|
|
217
|
+
entry = self._registry.get(route_path)
|
|
218
|
+
if not entry:
|
|
219
|
+
raise InvokeError("AGENT_INVOKE_NOT_FOUND", f"Route {route_path} not found", status=404)
|
|
220
|
+
|
|
221
|
+
child_timeout = min(remaining, timeout) if timeout else remaining
|
|
222
|
+
child_run_id = str(uuid4())
|
|
223
|
+
# 给 child context 注入排除自身 run_id 的 abortActiveRun 闭包,与 Node
|
|
224
|
+
# 的 `abortActiveRun: (target) => abortActiveRunExcept(target, run_id)`
|
|
225
|
+
# 行为一致。
|
|
226
|
+
if self._abort_except_fn is not None:
|
|
227
|
+
_child_abort_fn = lambda target, _rid=child_run_id: self._abort_except_fn( # type: ignore[misc]
|
|
228
|
+
target, _rid
|
|
229
|
+
)
|
|
230
|
+
else:
|
|
231
|
+
_child_abort_fn = None
|
|
232
|
+
child_context = AgentContext(
|
|
233
|
+
conversation_id=_context.conversation_id,
|
|
234
|
+
run_id=child_run_id,
|
|
235
|
+
parent_run_id=_context.run_id,
|
|
236
|
+
request=RequestInfo(
|
|
237
|
+
body=payload,
|
|
238
|
+
headers=_context.request.headers,
|
|
239
|
+
query=_context.request.query,
|
|
240
|
+
signal=_context.request.signal,
|
|
241
|
+
),
|
|
242
|
+
env=_context.env,
|
|
243
|
+
kv=self._store_resolver(route_path),
|
|
244
|
+
agents=self,
|
|
245
|
+
# active_run_id 不向 child 传递:child 是「当前 run 内部派生的子调用」,
|
|
246
|
+
# 沿用 parent 的 active_run_id 反而会让业务误以为有冲突。
|
|
247
|
+
active_run_id=None,
|
|
248
|
+
_store_blob=self._store_blob,
|
|
249
|
+
_deadline=time.monotonic() + child_timeout,
|
|
250
|
+
_depth=_context._depth + 1,
|
|
251
|
+
_call_chain=_context._call_chain | {route_path},
|
|
252
|
+
_call_chain_self=route_path,
|
|
253
|
+
_abort_active_run_fn=_child_abort_fn,
|
|
254
|
+
_loop=_context._loop,
|
|
255
|
+
)
|
|
256
|
+
return await asyncio.wait_for(_invoke_handler(entry.handler, child_context), timeout=child_timeout)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _log(msg: str) -> None:
|
|
260
|
+
print(msg, file=sys.stderr, flush=True)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _is_sandbox_error(error: Exception) -> bool:
|
|
264
|
+
code = str(getattr(error, "code", "") or "")
|
|
265
|
+
operation = str(getattr(error, "operation", "") or "")
|
|
266
|
+
name = error.__class__.__name__
|
|
267
|
+
return isinstance(error, SandboxRuntimeError) or code.startswith("SANDBOX_") or name == "SandboxNotAvailableError" or operation in {"acquire", "update"}
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _sandbox_error_payload(error: Exception) -> dict:
|
|
271
|
+
payload = {
|
|
272
|
+
"code": getattr(error, "code", None),
|
|
273
|
+
"operation": getattr(error, "operation", None),
|
|
274
|
+
"status": getattr(error, "status", None),
|
|
275
|
+
"businessCode": getattr(error, "business_code", getattr(error, "businessCode", None)),
|
|
276
|
+
"businessMessage": getattr(error, "business_message", getattr(error, "businessMessage", None)),
|
|
277
|
+
"requestId": getattr(error, "request_id", getattr(error, "requestId", None)),
|
|
278
|
+
"retryable": getattr(error, "retryable", None),
|
|
279
|
+
"details": _sanitize_sandbox_details(getattr(error, "details", None)),
|
|
280
|
+
}
|
|
281
|
+
return {k: v for k, v in payload.items() if v is not None and v != ""}
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _sanitize_sandbox_details(details):
|
|
285
|
+
if not isinstance(details, dict):
|
|
286
|
+
return None
|
|
287
|
+
blocked = {"sandboxToken", "trafficToken", "envdAccessToken", "authToken", "token", "access_token"}
|
|
288
|
+
return {key: value for key, value in details.items() if key not in blocked}
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _resolve_sandbox_api_env(env: dict, configured_api_env: str = "") -> str | None:
|
|
292
|
+
for value in (
|
|
293
|
+
env.get("SANDBOX_API_ENV"),
|
|
294
|
+
configured_api_env,
|
|
295
|
+
env.get("API_ENV"),
|
|
296
|
+
):
|
|
297
|
+
if isinstance(value, str) and value.strip():
|
|
298
|
+
return value.strip()
|
|
299
|
+
return None
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _sandbox_diagnostics(path: str, headers: dict, env: dict, conversation_id_source: str) -> dict:
|
|
303
|
+
try:
|
|
304
|
+
from .runtime_config import AGENT_MODE, SANDBOX_API_ENV
|
|
305
|
+
except Exception:
|
|
306
|
+
AGENT_MODE = "prod"
|
|
307
|
+
SANDBOX_API_ENV = ""
|
|
308
|
+
return {
|
|
309
|
+
"route_path": path,
|
|
310
|
+
"mode": AGENT_MODE or "prod",
|
|
311
|
+
"expected_conversation_id_header": "makers-conversation-id",
|
|
312
|
+
"conversation_id_source": conversation_id_source,
|
|
313
|
+
"has_makers_conversation_id_header": bool(_header(headers, "makers-conversation-id")),
|
|
314
|
+
"has_edgeone_pages_api_token": bool(env.get("EDGEONE_PAGES_API_TOKEN")),
|
|
315
|
+
"has_project_id": bool(env.get("PROJECT_ID") or env.get("EDGEONE_PROJECT_ID") or env.get("ProjectId") or env.get("PAGES_PROJECT_ID")),
|
|
316
|
+
"has_deployment_id": bool(env.get("DEPLOYMENT_ID") or env.get("EDGEONE_DEPLOYMENT_ID") or env.get("PAGES_DEPLOYMENT_ID")),
|
|
317
|
+
"api_env": _resolve_sandbox_api_env(env, SANDBOX_API_ENV),
|
|
318
|
+
"sandbox_site": env.get("SANDBOX_SITE") or env.get("EDGEONE_PAGES_API_REGION") or None,
|
|
319
|
+
"sandbox_region": env.get("SANDBOX_REGION") or env.get("TENCENTCLOUD_REGION") or None,
|
|
320
|
+
"has_sandbox_api_base": bool(env.get("SANDBOX_API_BASE")),
|
|
321
|
+
"has_sandbox_domain": bool(env.get("SANDBOX_DOMAIN")),
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _sandbox_suggestion(error: Exception, diagnostics: dict) -> str:
|
|
326
|
+
code = str(getattr(error, "code", "") or "")
|
|
327
|
+
if code == "SANDBOX_AUTH_TOKEN_MISSING":
|
|
328
|
+
if diagnostics.get("mode") == "dev":
|
|
329
|
+
return "Set EDGEONE_PAGES_API_TOKEN in .env and restart edgeone dev."
|
|
330
|
+
return "Ensure the deployment pipeline runs `edgeone pages replace` so PAGES_SANDBOX_SEALED_TOKEN is injected before publishing."
|
|
331
|
+
if code == "SANDBOX_INVALID_AUTH_TOKEN":
|
|
332
|
+
return "Use a Pages-generated sandbox.v1.* sealed token in production; do not inject a raw API token as the sealed token."
|
|
333
|
+
if code == "SANDBOX_PROJECT_ID_MISSING":
|
|
334
|
+
return "Set PROJECT_ID or EDGEONE_PROJECT_ID for local API-token mode, or pass project_id when initializing the sandbox SDK."
|
|
335
|
+
if code == "SANDBOX_CONVERSATION_ID_MISSING" or diagnostics.get("conversation_id_source") == "generated":
|
|
336
|
+
return "Forward the makers-conversation-id request header to keep sandbox lifecycle and cache stable across requests."
|
|
337
|
+
if code in {"SANDBOX_API_ENV_INVALID", "SANDBOX_SITE_INVALID", "SANDBOX_API_BASE_INVALID"}:
|
|
338
|
+
return "Check SANDBOX_API_BASE, SANDBOX_API_ENV/API_ENV, and SANDBOX_SITE/EDGEONE_PAGES_API_REGION. API env must be test, production, or pre."
|
|
339
|
+
if code in {"SANDBOX_INSTANCE_EXPIRED", "SANDBOX_INSTANCE_NOT_FOUND"}:
|
|
340
|
+
return "Retry the request. The toolkit has invalidated the cached sandbox instance; increase sandbox timeout if this happens frequently."
|
|
341
|
+
return "Check sandbox diagnostics and runtime logs. If the error includes requestId, provide it to sandbox backend support."
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
class AgentRuntime:
|
|
345
|
+
def __init__(
|
|
346
|
+
self,
|
|
347
|
+
registry: dict[str, RouteEntry],
|
|
348
|
+
store_resolver: Callable[[str], Any],
|
|
349
|
+
timeout: int = 300,
|
|
350
|
+
store_blob: Any = None,
|
|
351
|
+
failed_routes: Optional[dict[str, str]] = None,
|
|
352
|
+
):
|
|
353
|
+
self._registry = registry
|
|
354
|
+
self._store_resolver = store_resolver
|
|
355
|
+
self._timeout = timeout
|
|
356
|
+
self._store_blob = store_blob
|
|
357
|
+
# 加载失败的路由信息:route_path → 失败原因。404 响应时回显,
|
|
358
|
+
# 帮助用户快速发现"路由文件存在但因依赖缺失/语法错被跳过"的情况。
|
|
359
|
+
self._failed_routes: dict[str, str] = failed_routes or {}
|
|
360
|
+
self._active_runs: dict[str, str] = {} # conversation_id → run_id
|
|
361
|
+
self._active_tasks: dict[str, asyncio.Task] = {}
|
|
362
|
+
self._active_signals: dict[str, asyncio.Event] = {}
|
|
363
|
+
# AgentsApi 初始化时注入 runtime.request_cancel 的绑定方法引用;
|
|
364
|
+
# 构造完后才能绑,否则 bound-method 指向的是未完全初始化的 self。
|
|
365
|
+
self._agents_api = AgentsApi(
|
|
366
|
+
registry,
|
|
367
|
+
store_resolver,
|
|
368
|
+
cancel_fn=self.request_cancel,
|
|
369
|
+
store_blob=store_blob,
|
|
370
|
+
abort_except_fn=self.abort_active_run_except,
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
async def handle(
|
|
374
|
+
self,
|
|
375
|
+
path: str,
|
|
376
|
+
method: str,
|
|
377
|
+
headers: dict,
|
|
378
|
+
query: dict,
|
|
379
|
+
body: dict,
|
|
380
|
+
) -> RuntimeResult:
|
|
381
|
+
# Route match — user handler takes priority
|
|
382
|
+
entry = self._registry.get(path)
|
|
383
|
+
|
|
384
|
+
# Fallback: runtime built-in health (only if no user handler matched)
|
|
385
|
+
if not entry and path == "/health":
|
|
386
|
+
return RuntimeResult(200, {"status": "ok", "route_count": len(self._registry)}, {})
|
|
387
|
+
|
|
388
|
+
# Fallback: runtime built-in stop (only if no user handler matched)
|
|
389
|
+
if not entry and path.endswith("/stop"):
|
|
390
|
+
return await self._handle_stop(path, body, headers)
|
|
391
|
+
|
|
392
|
+
if not entry:
|
|
393
|
+
# 优先报告"路由文件存在但加载失败"的情况,避免用户面对空 404 摸不着头脑。
|
|
394
|
+
failure_reason = self._failed_routes.get(path)
|
|
395
|
+
if failure_reason:
|
|
396
|
+
_log(f"Agent error: {path} code=AGENT_ROUTE_LOAD_FAILED reason={failure_reason}")
|
|
397
|
+
return RuntimeResult(503, {
|
|
398
|
+
"code": "AGENT_ROUTE_LOAD_FAILED",
|
|
399
|
+
"message": (
|
|
400
|
+
f"Route '{path}' exists but failed to load: {failure_reason}. "
|
|
401
|
+
"Check server startup logs and ensure all required dependencies are installed."
|
|
402
|
+
),
|
|
403
|
+
"path": path,
|
|
404
|
+
"reason": failure_reason,
|
|
405
|
+
}, {})
|
|
406
|
+
return RuntimeResult(404, {"code": "AGENT_NOT_FOUND", "message": f"No handler for {path}"}, {})
|
|
407
|
+
|
|
408
|
+
# Parse IDs
|
|
409
|
+
# 与 Node runtime 对齐:当前请求自身的 conversation_id 只从 header 读,
|
|
410
|
+
# **不再回退** ``body.conversation_id`` —— 否则 /stop 这种请求自然会把
|
|
411
|
+
# body 里的「目标 conversation_id」当成自己的 conversation,进而覆盖
|
|
412
|
+
# active_signals 里被取消方的注册,导致 ctx.utils.abortActiveRun 失效。
|
|
413
|
+
# (body 里的 conversation_id 应该由业务 handler 自行解析作为参数。)
|
|
414
|
+
header_conversation_id = _header(headers, "makers-conversation-id") or _header(headers, "conversation-id")
|
|
415
|
+
if header_conversation_id:
|
|
416
|
+
conversation_id = header_conversation_id
|
|
417
|
+
conversation_id_source = "makers-conversation-id"
|
|
418
|
+
else:
|
|
419
|
+
conversation_id = str(uuid4())
|
|
420
|
+
conversation_id_source = "generated"
|
|
421
|
+
run_id = str(uuid4())
|
|
422
|
+
start_time = time.monotonic()
|
|
423
|
+
|
|
424
|
+
_log(f"Agent request: {method} {path} conversation={conversation_id} run={run_id}")
|
|
425
|
+
|
|
426
|
+
# [业务自管并发]不自动拦截,通过 ctx.active_run_id 暴露同 conversation 的活跃状态
|
|
427
|
+
existing_active_run_id = self._active_runs.get(conversation_id)
|
|
428
|
+
|
|
429
|
+
# Register active run (first-wins: 与 Node `if (isTopLevel && !existing_active_run)` 对齐)
|
|
430
|
+
cancel_event = asyncio.Event()
|
|
431
|
+
if entry.is_index and not existing_active_run_id:
|
|
432
|
+
self._active_runs[conversation_id] = run_id
|
|
433
|
+
self._active_signals[conversation_id] = cancel_event
|
|
434
|
+
elif not entry.is_index:
|
|
435
|
+
# 非 index 路由(如 /stop)不覆盖 _active_signals,避免把正在运行的
|
|
436
|
+
# index handler 的 cancel signal 覆盖掉,导致 abortActiveRun 失效。
|
|
437
|
+
# 非 index 路由自身的取消通过独立的 cancel_event 管理(不注册到全局表)。
|
|
438
|
+
pass
|
|
439
|
+
|
|
440
|
+
response_headers = {
|
|
441
|
+
"makers-conversation-id": conversation_id,
|
|
442
|
+
"makers-run-id": run_id,
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
# Build context
|
|
446
|
+
context = AgentContext(
|
|
447
|
+
conversation_id=conversation_id,
|
|
448
|
+
run_id=run_id,
|
|
449
|
+
parent_run_id=None,
|
|
450
|
+
request=RequestInfo(body=body, headers=headers, query=query, signal=cancel_event),
|
|
451
|
+
env=dict(os.environ),
|
|
452
|
+
kv=self._store_resolver(path),
|
|
453
|
+
agents=self._agents_api,
|
|
454
|
+
active_run_id=existing_active_run_id,
|
|
455
|
+
_store_blob=self._store_blob,
|
|
456
|
+
_tracer=_get_obs_tracer(),
|
|
457
|
+
_deadline=time.monotonic() + self._timeout,
|
|
458
|
+
_depth=0,
|
|
459
|
+
_call_chain=set(),
|
|
460
|
+
_call_chain_self=path,
|
|
461
|
+
# 与 Node `abortActiveRun: (target) => abortActiveRunExcept(target, run_id)`
|
|
462
|
+
# 行为一致:top-level handler 调 ctx.utils.abortActiveRun 时排除自己。
|
|
463
|
+
_abort_active_run_fn=lambda target, _rid=run_id: self.abort_active_run_except(
|
|
464
|
+
target, _rid
|
|
465
|
+
),
|
|
466
|
+
_loop=asyncio.get_running_loop(),
|
|
467
|
+
)
|
|
468
|
+
|
|
469
|
+
# Execute with timeout
|
|
470
|
+
task = asyncio.create_task(_invoke_handler(entry.handler, context))
|
|
471
|
+
# 只有占据了槽位的 run 才注册 task(与 first-wins 一致)
|
|
472
|
+
if entry.is_index and not existing_active_run_id:
|
|
473
|
+
self._active_tasks[conversation_id] = task
|
|
474
|
+
|
|
475
|
+
# 流式延后清理标记:handler 返回 streaming 时,由 adapter 在 chunk
|
|
476
|
+
# 全部 send 完后调用 _release_run;finally 看到这个标记会跳过 pop。
|
|
477
|
+
# 用 dict 而不是简单变量以便在 finally 闭包里改写(Python 闭包对
|
|
478
|
+
# 不可变变量是 read-only 的,dict / list 之类的容器就没问题)。
|
|
479
|
+
_streaming_started = {"v": False}
|
|
480
|
+
|
|
481
|
+
try:
|
|
482
|
+
result = await asyncio.wait_for(task, timeout=self._timeout)
|
|
483
|
+
duration_ms = int((time.monotonic() - start_time) * 1000)
|
|
484
|
+
|
|
485
|
+
# 流式分支:handler 返回 async generator 或显式 StreamResponse。
|
|
486
|
+
# 这里只完成「响应头部决议」(status / headers / content-type),
|
|
487
|
+
# 真正的 chunk 迭代交给 adapter 在 ASGI 层做:
|
|
488
|
+
# - adapter 已 send http.response.start 后才开始 async for;
|
|
489
|
+
# - 中途异常无法再改 status,只能 logger 记录 + 关闭连接。
|
|
490
|
+
# 同时把 conversation_id / cancel_event 注入到 RuntimeResult,让
|
|
491
|
+
# adapter 在 streaming 时能 finally 清理 active_runs(普通响应
|
|
492
|
+
# 是在 finally 里 pop 的,流式我们 pop 时机要延后到 chunk 流完)。
|
|
493
|
+
stream_resp = self._coerce_to_stream(result)
|
|
494
|
+
if stream_resp is not None:
|
|
495
|
+
_streaming_started["v"] = True
|
|
496
|
+
_log(f"Agent response (streaming): {path} status={stream_resp.status} headers_decided={duration_ms}ms")
|
|
497
|
+
stream_headers = dict(response_headers)
|
|
498
|
+
if stream_resp.headers:
|
|
499
|
+
# 业务自定义 headers 覆盖默认(除了 conversation/run id 这两个
|
|
500
|
+
# 是诊断用的,业务也允许覆盖 —— 给最大灵活度)。
|
|
501
|
+
for k, v in stream_resp.headers.items():
|
|
502
|
+
stream_headers[k.lower()] = v
|
|
503
|
+
if stream_resp.content_type:
|
|
504
|
+
stream_headers["content-type"] = stream_resp.content_type
|
|
505
|
+
# 加上流式信号头,对齐 Node `INTERNAL_STREAM_SIGNAL_HEADER`:
|
|
506
|
+
# 业务自定义 headers 没显式覆盖时,runtime 强制注入;上游
|
|
507
|
+
# 网关/反代可凭此头识别流式响应做反缓冲处理。
|
|
508
|
+
stream_headers.setdefault(INTERNAL_STREAM_SIGNAL_HEADER, "true")
|
|
509
|
+
# 不在这里 pop active_runs —— 让 adapter 在 chunk 流结束后调
|
|
510
|
+
# release_active_run。流式期间仍需要保持 active_run 注册,
|
|
511
|
+
# 这样 /stop 才能拿到 cancel_event 把它打开。
|
|
512
|
+
streaming_result = RuntimeResult(
|
|
513
|
+
status=stream_resp.status,
|
|
514
|
+
body=stream_resp,
|
|
515
|
+
headers=stream_headers,
|
|
516
|
+
)
|
|
517
|
+
# 标记一下,告诉 adapter 流结束后要清理。
|
|
518
|
+
streaming_result._defer_cleanup = True # type: ignore[attr-defined]
|
|
519
|
+
streaming_result._cleanup = lambda cid=conversation_id: self._release_run(cid) # type: ignore[attr-defined]
|
|
520
|
+
# 把 cancel_event 传给 adapter,让 /stop 和 abort_active_run
|
|
521
|
+
# 也能中断流式迭代(不仅仅是 client disconnect)。
|
|
522
|
+
streaming_result._cancel_signal = cancel_event # type: ignore[attr-defined]
|
|
523
|
+
return streaming_result
|
|
524
|
+
|
|
525
|
+
runtime_result = self._serialize_result(result, response_headers)
|
|
526
|
+
_log(f"Agent response: {path} status={runtime_result.status} duration={duration_ms}ms")
|
|
527
|
+
return runtime_result
|
|
528
|
+
|
|
529
|
+
except asyncio.TimeoutError:
|
|
530
|
+
duration_ms = int((time.monotonic() - start_time) * 1000)
|
|
531
|
+
# 与 Node runtime 一致:top-level timeout 用 error 级别(用户可感知),
|
|
532
|
+
# invoke 子调用超时只算 warn(父级 handler 可以捕获 InvokeError 自处理)。
|
|
533
|
+
# Python runtime 的 handle 入口只会进入 top-level,invoke 子调用的
|
|
534
|
+
# AGENT_INVOKE_TIMEOUT_EXHAUSTED 是另一条路径。
|
|
535
|
+
timeout_scope = "top_level"
|
|
536
|
+
timeout_context = {
|
|
537
|
+
"timeout_scope": timeout_scope,
|
|
538
|
+
"route_path": path,
|
|
539
|
+
"parent_run_id": None,
|
|
540
|
+
"conversation_id": conversation_id,
|
|
541
|
+
"run_id": run_id,
|
|
542
|
+
"timeout_ms": self._timeout * 1000,
|
|
543
|
+
"duration_ms": duration_ms,
|
|
544
|
+
}
|
|
545
|
+
_log(f"Agent runtime timeout: {timeout_context}")
|
|
546
|
+
return RuntimeResult(408, {
|
|
547
|
+
"code": "AGENT_RUN_TIMEOUT",
|
|
548
|
+
"message": f"Agent run timed out after {self._timeout}s",
|
|
549
|
+
"conversation_id": conversation_id,
|
|
550
|
+
"run_id": run_id,
|
|
551
|
+
"timeout_ms": self._timeout * 1000,
|
|
552
|
+
"timeout_scope": timeout_scope,
|
|
553
|
+
"route_path": path,
|
|
554
|
+
"parent_run_id": None,
|
|
555
|
+
}, response_headers)
|
|
556
|
+
|
|
557
|
+
except asyncio.CancelledError:
|
|
558
|
+
_log(f"Agent error: {path} code=AGENT_RUN_CANCELLED conversation={conversation_id} run={run_id}")
|
|
559
|
+
return RuntimeResult(499, {
|
|
560
|
+
"code": "AGENT_RUN_CANCELLED",
|
|
561
|
+
"message": "Agent run was cancelled",
|
|
562
|
+
"conversation_id": conversation_id,
|
|
563
|
+
"run_id": run_id,
|
|
564
|
+
}, response_headers)
|
|
565
|
+
|
|
566
|
+
except InvokeError as e:
|
|
567
|
+
_log(f"Agent error: {path} code={e.code} conversation={conversation_id} run={run_id}")
|
|
568
|
+
return RuntimeResult(e.status, {
|
|
569
|
+
"code": e.code,
|
|
570
|
+
"message": e.message,
|
|
571
|
+
"conversation_id": conversation_id,
|
|
572
|
+
"run_id": run_id,
|
|
573
|
+
}, response_headers)
|
|
574
|
+
|
|
575
|
+
except Exception as e:
|
|
576
|
+
if _is_interrupt_exception(e):
|
|
577
|
+
# LangGraph interrupt():graph 暂停等待人工输入,属正常控制流,
|
|
578
|
+
# 不是错误。不返回 500,回一个「已暂停」的 200,并透传 interrupt
|
|
579
|
+
# payload 供前端处理(与流式 __interrupt__ 事件等价)。
|
|
580
|
+
_log(f"Agent paused (interrupt): {path} conversation={conversation_id} run={run_id}")
|
|
581
|
+
return RuntimeResult(200, {
|
|
582
|
+
"code": "AGENT_RUN_INTERRUPTED",
|
|
583
|
+
"message": "Agent run paused awaiting human input",
|
|
584
|
+
"conversation_id": conversation_id,
|
|
585
|
+
"run_id": run_id,
|
|
586
|
+
"interrupt": _serialize_interrupt(e),
|
|
587
|
+
}, response_headers)
|
|
588
|
+
if _is_sandbox_error(e):
|
|
589
|
+
diagnostics = _sandbox_diagnostics(path, headers, context.env, conversation_id_source)
|
|
590
|
+
_log(f"Agent error: {path} code=AGENT_SANDBOX_ERROR conversation={conversation_id} run={run_id} error={e}")
|
|
591
|
+
return RuntimeResult(500, {
|
|
592
|
+
"code": "AGENT_SANDBOX_ERROR",
|
|
593
|
+
"message": str(e),
|
|
594
|
+
"conversation_id": conversation_id,
|
|
595
|
+
"run_id": run_id,
|
|
596
|
+
"sandbox": _sandbox_error_payload(e),
|
|
597
|
+
"diagnostics": diagnostics,
|
|
598
|
+
"suggestion": _sandbox_suggestion(e, diagnostics),
|
|
599
|
+
}, response_headers)
|
|
600
|
+
_log(f"Agent error: {path} code=AGENT_RUN_ERROR conversation={conversation_id} run={run_id} error={e}")
|
|
601
|
+
return RuntimeResult(500, {
|
|
602
|
+
"code": "AGENT_RUN_ERROR",
|
|
603
|
+
"message": str(e),
|
|
604
|
+
"conversation_id": conversation_id,
|
|
605
|
+
"run_id": run_id,
|
|
606
|
+
}, response_headers)
|
|
607
|
+
|
|
608
|
+
finally:
|
|
609
|
+
# 流式响应 finally 不能直接 pop —— streaming chunk 还在迭代中,
|
|
610
|
+
# active_signals[conv_id] 必须保留让 /stop 能取消。延后到 adapter
|
|
611
|
+
# 调用 _release_run。普通(非流式)响应仍走立即清理。
|
|
612
|
+
#
|
|
613
|
+
# 注意:这个 if 的判断要看实际流向 ——
|
|
614
|
+
# 1. 抛出异常的分支(TimeoutError/CancelledError/InvokeError/Exception)
|
|
615
|
+
# 都已 return RuntimeResult 而不是 raise,所以会走进 finally;
|
|
616
|
+
# 它们都不是 streaming,应该立即清理。
|
|
617
|
+
# 2. 正常 return 的 streaming 分支带 _defer_cleanup 标记,跳过。
|
|
618
|
+
#
|
|
619
|
+
# 我们只能拿到 finally 里 closure 的状态,没有 result 的上下文,
|
|
620
|
+
# 所以用一个 nonlocal-like 的 _streaming_started 闭包变量来传递。
|
|
621
|
+
if not _streaming_started["v"]:
|
|
622
|
+
# 与 Node `releaseActiveRunSlot(conversation_id, run_id)` 对齐:
|
|
623
|
+
# 只有占据了槽位的 run(run_id 匹配)才清理,避免并发请求误删。
|
|
624
|
+
if entry.is_index and self._active_runs.get(conversation_id) == run_id:
|
|
625
|
+
self._active_runs.pop(conversation_id, None)
|
|
626
|
+
self._active_tasks.pop(conversation_id, None)
|
|
627
|
+
self._active_signals.pop(conversation_id, None)
|
|
628
|
+
|
|
629
|
+
async def _handle_stop(self, path: str, body: dict, headers: dict) -> RuntimeResult:
|
|
630
|
+
conversation_id = (
|
|
631
|
+
body.get("conversation_id")
|
|
632
|
+
or _header(headers, "conversation-id")
|
|
633
|
+
or _header(headers, "makers-conversation-id")
|
|
634
|
+
or ""
|
|
635
|
+
)
|
|
636
|
+
if not conversation_id:
|
|
637
|
+
return RuntimeResult(400, {"code": "MISSING_CONVERSATION_ID", "message": "conversation_id is required for stop"}, {})
|
|
638
|
+
|
|
639
|
+
# 复用 request_cancel 的判定逻辑:判定 active 看 signal 不看 task,
|
|
640
|
+
# 因为流式响应的 handler task 在 yield generator 后立即 done,但流
|
|
641
|
+
# 仍在迭代,signal 仍然有效;这种状态下 /stop 也应该能取消。
|
|
642
|
+
signal = self._active_signals.get(conversation_id)
|
|
643
|
+
if signal is not None:
|
|
644
|
+
run_id = self._active_runs.get(conversation_id, "")
|
|
645
|
+
# 标准取消序列:set signal → 业务侧 is_cancelled=True;task.cancel
|
|
646
|
+
# 是非流式场景的兜底(在 await 点抛 CancelledError)。
|
|
647
|
+
task = self._active_tasks.get(conversation_id)
|
|
648
|
+
if not signal.is_set():
|
|
649
|
+
signal.set()
|
|
650
|
+
if task is not None and not task.done():
|
|
651
|
+
task.cancel()
|
|
652
|
+
return RuntimeResult(200, {
|
|
653
|
+
"status": "aborting",
|
|
654
|
+
"conversation_id": conversation_id,
|
|
655
|
+
"run_id": run_id,
|
|
656
|
+
}, {})
|
|
657
|
+
|
|
658
|
+
return RuntimeResult(200, {
|
|
659
|
+
"status": "idle",
|
|
660
|
+
"conversation_id": conversation_id,
|
|
661
|
+
}, {})
|
|
662
|
+
|
|
663
|
+
def request_cancel(self, conversation_id: str) -> bool:
|
|
664
|
+
"""主动发起取消(非 HTTP 路径),供 adapter 在 http.disconnect 等
|
|
665
|
+
情形下调用。
|
|
666
|
+
|
|
667
|
+
行为和 _handle_stop 等价:
|
|
668
|
+
1. 先 set cancel_event → 业务 handler 下次检查 is_cancelled
|
|
669
|
+
可走优雅返回路径(保存上下文、收尾等);
|
|
670
|
+
2. 再 task.cancel() 作为兜底 → 下一次 await 会抛 CancelledError。
|
|
671
|
+
|
|
672
|
+
返回 True 表示确实取消了一个活跃 conversation,False 表示该
|
|
673
|
+
conversation_id 下没有任何活跃状态(可能已完成、已取消、或 id 错)。
|
|
674
|
+
|
|
675
|
+
流式响应特殊情况:流式 handler 在第一次 await 时立即 return generator
|
|
676
|
+
对象,所以 task 早就 done 了,但 active_signals[conv_id] 仍存在
|
|
677
|
+
(chunk 还在被 adapter 迭代)。这种情况只能 set signal,业务侧靠
|
|
678
|
+
is_cancelled 检测;task.cancel() 已经无效(task done 后 cancel 是
|
|
679
|
+
no-op)。所以判定「是否活跃」要看 signal 而不是 task。
|
|
680
|
+
"""
|
|
681
|
+
if not conversation_id:
|
|
682
|
+
return False
|
|
683
|
+
signal = self._active_signals.get(conversation_id)
|
|
684
|
+
task = self._active_tasks.get(conversation_id)
|
|
685
|
+
# 没有 signal 就意味着 conversation 整个生命周期都结束了(被 finally
|
|
686
|
+
# 或 _release_run 清理过),无法再取消。
|
|
687
|
+
if signal is None:
|
|
688
|
+
return False
|
|
689
|
+
if signal.is_set():
|
|
690
|
+
# 已经被 set 过了,不重复 cancel;返回 True 表达「确实是个活跃 run」。
|
|
691
|
+
return True
|
|
692
|
+
signal.set()
|
|
693
|
+
# task 可能已经 done(流式场景),cancel 是 no-op,不会抛;不过 None
|
|
694
|
+
# 检查保险一点。
|
|
695
|
+
if task is not None and not task.done():
|
|
696
|
+
task.cancel()
|
|
697
|
+
return True
|
|
698
|
+
|
|
699
|
+
def abort_active_run_except(
|
|
700
|
+
self,
|
|
701
|
+
conversation_id: str,
|
|
702
|
+
exclude_run_id: str,
|
|
703
|
+
) -> AbortActiveRunResult:
|
|
704
|
+
"""与 Node ``abortActiveRunExcept`` 对齐:取消目标 conversation 的活跃
|
|
705
|
+
run,但**不取消** ``exclude_run_id`` 自己。
|
|
706
|
+
|
|
707
|
+
作为 ``ctx.utils.abortActiveRun`` 的底层实现:每次构造 context 时都
|
|
708
|
+
会绑定当前 run_id 作为 exclude_run_id,确保业务里调
|
|
709
|
+
``ctx.utils.abortActiveRun(self_conv_id)`` 不会误把自己取消。
|
|
710
|
+
|
|
711
|
+
Returns:
|
|
712
|
+
``AbortActiveRunResult``:
|
|
713
|
+
- ``aborted=True`` + ``conversation_id`` + ``run_id``:成功触发取消;
|
|
714
|
+
- ``aborted=False``:目标 conversation 没有活跃 run,或活跃 run
|
|
715
|
+
就是当前调用方自己(不允许自取消)。
|
|
716
|
+
|
|
717
|
+
实现复用 ``request_cancel``:判定活跃看 signal 而非 task(流式场景下
|
|
718
|
+
handler task 早就 done 了)。
|
|
719
|
+
"""
|
|
720
|
+
if not conversation_id:
|
|
721
|
+
return AbortActiveRunResult(aborted=False)
|
|
722
|
+
|
|
723
|
+
active_run_id = self._active_runs.get(conversation_id)
|
|
724
|
+
# 即便不是 index 路由(active_runs 可能没记,但 active_signals 记了),
|
|
725
|
+
# 我们也允许通过 conversation_id 取消;判定活跃看 signal。
|
|
726
|
+
signal = self._active_signals.get(conversation_id)
|
|
727
|
+
if signal is None:
|
|
728
|
+
return AbortActiveRunResult(aborted=False)
|
|
729
|
+
|
|
730
|
+
# 排除自身:只有当 active_run_id 明确等于 exclude_run_id 时才跳过;
|
|
731
|
+
# 非 index 路由下 active_run_id 可能为 None,此时仍可被取消。
|
|
732
|
+
if active_run_id is not None and active_run_id == exclude_run_id:
|
|
733
|
+
return AbortActiveRunResult(aborted=False)
|
|
734
|
+
|
|
735
|
+
ok = self.request_cancel(conversation_id)
|
|
736
|
+
if not ok:
|
|
737
|
+
return AbortActiveRunResult(aborted=False)
|
|
738
|
+
return AbortActiveRunResult(
|
|
739
|
+
aborted=True,
|
|
740
|
+
conversation_id=conversation_id,
|
|
741
|
+
run_id=active_run_id,
|
|
742
|
+
)
|
|
743
|
+
|
|
744
|
+
def _serialize_result(self, result: Any, headers: dict) -> RuntimeResult:
|
|
745
|
+
if result is None:
|
|
746
|
+
return RuntimeResult(204, None, headers)
|
|
747
|
+
if isinstance(result, str):
|
|
748
|
+
headers["content-type"] = "text/plain"
|
|
749
|
+
return RuntimeResult(200, result, headers)
|
|
750
|
+
if isinstance(result, dict):
|
|
751
|
+
# 信封格式:{"status_code": N, "body": ..., "headers": {...}}
|
|
752
|
+
# 仅当 status_code 是合法 HTTP 状态码(100-599)时才触发,
|
|
753
|
+
# 避免误判普通业务 dict 里恰好有 status_code 字段。
|
|
754
|
+
envelope_status = result.get("status_code")
|
|
755
|
+
if (
|
|
756
|
+
isinstance(envelope_status, int)
|
|
757
|
+
and 100 <= envelope_status <= 599
|
|
758
|
+
and "body" in result
|
|
759
|
+
):
|
|
760
|
+
body = result["body"]
|
|
761
|
+
status = envelope_status
|
|
762
|
+
extra_headers = result.get("headers")
|
|
763
|
+
if isinstance(extra_headers, dict):
|
|
764
|
+
for k, v in extra_headers.items():
|
|
765
|
+
headers[k.lower()] = str(v)
|
|
766
|
+
if body is None:
|
|
767
|
+
return RuntimeResult(status, None, headers)
|
|
768
|
+
if isinstance(body, str):
|
|
769
|
+
headers.setdefault("content-type", "text/plain")
|
|
770
|
+
return RuntimeResult(status, body, headers)
|
|
771
|
+
try:
|
|
772
|
+
json.dumps(body)
|
|
773
|
+
return RuntimeResult(status, body, headers)
|
|
774
|
+
except (TypeError, ValueError):
|
|
775
|
+
headers.setdefault("content-type", "text/plain")
|
|
776
|
+
return RuntimeResult(status, str(body), headers)
|
|
777
|
+
# 普通 dict
|
|
778
|
+
try:
|
|
779
|
+
json.dumps(result)
|
|
780
|
+
except (TypeError, ValueError):
|
|
781
|
+
headers["content-type"] = "text/plain"
|
|
782
|
+
return RuntimeResult(200, str(result), headers)
|
|
783
|
+
return RuntimeResult(200, result, headers)
|
|
784
|
+
# For other types, try to make them JSON-serializable
|
|
785
|
+
try:
|
|
786
|
+
json.dumps(result) # test if serializable
|
|
787
|
+
return RuntimeResult(200, result, headers)
|
|
788
|
+
except (TypeError, ValueError):
|
|
789
|
+
headers["content-type"] = "text/plain"
|
|
790
|
+
return RuntimeResult(200, str(result), headers)
|
|
791
|
+
|
|
792
|
+
def _coerce_to_stream(self, result: Any) -> Optional[StreamResponse]:
|
|
793
|
+
"""识别 handler 返回值是否为流式响应,并归一成 StreamResponse。
|
|
794
|
+
|
|
795
|
+
三种合法输入:
|
|
796
|
+
1. StreamResponse 实例:原样返回(已有自定义 status/headers)。
|
|
797
|
+
2. async generator (`async def gen(): yield ...`):包装成默认
|
|
798
|
+
200 + content-type 自动推断的 StreamResponse。
|
|
799
|
+
3. async iterator(实现了 __aiter__):同 2 处理。
|
|
800
|
+
|
|
801
|
+
其他普通对象(dict/str/None/list)返回 None,由 _serialize_result
|
|
802
|
+
走旧的一次性序列化路径。
|
|
803
|
+
|
|
804
|
+
注意 list / tuple 不算流式 —— Python 的 list 既可被 iter() 遍历也
|
|
805
|
+
被 inspect.isasyncgen 识别为 False,但和 async iterator 是不同语义;
|
|
806
|
+
list 视为「一次性数组响应」更符合用户预期。
|
|
807
|
+
"""
|
|
808
|
+
if result is None:
|
|
809
|
+
return None
|
|
810
|
+
if isinstance(result, StreamResponse):
|
|
811
|
+
return result
|
|
812
|
+
# 直接是 async generator
|
|
813
|
+
if inspect.isasyncgen(result):
|
|
814
|
+
return StreamResponse(body=result)
|
|
815
|
+
# 实现了 __aiter__ 的对象(自定义 async iterator)
|
|
816
|
+
if hasattr(result, "__aiter__") and not isinstance(
|
|
817
|
+
result, (dict, list, tuple, str, bytes, bytearray)
|
|
818
|
+
):
|
|
819
|
+
return StreamResponse(body=result)
|
|
820
|
+
return None
|
|
821
|
+
|
|
822
|
+
def _release_run(self, conversation_id: str) -> None:
|
|
823
|
+
"""供 adapter 在流式响应 chunk 全部 send 完后调用,清理 active_*。
|
|
824
|
+
|
|
825
|
+
分离出来是因为流式响应的生命周期跨越 runtime.handle 返回之后,
|
|
826
|
+
finally 块那时候已经走完了,不能在那里 pop。
|
|
827
|
+
"""
|
|
828
|
+
self._active_runs.pop(conversation_id, None)
|
|
829
|
+
self._active_tasks.pop(conversation_id, None)
|
|
830
|
+
self._active_signals.pop(conversation_id, None)
|
|
831
|
+
|
|
832
|
+
|
|
833
|
+
def _header(headers: dict, name: str) -> Optional[str]:
|
|
834
|
+
"""Case-insensitive header lookup."""
|
|
835
|
+
name_lower = name.lower()
|
|
836
|
+
for k, v in headers.items():
|
|
837
|
+
if k.lower() == name_lower:
|
|
838
|
+
return v if isinstance(v, str) else v.decode("utf-8") if isinstance(v, bytes) else str(v)
|
|
839
|
+
return None
|