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.
- package/README.md +26 -26
- package/edgeone-bin/edgeone.js +3 -3
- package/edgeone-dist/cli.js +86879 -2307
- 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 +86521 -2090
- 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,908 @@
|
|
|
1
|
+
# src/pages/builder/templates/agent-python/adapter.py
|
|
2
|
+
"""ASGI entry point — uvicorn runs this as the application."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import hashlib
|
|
7
|
+
import importlib
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
import sys
|
|
12
|
+
from typing import Any, Callable
|
|
13
|
+
from urllib.parse import parse_qs
|
|
14
|
+
import httpx
|
|
15
|
+
|
|
16
|
+
# --- Observability bootstrap (must run before user code imports) ---
|
|
17
|
+
# Reads entry names from AGENT_OBSERVABILITY_ENTRIES env var (set by main.py
|
|
18
|
+
# or the dev runner). Idempotent — safe to call multiple times.
|
|
19
|
+
_obs_entries_raw = os.environ.get("AGENT_OBSERVABILITY_ENTRIES", "")
|
|
20
|
+
if _obs_entries_raw:
|
|
21
|
+
try:
|
|
22
|
+
from _platform._observability import setup as _obs_setup
|
|
23
|
+
_obs_setup(json.loads(_obs_entries_raw))
|
|
24
|
+
except Exception as _obs_err:
|
|
25
|
+
print(f"[observability] setup failed: {_obs_err}", file=sys.stderr, flush=True)
|
|
26
|
+
|
|
27
|
+
from .store import InMemoryStore, BlobBackedStore
|
|
28
|
+
from .runtime import AgentRuntime, RouteEntry
|
|
29
|
+
from .context import StreamResponse
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# --- Platform response headers (align with Node agent + python-function) ---
|
|
33
|
+
|
|
34
|
+
def _build_platform_headers(request_headers: dict, status_code: int) -> list[tuple[bytes, bytes]]:
|
|
35
|
+
"""Build platform response headers matching Node agent behavior.
|
|
36
|
+
|
|
37
|
+
Injects:
|
|
38
|
+
- Functions-Request-Id: from incoming x-scf-request-id header (or _LAMBDA_RUNTIME_REQUEST_ID env)
|
|
39
|
+
- eo-pages-inner-scf-status: actual response status code
|
|
40
|
+
- eo-pages-inner-status-intercept: 'true' if status >= 500 else 'false'
|
|
41
|
+
|
|
42
|
+
For agent-python (long-running ASGI), the request-id comes from the
|
|
43
|
+
incoming request header x-scf-request-id (same as Node agent), not from
|
|
44
|
+
_LAMBDA_RUNTIME_REQUEST_ID env var (which is per-invocation in SCF).
|
|
45
|
+
We check both for compatibility.
|
|
46
|
+
"""
|
|
47
|
+
request_id = (
|
|
48
|
+
request_headers.get("x-scf-request-id", "")
|
|
49
|
+
or os.environ.get("_LAMBDA_RUNTIME_REQUEST_ID", "")
|
|
50
|
+
)
|
|
51
|
+
result: list[tuple[bytes, bytes]] = []
|
|
52
|
+
if request_id:
|
|
53
|
+
result.append((b"functions-request-id", request_id.encode("latin1")))
|
|
54
|
+
result.append((b"eo-pages-inner-scf-status", str(status_code).encode("latin1")))
|
|
55
|
+
result.append((
|
|
56
|
+
b"eo-pages-inner-status-intercept",
|
|
57
|
+
b"true" if status_code >= 500 else b"false",
|
|
58
|
+
))
|
|
59
|
+
return result
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# LangGraph 通过抛出这些异常实现 graph 暂停 / 控制流跳转,并非业务错误。
|
|
63
|
+
# interrupt() 命中时会抛出 GraphInterrupt(Exception 子类),冒泡出 handler
|
|
64
|
+
# generator 后会落进流式迭代的 except 分支——这属于「正常暂停等待人工」,
|
|
65
|
+
# 不能当 streaming error 报错或把 root span 标 ERROR。
|
|
66
|
+
_INTERRUPT_EXCEPTION_NAMES = frozenset({
|
|
67
|
+
"GraphInterrupt",
|
|
68
|
+
"NodeInterrupt",
|
|
69
|
+
"ParentCommand",
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _is_interrupt_exception(err: BaseException) -> bool:
|
|
74
|
+
"""按类继承链(MRO)匹配 LangGraph 控制流异常名。
|
|
75
|
+
|
|
76
|
+
走 MRO 是为了让 GraphInterrupt 的子类也能识别,同时足够严格、
|
|
77
|
+
不会误伤名字里恰好含 "interrupt" 的业务异常。
|
|
78
|
+
"""
|
|
79
|
+
for cls in type(err).__mro__:
|
|
80
|
+
if cls.__name__ in _INTERRUPT_EXCEPTION_NAMES:
|
|
81
|
+
return True
|
|
82
|
+
return False
|
|
83
|
+
|
|
84
|
+
# --- Proxy transport for credential requests ---
|
|
85
|
+
|
|
86
|
+
_PROXY_UUID = "{{PAGES_PROXY_UUID}}"
|
|
87
|
+
_PROXY_HOST = "{{PAGES_PROXY_HOST}}"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _is_proxy_enabled() -> bool:
|
|
91
|
+
"""Check if proxy placeholders have been replaced with real values."""
|
|
92
|
+
return (
|
|
93
|
+
not _PROXY_UUID.startswith("{{")
|
|
94
|
+
and not _PROXY_HOST.startswith("{{")
|
|
95
|
+
and bool(_PROXY_UUID)
|
|
96
|
+
and bool(_PROXY_HOST)
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class _ProxyTransport(httpx.AsyncBaseTransport):
|
|
101
|
+
"""Rewrites requests to go through the EdgeOne proxy with signing headers.
|
|
102
|
+
|
|
103
|
+
Signature algorithm matches Node fetch-proxy.js:
|
|
104
|
+
sign = md5(timestamp + '-' + pathname + '-' + uuid)
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
def __init__(self) -> None:
|
|
108
|
+
self._inner = httpx.AsyncHTTPTransport()
|
|
109
|
+
|
|
110
|
+
async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
|
|
111
|
+
original_url = request.url
|
|
112
|
+
pathname = original_url.raw_path.decode("ascii").split("?")[0]
|
|
113
|
+
timestamp = str(int(__import__("time").time() * 1000))
|
|
114
|
+
sign = hashlib.md5(
|
|
115
|
+
f"{timestamp}-{pathname}-{_PROXY_UUID}".encode()
|
|
116
|
+
).hexdigest()
|
|
117
|
+
|
|
118
|
+
# Rewrite URL to proxy host, preserving path and query
|
|
119
|
+
proxy_url = original_url.copy_with(
|
|
120
|
+
host=_PROXY_HOST.replace("https://", "").replace("http://", ""),
|
|
121
|
+
scheme="https",
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
# Add signing headers
|
|
125
|
+
request.headers["oe-host"] = str(original_url.host)
|
|
126
|
+
request.headers["oe-timestamp"] = timestamp
|
|
127
|
+
request.headers["oe-sign"] = sign
|
|
128
|
+
|
|
129
|
+
# Build new request targeting proxy
|
|
130
|
+
proxy_request = httpx.Request(
|
|
131
|
+
method=request.method,
|
|
132
|
+
url=proxy_url,
|
|
133
|
+
headers=request.headers,
|
|
134
|
+
content=request.content,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
return await self._inner.handle_async_request(proxy_request)
|
|
138
|
+
|
|
139
|
+
async def aclose(self) -> None:
|
|
140
|
+
await self._inner.aclose()
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _build_proxy_client() -> httpx.AsyncClient | None:
|
|
144
|
+
"""Build an httpx.AsyncClient with proxy transport, or None if proxy is not configured."""
|
|
145
|
+
if not _is_proxy_enabled():
|
|
146
|
+
return None
|
|
147
|
+
return httpx.AsyncClient(transport=_ProxyTransport(), timeout=30.0)
|
|
148
|
+
|
|
149
|
+
# --- Cold start ---
|
|
150
|
+
try:
|
|
151
|
+
_route_table_raw = json.loads(os.environ.get("AGENT_ROUTE_TABLE", "{}"))
|
|
152
|
+
except (json.JSONDecodeError, ValueError):
|
|
153
|
+
print("[agent-python] ERROR: Invalid AGENT_ROUTE_TABLE, using empty", file=sys.stderr, flush=True)
|
|
154
|
+
_route_table_raw = {}
|
|
155
|
+
|
|
156
|
+
try:
|
|
157
|
+
_timeout = int(os.environ.get("AGENT_TIMEOUT", "300"))
|
|
158
|
+
except ValueError:
|
|
159
|
+
print("[agent-python] WARNING: Invalid AGENT_TIMEOUT, using 300", file=sys.stderr, flush=True)
|
|
160
|
+
_timeout = 300
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
# --- Dev mode: inject credential from environment variable ---
|
|
164
|
+
# When user runs `edgeone pages link`, CLI injects PAGES_BLOB_DEPLOY_CREDENTIAL
|
|
165
|
+
# into the Python process env. If present and valid (not placeholder), inject it
|
|
166
|
+
# into pages-blob SDK so it can connect to COS without build-time replacement.
|
|
167
|
+
_dev_credential = os.environ.get("PAGES_BLOB_DEPLOY_CREDENTIAL", "")
|
|
168
|
+
if _dev_credential and not (_dev_credential.startswith("{{") and _dev_credential.endswith("}}")):
|
|
169
|
+
try:
|
|
170
|
+
from pages_blob import set_environment_context
|
|
171
|
+
from pages_blob.types import PagesBlobContext
|
|
172
|
+
set_environment_context(PagesBlobContext(deploy_credential=_dev_credential))
|
|
173
|
+
except ImportError:
|
|
174
|
+
pass
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
# --- Store resolver (matches Node agent-node.ts pattern) ---
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _to_agent_store_name(route_path: str) -> str:
|
|
181
|
+
"""Convert route path to blob store name. Mirrors Node toAgentStoreName()."""
|
|
182
|
+
normalized = re.sub(r"^/+|/+$", "", str(route_path or ""))
|
|
183
|
+
parts = [p for p in normalized.split("/") if p]
|
|
184
|
+
joined = "-".join(parts)
|
|
185
|
+
joined = re.sub(r"[^a-zA-Z0-9_-]+", "-", joined)
|
|
186
|
+
joined = re.sub(r"-+", "-", joined)
|
|
187
|
+
joined = re.sub(r"^[-_]+|[-_]+$", "", joined)
|
|
188
|
+
joined = joined.lower()
|
|
189
|
+
suffix = joined or "index"
|
|
190
|
+
return "agent-" + suffix
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _create_store_resolver() -> Callable[[str], Any]:
|
|
194
|
+
"""Build store resolver: blob-backed in production, in-memory for dev."""
|
|
195
|
+
cache: dict[str, Any] = {}
|
|
196
|
+
|
|
197
|
+
# 0. Local persist mode(dev 专用):PAGES_BLOB_LOCAL_PERSIST=1 时
|
|
198
|
+
# 绕开 pages-blob SDK,用本地文件做持久化后端。
|
|
199
|
+
# 细节见 local_blob_store.py 顶部注释。
|
|
200
|
+
from .local_blob_store import LocalFileBlobStore, get_local_base_dir
|
|
201
|
+
_local_base = get_local_base_dir()
|
|
202
|
+
if _local_base is not None:
|
|
203
|
+
print(
|
|
204
|
+
f"[agent-python] Local persist mode: storing blobs under {_local_base}",
|
|
205
|
+
file=sys.stderr, flush=True,
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
# Try pages-blob
|
|
209
|
+
_get_store = None
|
|
210
|
+
try:
|
|
211
|
+
from pages_blob import get_store as _get_store_fn
|
|
212
|
+
_get_store = _get_store_fn
|
|
213
|
+
print("[agent-python] pages-blob available, using BlobBackedStore", file=sys.stderr, flush=True)
|
|
214
|
+
except Exception as _blob_import_err:
|
|
215
|
+
print(f"[agent-python] pages-blob not available: {type(_blob_import_err).__name__}: {_blob_import_err}", file=sys.stderr, flush=True)
|
|
216
|
+
print("[agent-python] using InMemoryStore", file=sys.stderr, flush=True)
|
|
217
|
+
|
|
218
|
+
# Build proxy client for credential requests (only in prod when placeholders are replaced)
|
|
219
|
+
_proxy_client = _build_proxy_client()
|
|
220
|
+
if _proxy_client is not None:
|
|
221
|
+
print("[agent-python] Proxy enabled for credential requests", file=sys.stderr, flush=True)
|
|
222
|
+
else:
|
|
223
|
+
print("[agent-python] Proxy not configured, using direct connections", file=sys.stderr, flush=True)
|
|
224
|
+
|
|
225
|
+
def resolver(route_path: str) -> Any:
|
|
226
|
+
cache_key = str(route_path or "/")
|
|
227
|
+
if cache_key in cache:
|
|
228
|
+
return cache[cache_key]
|
|
229
|
+
|
|
230
|
+
store_name = _to_agent_store_name(cache_key)
|
|
231
|
+
|
|
232
|
+
# Local persist 优先——和 blob 走一样的 BlobBackedStore 包装(envelope/TTL
|
|
233
|
+
# 语义完全一致),只是底层换成文件。
|
|
234
|
+
if _local_base is not None:
|
|
235
|
+
local_blob = LocalFileBlobStore(store_name, _local_base)
|
|
236
|
+
store = BlobBackedStore(local_blob)
|
|
237
|
+
cache[cache_key] = store
|
|
238
|
+
return store
|
|
239
|
+
|
|
240
|
+
if _get_store is not None:
|
|
241
|
+
try:
|
|
242
|
+
_project_id = os.environ.get("PAGES_PROJECT_ID", "") or None
|
|
243
|
+
blob_store = _get_store(store_name, project_id=_project_id, token=_dev_credential or None, consistency="strong", http_client=_proxy_client) if (_dev_credential and _project_id) else _get_store(store_name, consistency="strong", http_client=_proxy_client)
|
|
244
|
+
store = BlobBackedStore(blob_store)
|
|
245
|
+
except Exception as e:
|
|
246
|
+
print(f"[agent-python] WARNING: Failed to create blob store for {cache_key}: {e}, falling back to InMemoryStore", file=sys.stderr, flush=True)
|
|
247
|
+
store = InMemoryStore()
|
|
248
|
+
else:
|
|
249
|
+
store = InMemoryStore()
|
|
250
|
+
|
|
251
|
+
cache[cache_key] = store
|
|
252
|
+
return store
|
|
253
|
+
|
|
254
|
+
return resolver
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
# --- Store blob (dedicated namespace for ctx.store) ---
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _get_store_blob_name() -> str:
|
|
261
|
+
"""Compute the store blob namespace name.
|
|
262
|
+
|
|
263
|
+
prod: memory-{projectId}
|
|
264
|
+
dev: memory-{projectId}-dev
|
|
265
|
+
Missing projectId: dev → memory-local-dev, prod → memory-agent (with warning).
|
|
266
|
+
|
|
267
|
+
projectId resolution order (mirrors Node getMemoryProjectId):
|
|
268
|
+
0. BUILDER_PROJECT_ID from runtime_config (build-time injected)
|
|
269
|
+
1. PAGES_PROJECT_ID env var
|
|
270
|
+
2. EDGEONE_PROJECT_ID env var
|
|
271
|
+
3. ProjectId env var (injected by CLI dev server)
|
|
272
|
+
"""
|
|
273
|
+
# Build-time injected project ID (aligns with agent-node.ts BUILDER_PROJECT_ID)
|
|
274
|
+
_builder_project_id = ""
|
|
275
|
+
try:
|
|
276
|
+
from _platform.runtime_config import BUILDER_PROJECT_ID as _bpid
|
|
277
|
+
_builder_project_id = _bpid
|
|
278
|
+
except (ImportError, AttributeError, Exception):
|
|
279
|
+
pass
|
|
280
|
+
|
|
281
|
+
project_id = (
|
|
282
|
+
_builder_project_id
|
|
283
|
+
or os.environ.get("PAGES_PROJECT_ID", "").strip()
|
|
284
|
+
or os.environ.get("EDGEONE_PROJECT_ID", "").strip()
|
|
285
|
+
or os.environ.get("ProjectId", "").strip()
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
# Determine mode from runtime_config or AGENT_MODE env
|
|
289
|
+
try:
|
|
290
|
+
from _platform.runtime_config import AGENT_MODE
|
|
291
|
+
is_dev = AGENT_MODE == "dev"
|
|
292
|
+
except (ImportError, Exception):
|
|
293
|
+
env_val = os.environ.get("PAGES_ENV", "").strip().lower()
|
|
294
|
+
is_dev = env_val in ("dev", "development", "local", "")
|
|
295
|
+
|
|
296
|
+
if project_id:
|
|
297
|
+
if is_dev:
|
|
298
|
+
return f"memory-{project_id}-dev"
|
|
299
|
+
return f"memory-{project_id}"
|
|
300
|
+
else:
|
|
301
|
+
if is_dev:
|
|
302
|
+
return "memory-local-dev"
|
|
303
|
+
print(
|
|
304
|
+
"[agent-python] WARNING: PAGES_PROJECT_ID not set in production, "
|
|
305
|
+
"using fallback store name 'memory-agent'",
|
|
306
|
+
file=sys.stderr, flush=True,
|
|
307
|
+
)
|
|
308
|
+
return "memory-agent"
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
class _InMemoryBlobAdapter:
|
|
312
|
+
"""In-memory adapter matching raw blob store duck-type interface.
|
|
313
|
+
|
|
314
|
+
Used as conversation store fallback when pages-blob is unavailable.
|
|
315
|
+
Implements: get(key, type=), set(key, value), delete(key), list(prefix=).
|
|
316
|
+
"""
|
|
317
|
+
|
|
318
|
+
def __init__(self) -> None:
|
|
319
|
+
self._data: dict[str, str] = {}
|
|
320
|
+
|
|
321
|
+
async def get(self, key: str, *, type: str = "text", **_kwargs) -> Any:
|
|
322
|
+
value = self._data.get(key)
|
|
323
|
+
if value is None:
|
|
324
|
+
return None
|
|
325
|
+
if type == "json":
|
|
326
|
+
return json.loads(value)
|
|
327
|
+
return value
|
|
328
|
+
|
|
329
|
+
async def set(self, key: str, value: Any, **_kwargs) -> None:
|
|
330
|
+
if isinstance(value, str):
|
|
331
|
+
self._data[key] = value
|
|
332
|
+
elif isinstance(value, (bytes, bytearray)):
|
|
333
|
+
self._data[key] = bytes(value).decode("utf-8")
|
|
334
|
+
else:
|
|
335
|
+
self._data[key] = json.dumps(value, ensure_ascii=False)
|
|
336
|
+
|
|
337
|
+
async def delete(self, key: str) -> None:
|
|
338
|
+
self._data.pop(key, None)
|
|
339
|
+
|
|
340
|
+
async def list(self, *, prefix: str | None = None, **_kwargs) -> Any:
|
|
341
|
+
from dataclasses import dataclass, field as _field
|
|
342
|
+
|
|
343
|
+
@dataclass
|
|
344
|
+
class _BlobInfo:
|
|
345
|
+
key: str
|
|
346
|
+
|
|
347
|
+
@dataclass
|
|
348
|
+
class _ListResult:
|
|
349
|
+
blobs: list = _field(default_factory=list)
|
|
350
|
+
|
|
351
|
+
keys = sorted(k for k in self._data if prefix is None or k.startswith(prefix))
|
|
352
|
+
return _ListResult(blobs=[_BlobInfo(key=k) for k in keys])
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _create_store_blob() -> Any:
|
|
356
|
+
"""Create the dedicated blob store for ctx.store.
|
|
357
|
+
|
|
358
|
+
Returns the raw blob store instance (not wrapped in BlobBackedStore —
|
|
359
|
+
ConversationMemory manages its own data format).
|
|
360
|
+
"""
|
|
361
|
+
store_name = _get_store_blob_name()
|
|
362
|
+
|
|
363
|
+
from .local_blob_store import LocalFileBlobStore, get_local_base_dir
|
|
364
|
+
local_base = get_local_base_dir()
|
|
365
|
+
if local_base is not None:
|
|
366
|
+
print(
|
|
367
|
+
f"[agent-python] Conversation store: local persist → {local_base / store_name}",
|
|
368
|
+
file=sys.stderr, flush=True,
|
|
369
|
+
)
|
|
370
|
+
return LocalFileBlobStore(store_name, local_base)
|
|
371
|
+
|
|
372
|
+
try:
|
|
373
|
+
from pages_blob import get_store as _get_store_fn
|
|
374
|
+
proxy_client = _build_proxy_client()
|
|
375
|
+
_mem_project_id = os.environ.get("PAGES_PROJECT_ID", "") or None
|
|
376
|
+
_mem_token = os.environ.get("PAGES_BLOB_DEPLOY_CREDENTIAL", "") or None
|
|
377
|
+
if _mem_token and _mem_project_id:
|
|
378
|
+
blob_store = _get_store_fn(store_name, project_id=_mem_project_id, token=_mem_token, consistency="strong", http_client=proxy_client)
|
|
379
|
+
else:
|
|
380
|
+
blob_store = _get_store_fn(store_name, consistency="strong", http_client=proxy_client)
|
|
381
|
+
print(
|
|
382
|
+
f"[agent-python] Conversation store: blob-backed → {store_name}",
|
|
383
|
+
file=sys.stderr, flush=True,
|
|
384
|
+
)
|
|
385
|
+
return blob_store
|
|
386
|
+
except ImportError:
|
|
387
|
+
print(
|
|
388
|
+
"[agent-python] Conversation store: in-memory (data will be lost on restart)\n"
|
|
389
|
+
"[agent-python] → To persist locally: set PAGES_BLOB_LOCAL_PERSIST=1 in .env\n"
|
|
390
|
+
"[agent-python] → To use cloud storage: run `edgeone pages link`",
|
|
391
|
+
file=sys.stderr, flush=True,
|
|
392
|
+
)
|
|
393
|
+
return _InMemoryBlobAdapter()
|
|
394
|
+
except Exception as e:
|
|
395
|
+
print(
|
|
396
|
+
f"[agent-python] WARNING: Failed to create conversation store: {e}\n"
|
|
397
|
+
"[agent-python] Conversation store: in-memory (data will be lost on restart)\n"
|
|
398
|
+
"[agent-python] → To persist locally: set PAGES_BLOB_LOCAL_PERSIST=1 in .env\n"
|
|
399
|
+
"[agent-python] → To use cloud storage: run `edgeone pages link`",
|
|
400
|
+
file=sys.stderr, flush=True,
|
|
401
|
+
)
|
|
402
|
+
return _InMemoryBlobAdapter()
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
_store_resolver = _create_store_resolver()
|
|
406
|
+
_store_blob = _create_store_blob()
|
|
407
|
+
_registry: dict[str, RouteEntry] = {}
|
|
408
|
+
# 加载失败的路由:route_path → 失败原因。404 响应时回显,避免静默 404 难以排查。
|
|
409
|
+
_failed_routes: dict[str, str] = {}
|
|
410
|
+
|
|
411
|
+
for route_path, info in _route_table_raw.items():
|
|
412
|
+
module_path = info["module"]
|
|
413
|
+
is_index = info.get("isIndex", False)
|
|
414
|
+
try:
|
|
415
|
+
mod = importlib.import_module(module_path)
|
|
416
|
+
handler = getattr(mod, "handler", None)
|
|
417
|
+
if handler is None:
|
|
418
|
+
reason = f"module '{module_path}' has no 'handler' function"
|
|
419
|
+
print(f"[agent-python] WARNING: {reason}, skipping", file=sys.stderr, flush=True)
|
|
420
|
+
_failed_routes[route_path] = reason
|
|
421
|
+
continue
|
|
422
|
+
_registry[route_path] = RouteEntry(handler=handler, is_index=is_index, module_path=module_path)
|
|
423
|
+
except Exception as e:
|
|
424
|
+
reason = f"failed to import '{module_path}': {e}"
|
|
425
|
+
print(f"[agent-python] ERROR loading {module_path}: {e}", file=sys.stderr, flush=True)
|
|
426
|
+
_failed_routes[route_path] = reason
|
|
427
|
+
|
|
428
|
+
_runtime = AgentRuntime(
|
|
429
|
+
registry=_registry,
|
|
430
|
+
store_resolver=_store_resolver,
|
|
431
|
+
timeout=_timeout,
|
|
432
|
+
store_blob=_store_blob,
|
|
433
|
+
failed_routes=_failed_routes,
|
|
434
|
+
)
|
|
435
|
+
|
|
436
|
+
print(f"[agent-python] Loaded {len(_registry)} routes, timeout={_timeout}s", file=sys.stderr, flush=True)
|
|
437
|
+
if _failed_routes:
|
|
438
|
+
print(
|
|
439
|
+
f"[agent-python] WARNING: {len(_failed_routes)} route(s) failed to load: "
|
|
440
|
+
f"{', '.join(_failed_routes.keys())}",
|
|
441
|
+
file=sys.stderr, flush=True,
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
# --- ASGI app ---
|
|
446
|
+
async def app(scope: dict, receive, send) -> None:
|
|
447
|
+
if scope["type"] == "lifespan":
|
|
448
|
+
msg = await receive()
|
|
449
|
+
if msg["type"] == "lifespan.startup":
|
|
450
|
+
await send({"type": "lifespan.startup.complete"})
|
|
451
|
+
msg = await receive()
|
|
452
|
+
if msg["type"] == "lifespan.shutdown":
|
|
453
|
+
# Flush telemetry before process exits
|
|
454
|
+
try:
|
|
455
|
+
from _platform._observability.telemetry import get_telemetry
|
|
456
|
+
_tel = get_telemetry()
|
|
457
|
+
if _tel:
|
|
458
|
+
_tel.shutdown()
|
|
459
|
+
except (ImportError, Exception):
|
|
460
|
+
pass
|
|
461
|
+
await send({"type": "lifespan.shutdown.complete"})
|
|
462
|
+
return
|
|
463
|
+
|
|
464
|
+
if scope["type"] != "http":
|
|
465
|
+
return
|
|
466
|
+
|
|
467
|
+
# Read body
|
|
468
|
+
body_parts = []
|
|
469
|
+
while True:
|
|
470
|
+
msg = await receive()
|
|
471
|
+
body_parts.append(msg.get("body", b""))
|
|
472
|
+
if not msg.get("more_body", False):
|
|
473
|
+
break
|
|
474
|
+
raw_body = b"".join(body_parts)
|
|
475
|
+
|
|
476
|
+
try:
|
|
477
|
+
parsed = json.loads(raw_body) if raw_body else {}
|
|
478
|
+
body = parsed if isinstance(parsed, dict) else {}
|
|
479
|
+
except (json.JSONDecodeError, ValueError) as e:
|
|
480
|
+
# JSON 解析失败直接返回 400,避免静默降级导致路由逻辑误判
|
|
481
|
+
# 注意:此时 headers dict 尚未构建,需要从 scope 提取原始 headers 来获取 request-id
|
|
482
|
+
_early_headers = {
|
|
483
|
+
k.decode("utf-8") if isinstance(k, bytes) else k: v.decode("utf-8") if isinstance(v, bytes) else v
|
|
484
|
+
for k, v in scope.get("headers", [])
|
|
485
|
+
}
|
|
486
|
+
error_body = json.dumps({
|
|
487
|
+
"code": "INVALID_REQUEST_BODY",
|
|
488
|
+
"message": f"Request body is not valid JSON: {e}",
|
|
489
|
+
}).encode("utf-8")
|
|
490
|
+
_err_resp_headers: list[tuple[bytes, bytes]] = [
|
|
491
|
+
(b"content-type", b"application/json"),
|
|
492
|
+
(b"content-length", str(len(error_body)).encode()),
|
|
493
|
+
]
|
|
494
|
+
_err_resp_headers.extend(_build_platform_headers(_early_headers, 400))
|
|
495
|
+
await send({
|
|
496
|
+
"type": "http.response.start",
|
|
497
|
+
"status": 400,
|
|
498
|
+
"headers": _err_resp_headers,
|
|
499
|
+
})
|
|
500
|
+
await send({"type": "http.response.body", "body": error_body})
|
|
501
|
+
return
|
|
502
|
+
|
|
503
|
+
path = scope.get("path", "/")
|
|
504
|
+
method = scope.get("method", "POST")
|
|
505
|
+
# Parse query string: ?foo=bar&x=1 → {"foo": "bar", "x": "1"}
|
|
506
|
+
# 多值参数只取最后一个(和 Node req.query 行为一致);
|
|
507
|
+
# 需要多值的场景业务侧可直接 parse scope["query_string"]。
|
|
508
|
+
raw_qs = scope.get("query_string", b"")
|
|
509
|
+
if isinstance(raw_qs, bytes):
|
|
510
|
+
raw_qs = raw_qs.decode("utf-8", errors="replace")
|
|
511
|
+
query = {k: v[-1] for k, v in parse_qs(raw_qs, keep_blank_values=True).items()} if raw_qs else {}
|
|
512
|
+
headers = {
|
|
513
|
+
k.decode("utf-8") if isinstance(k, bytes) else k: v.decode("utf-8") if isinstance(v, bytes) else v
|
|
514
|
+
for k, v in scope.get("headers", [])
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
# Log Makers request path (matches python-function format)
|
|
518
|
+
# 部署环境下通过 eo-pages-host header 拼接完整 URL
|
|
519
|
+
_pages_full_path = path
|
|
520
|
+
_pages_host = headers.get("eo-pages-host", "")
|
|
521
|
+
if _pages_host:
|
|
522
|
+
_pages_proto = headers.get("x-forwarded-proto", "https")
|
|
523
|
+
_pages_full_path = f"{_pages_proto}://{_pages_host}{path}"
|
|
524
|
+
print(f"Makers request path: {_pages_full_path}", file=sys.stderr, flush=True)
|
|
525
|
+
|
|
526
|
+
# Dispatch to runtime. 同时起一个后台 task 监听 http.disconnect,
|
|
527
|
+
# 客户端主动断开(curl Ctrl+C、浏览器关标签、网关超时踢掉)时:
|
|
528
|
+
# 1. 从 header 解析 conversation_id;
|
|
529
|
+
# 2. 调 runtime.request_cancel(conv_id) —— 业务 handler 下次检查
|
|
530
|
+
# is_cancelled 就能优雅返回,没检查的也会在下一个 await 点抛
|
|
531
|
+
# CancelledError,被 runtime 的 except CancelledError 捕获。
|
|
532
|
+
# 注意:不能简单 `dispatch_task.cancel()`,因为 runtime.handle 本身
|
|
533
|
+
# 也有 finally 清理(active_tasks pop 等),直接 cancel 整个 handle
|
|
534
|
+
# 会让这些清理被 CancelledError 打断、留下幽灵 entry。
|
|
535
|
+
#
|
|
536
|
+
# 流式响应特殊处理:dispatch_task 完成(generator 拿到了)后 watcher 还
|
|
537
|
+
# 必须继续工作,因为真正的耗时在后续 async for 迭代 chunk 阶段;client
|
|
538
|
+
# 在 chunk 流期间断开时,watcher 要能继续触发 cancel。所以 watcher 的
|
|
539
|
+
# 关闭点延后到「流式 chunk 全部发完 / 或非流式响应已 send」之后。
|
|
540
|
+
_conv_id = headers.get("makers-conversation-id") or headers.get("conversation-id") or ""
|
|
541
|
+
|
|
542
|
+
# --- Observability: per-request root span ---
|
|
543
|
+
_obs_root_span = None
|
|
544
|
+
_obs_token = None
|
|
545
|
+
_obs_conv_token = None
|
|
546
|
+
try:
|
|
547
|
+
# 必须在创建 root span 之前写入 conversation_id:AgentContextPropagator
|
|
548
|
+
# 在 span 的 on_start 那一刻读取 ContextVar 来注入 agent.conversation_id。
|
|
549
|
+
# 若放在 start_request_span 之后,root span 的 on_start 已经跑完,会读到
|
|
550
|
+
# None,导致 root span 漏掉 agent.conversation_id(只有子 span 有)。
|
|
551
|
+
try:
|
|
552
|
+
from _platform._observability import set_agent_conversation_id as _obs_set_conv
|
|
553
|
+
_obs_conv_token = _obs_set_conv(_conv_id or None)
|
|
554
|
+
except (ImportError, Exception):
|
|
555
|
+
pass
|
|
556
|
+
from _platform._observability import start_request_span as _obs_start_span
|
|
557
|
+
# agent.conversation_id 与 agent.run_id 由 AgentContextPropagator 在 onStart
|
|
558
|
+
# 时统一注入到所有 span(含 root),这里不再手动放进 attributes 以避免重复。
|
|
559
|
+
_obs_root_span, _obs_ctx = _obs_start_span(
|
|
560
|
+
f"agent.request:{path}",
|
|
561
|
+
{"http.method": method, "http.route": path},
|
|
562
|
+
)
|
|
563
|
+
from opentelemetry.context import attach as _obs_attach
|
|
564
|
+
_obs_token = _obs_attach(_obs_ctx)
|
|
565
|
+
# Make context available to CrewAI's background thread
|
|
566
|
+
try:
|
|
567
|
+
from _platform._observability import set_request_context
|
|
568
|
+
set_request_context(_obs_ctx)
|
|
569
|
+
except (ImportError, Exception):
|
|
570
|
+
pass
|
|
571
|
+
except (ImportError, Exception):
|
|
572
|
+
pass
|
|
573
|
+
|
|
574
|
+
dispatch_task = asyncio.create_task(_runtime.handle(path, method, headers, query, body))
|
|
575
|
+
|
|
576
|
+
# 用 Event 让 watcher 区分「流被业务自己 cancel 了」和「client 真的断开」:
|
|
577
|
+
# 流式 generator 内部 await asyncio.sleep 时如果业务调了 task.cancel,
|
|
578
|
+
# 会抛 CancelledError;但那不是 client 断开,不应该再 print "client
|
|
579
|
+
# disconnected" 误导排查。
|
|
580
|
+
_disconnect_seen = asyncio.Event()
|
|
581
|
+
|
|
582
|
+
async def _watch_disconnect() -> None:
|
|
583
|
+
try:
|
|
584
|
+
while True:
|
|
585
|
+
msg = await receive()
|
|
586
|
+
if msg.get("type") == "http.disconnect":
|
|
587
|
+
_disconnect_seen.set()
|
|
588
|
+
if _conv_id and _runtime.request_cancel(_conv_id):
|
|
589
|
+
print(
|
|
590
|
+
f"[agent-python] Client disconnected, cancelling run conv={_conv_id}",
|
|
591
|
+
file=sys.stderr, flush=True,
|
|
592
|
+
)
|
|
593
|
+
return
|
|
594
|
+
except asyncio.CancelledError:
|
|
595
|
+
# 正常完成时 watcher 会被 adapter finally cancel 掉,吞掉即可。
|
|
596
|
+
return
|
|
597
|
+
|
|
598
|
+
_watcher = asyncio.create_task(_watch_disconnect())
|
|
599
|
+
result = None
|
|
600
|
+
try:
|
|
601
|
+
result = await dispatch_task
|
|
602
|
+
except BaseException:
|
|
603
|
+
# dispatch 本身挂了的话先把 watcher 关了再 raise,避免 watcher 卡 receive。
|
|
604
|
+
_watcher.cancel()
|
|
605
|
+
raise
|
|
606
|
+
|
|
607
|
+
# 注意:这里不再立即 cancel watcher —— 流式分支需要它在 chunk 迭代时继续
|
|
608
|
+
# 监听 disconnect。非流式分支会在下面 send 完后统一 cancel。
|
|
609
|
+
|
|
610
|
+
# Log Makers response status (matches python-function format)
|
|
611
|
+
print(f"Makers response status: {result.status}", file=sys.stderr, flush=True)
|
|
612
|
+
|
|
613
|
+
# Send response
|
|
614
|
+
response_headers = [(k.encode(), str(v).encode()) for k, v in result.headers.items()]
|
|
615
|
+
# Inject platform headers (Functions-Request-Id, eo-pages-inner-scf-status, eo-pages-inner-status-intercept)
|
|
616
|
+
response_headers.extend(_build_platform_headers(headers, result.status))
|
|
617
|
+
|
|
618
|
+
# ───────── 流式分支 ─────────
|
|
619
|
+
# runtime 已决议好 status / headers / content-type,body 是 StreamResponse;
|
|
620
|
+
# 这里只负责把 chunk 一帧帧 send 到 ASGI 通道。
|
|
621
|
+
#
|
|
622
|
+
# 关键点:
|
|
623
|
+
# 1. http.response.start 只能 send 一次(ASGI 协议要求),所以一旦 send
|
|
624
|
+
# 过去就不能再改 status —— generator 中途异常只能 close 连接。
|
|
625
|
+
# 2. 每个 chunk send 时 more_body=True,最后再 send 一个空 body
|
|
626
|
+
# more_body=False 标识结束;不发结尾帧的话 uvicorn 会一直挂连接。
|
|
627
|
+
# 3. async for 期间要监听 watcher 触发的 cancel —— is_cancelled / 任务被
|
|
628
|
+
# cancel 时 generator 自然抛 CancelledError 我们走 except 关流就行;
|
|
629
|
+
# 不要尝试在 stream 里再 send 错误响应(headers 已发出去了)。
|
|
630
|
+
# 4. 最终(无论成功失败)都要调 result._cleanup() 清掉 active_runs,
|
|
631
|
+
# 否则同 conversation_id 会一直被 409 拒绝。
|
|
632
|
+
if result.is_streaming:
|
|
633
|
+
stream: StreamResponse = result.body # type: ignore[assignment]
|
|
634
|
+
await send({
|
|
635
|
+
"type": "http.response.start",
|
|
636
|
+
"status": result.status,
|
|
637
|
+
"headers": response_headers,
|
|
638
|
+
})
|
|
639
|
+
|
|
640
|
+
# 流式响应的取消机制有别于非流式:
|
|
641
|
+
# - 非流式时,handler 本身的 task 在 await 点能被 task.cancel() 打断;
|
|
642
|
+
# - 流式时,handler 调用立刻返回 generator 对象(async generator
|
|
643
|
+
# 函数体并不会进入),handler 的 task 已经 done,再 cancel 它没用。
|
|
644
|
+
# 真正在跑的是 adapter 这里的 `async for chunk in stream.body`。
|
|
645
|
+
# 所以要在 async for 阶段把「下一帧」和「中断事件」做成 race,
|
|
646
|
+
# 谁先到处理谁。中断事件包括:
|
|
647
|
+
# 1. client disconnect(_disconnect_seen)
|
|
648
|
+
# 2. /stop 或 abort_active_run 设置的 cancel signal
|
|
649
|
+
chunks_sent = 0
|
|
650
|
+
agen = stream.body.__aiter__()
|
|
651
|
+
# 获取 runtime 附着的 cancel signal(/stop、abort_active_run 会 set 它)
|
|
652
|
+
_cancel_signal: asyncio.Event | None = getattr(result, "_cancel_signal", None)
|
|
653
|
+
try:
|
|
654
|
+
while True:
|
|
655
|
+
next_chunk_task = asyncio.create_task(agen.__anext__())
|
|
656
|
+
disconnect_task = asyncio.create_task(_disconnect_seen.wait())
|
|
657
|
+
race_set: set[asyncio.Task] = {next_chunk_task, disconnect_task}
|
|
658
|
+
# 如果有 cancel signal,加入 race;这样 /stop 和
|
|
659
|
+
# abort_active_run 也能立即中断流式迭代。
|
|
660
|
+
cancel_signal_task: asyncio.Task | None = None
|
|
661
|
+
if _cancel_signal is not None and not _cancel_signal.is_set():
|
|
662
|
+
cancel_signal_task = asyncio.create_task(_cancel_signal.wait())
|
|
663
|
+
race_set.add(cancel_signal_task)
|
|
664
|
+
|
|
665
|
+
done, pending = await asyncio.wait(
|
|
666
|
+
race_set,
|
|
667
|
+
return_when=asyncio.FIRST_COMPLETED,
|
|
668
|
+
)
|
|
669
|
+
# 取消未完成的那个 —— 注意 next_chunk_task 被 cancel 时,
|
|
670
|
+
# generator 内部 await 点会抛 CancelledError,业务侧的 finally
|
|
671
|
+
# (文件关闭、连接释放等)能正常跑。
|
|
672
|
+
for p in pending:
|
|
673
|
+
p.cancel()
|
|
674
|
+
# 等 cancel 真正生效,避免 task 泄漏告警
|
|
675
|
+
try:
|
|
676
|
+
await p
|
|
677
|
+
except (asyncio.CancelledError, StopAsyncIteration, Exception):
|
|
678
|
+
pass
|
|
679
|
+
|
|
680
|
+
# 检测中断:client disconnect 或 cancel signal
|
|
681
|
+
_interrupted = False
|
|
682
|
+
_interrupt_reason = ""
|
|
683
|
+
if disconnect_task in done:
|
|
684
|
+
_interrupted = True
|
|
685
|
+
_interrupt_reason = "client disconnected"
|
|
686
|
+
elif cancel_signal_task is not None and cancel_signal_task in done:
|
|
687
|
+
_interrupted = True
|
|
688
|
+
_interrupt_reason = "cancel signal received (/stop or abort_active_run)"
|
|
689
|
+
|
|
690
|
+
if _interrupted:
|
|
691
|
+
print(
|
|
692
|
+
f"[agent-python] Stream interrupted ({_interrupt_reason}) after {chunks_sent} chunks for {path}",
|
|
693
|
+
file=sys.stderr, flush=True,
|
|
694
|
+
)
|
|
695
|
+
# 主动 aclose generator,让业务侧 try/finally 能跑收尾。
|
|
696
|
+
# 这会传播 GeneratorExit 到上游(如 LLM streaming),释放连接。
|
|
697
|
+
try:
|
|
698
|
+
await agen.aclose()
|
|
699
|
+
except Exception:
|
|
700
|
+
pass
|
|
701
|
+
break
|
|
702
|
+
|
|
703
|
+
# next_chunk_task 完成:可能是正常 chunk、StopAsyncIteration、或异常。
|
|
704
|
+
try:
|
|
705
|
+
chunk = next_chunk_task.result()
|
|
706
|
+
except StopAsyncIteration:
|
|
707
|
+
break
|
|
708
|
+
except asyncio.CancelledError:
|
|
709
|
+
# 被我们自己 cancel 的(理论上不会走到 —— pending 集合排除了 done);
|
|
710
|
+
# 兜底处理。
|
|
711
|
+
break
|
|
712
|
+
|
|
713
|
+
if chunk is None:
|
|
714
|
+
continue
|
|
715
|
+
payload = _encode_stream_chunk(chunk)
|
|
716
|
+
if not payload:
|
|
717
|
+
continue
|
|
718
|
+
await send({
|
|
719
|
+
"type": "http.response.body",
|
|
720
|
+
"body": payload,
|
|
721
|
+
"more_body": True,
|
|
722
|
+
})
|
|
723
|
+
chunks_sent += 1
|
|
724
|
+
except asyncio.CancelledError:
|
|
725
|
+
# ASGI server 主动 cancel 整个 app coroutine(极少见,比如服务关停)。
|
|
726
|
+
# 已发的 chunk 不会回滚,直接关连接即可。注意要 re-raise 让上层
|
|
727
|
+
# task 状态收敛。
|
|
728
|
+
print(
|
|
729
|
+
f"[agent-python] Streaming cancelled (server shutdown) after {chunks_sent} chunks for {path}",
|
|
730
|
+
file=sys.stderr, flush=True,
|
|
731
|
+
)
|
|
732
|
+
try:
|
|
733
|
+
await send({"type": "http.response.body", "body": b"", "more_body": False})
|
|
734
|
+
except Exception:
|
|
735
|
+
pass
|
|
736
|
+
raise
|
|
737
|
+
except Exception as e:
|
|
738
|
+
if _is_interrupt_exception(e):
|
|
739
|
+
# LangGraph interrupt():graph 暂停等待人工输入,属正常控制流,
|
|
740
|
+
# 不是 streaming error。静默收尾:不打 error 日志、不把 root span
|
|
741
|
+
# 标 ERROR,正常发结尾空帧关闭流。前端通过 __interrupt__ 事件
|
|
742
|
+
# 单独获取暂停草稿。
|
|
743
|
+
print(
|
|
744
|
+
f"[agent-python] Stream paused (interrupt) after {chunks_sent} chunks for {path}",
|
|
745
|
+
file=sys.stderr, flush=True,
|
|
746
|
+
)
|
|
747
|
+
if _obs_root_span:
|
|
748
|
+
try:
|
|
749
|
+
_obs_root_span.set_attribute("agent.interrupt", True)
|
|
750
|
+
except Exception:
|
|
751
|
+
pass
|
|
752
|
+
try:
|
|
753
|
+
await send({"type": "http.response.body", "body": b"", "more_body": False})
|
|
754
|
+
except Exception:
|
|
755
|
+
pass
|
|
756
|
+
else:
|
|
757
|
+
# generator 内部业务异常:headers 已发,没法再改 status,只能记日志
|
|
758
|
+
# 并以空 body 收尾。客户端看到的会是「收到部分数据后流被截断」——
|
|
759
|
+
# 业务侧应该自行在最后一个 chunk 包含「success/error」标识。
|
|
760
|
+
print(
|
|
761
|
+
f"[agent-python] Streaming error after {chunks_sent} chunks for {path}: {e}",
|
|
762
|
+
file=sys.stderr, flush=True,
|
|
763
|
+
)
|
|
764
|
+
if _obs_root_span:
|
|
765
|
+
try:
|
|
766
|
+
from opentelemetry.trace.status import Status as _OStatus, StatusCode as _OSC
|
|
767
|
+
_obs_root_span.set_status(_OStatus(_OSC.ERROR, str(e)))
|
|
768
|
+
except (ImportError, Exception):
|
|
769
|
+
pass
|
|
770
|
+
try:
|
|
771
|
+
await send({"type": "http.response.body", "body": b"", "more_body": False})
|
|
772
|
+
except Exception:
|
|
773
|
+
pass
|
|
774
|
+
else:
|
|
775
|
+
# 正常结束(包括 disconnect 后的优雅 break):发结尾帧 more_body=False。
|
|
776
|
+
try:
|
|
777
|
+
await send({"type": "http.response.body", "body": b"", "more_body": False})
|
|
778
|
+
except Exception:
|
|
779
|
+
# client 已断的话 send 会 raise,吞掉就行。
|
|
780
|
+
pass
|
|
781
|
+
print(
|
|
782
|
+
f"[agent-python] Streaming complete: {path} chunks={chunks_sent}",
|
|
783
|
+
file=sys.stderr, flush=True,
|
|
784
|
+
)
|
|
785
|
+
finally:
|
|
786
|
+
_watcher.cancel()
|
|
787
|
+
cleanup = getattr(result, "_cleanup", None)
|
|
788
|
+
if callable(cleanup):
|
|
789
|
+
try:
|
|
790
|
+
cleanup()
|
|
791
|
+
except Exception as cleanup_err:
|
|
792
|
+
print(
|
|
793
|
+
f"[agent-python] Cleanup error for {path}: {cleanup_err}",
|
|
794
|
+
file=sys.stderr, flush=True,
|
|
795
|
+
)
|
|
796
|
+
# --- Observability: end request root span (streaming) ---
|
|
797
|
+
if _obs_root_span:
|
|
798
|
+
try:
|
|
799
|
+
from _platform._observability import end_request_span as _obs_end_span
|
|
800
|
+
_obs_end_span(_obs_root_span)
|
|
801
|
+
except (ImportError, Exception):
|
|
802
|
+
try:
|
|
803
|
+
_obs_root_span.end()
|
|
804
|
+
except Exception:
|
|
805
|
+
pass
|
|
806
|
+
if _obs_token:
|
|
807
|
+
try:
|
|
808
|
+
from opentelemetry.context import detach as _obs_detach
|
|
809
|
+
_obs_detach(_obs_token)
|
|
810
|
+
except (ImportError, Exception):
|
|
811
|
+
pass
|
|
812
|
+
# Reset agent.conversation_id ContextVar (used by AgentContextPropagator)
|
|
813
|
+
if _obs_conv_token is not None:
|
|
814
|
+
try:
|
|
815
|
+
from _platform._observability import reset_agent_conversation_id as _obs_reset_conv
|
|
816
|
+
_obs_reset_conv(_obs_conv_token)
|
|
817
|
+
except (ImportError, Exception):
|
|
818
|
+
pass
|
|
819
|
+
# Clear CrewAI request context
|
|
820
|
+
try:
|
|
821
|
+
from _platform._observability import clear_request_context
|
|
822
|
+
clear_request_context()
|
|
823
|
+
except (ImportError, Exception):
|
|
824
|
+
pass
|
|
825
|
+
return
|
|
826
|
+
|
|
827
|
+
# ───────── 非流式分支(旧行为,原样保留) ─────────
|
|
828
|
+
_watcher.cancel()
|
|
829
|
+
if result.body is None:
|
|
830
|
+
response_headers.append((b"content-length", b"0"))
|
|
831
|
+
await send({"type": "http.response.start", "status": result.status, "headers": response_headers})
|
|
832
|
+
await send({"type": "http.response.body", "body": b""})
|
|
833
|
+
elif isinstance(result.body, str):
|
|
834
|
+
encoded = result.body.encode("utf-8")
|
|
835
|
+
if not any(k == b"content-type" for k, _ in response_headers):
|
|
836
|
+
response_headers.append((b"content-type", b"text/plain; charset=utf-8"))
|
|
837
|
+
response_headers.append((b"content-length", str(len(encoded)).encode()))
|
|
838
|
+
await send({"type": "http.response.start", "status": result.status, "headers": response_headers})
|
|
839
|
+
await send({"type": "http.response.body", "body": encoded})
|
|
840
|
+
else:
|
|
841
|
+
encoded = json.dumps(result.body, ensure_ascii=False).encode("utf-8")
|
|
842
|
+
response_headers.append((b"content-type", b"application/json; charset=utf-8"))
|
|
843
|
+
response_headers.append((b"content-length", str(len(encoded)).encode()))
|
|
844
|
+
await send({"type": "http.response.start", "status": result.status, "headers": response_headers})
|
|
845
|
+
await send({"type": "http.response.body", "body": encoded})
|
|
846
|
+
|
|
847
|
+
# --- Observability: end request root span ---
|
|
848
|
+
if _obs_root_span:
|
|
849
|
+
try:
|
|
850
|
+
from _platform._observability import end_request_span as _obs_end_span
|
|
851
|
+
if result and result.status >= 400:
|
|
852
|
+
from opentelemetry.trace.status import Status as _OStatus, StatusCode as _OSC
|
|
853
|
+
_obs_root_span.set_status(_OStatus(_OSC.ERROR))
|
|
854
|
+
_obs_end_span(_obs_root_span)
|
|
855
|
+
except (ImportError, Exception):
|
|
856
|
+
try:
|
|
857
|
+
_obs_root_span.end()
|
|
858
|
+
except Exception:
|
|
859
|
+
pass
|
|
860
|
+
if _obs_token:
|
|
861
|
+
try:
|
|
862
|
+
from opentelemetry.context import detach as _obs_detach
|
|
863
|
+
_obs_detach(_obs_token)
|
|
864
|
+
except (ImportError, Exception):
|
|
865
|
+
pass
|
|
866
|
+
# Reset agent.conversation_id ContextVar (used by AgentContextPropagator)
|
|
867
|
+
if _obs_conv_token is not None:
|
|
868
|
+
try:
|
|
869
|
+
from _platform._observability import reset_agent_conversation_id as _obs_reset_conv
|
|
870
|
+
_obs_reset_conv(_obs_conv_token)
|
|
871
|
+
except (ImportError, Exception):
|
|
872
|
+
pass
|
|
873
|
+
# Clear CrewAI request context
|
|
874
|
+
try:
|
|
875
|
+
from _platform._observability import clear_request_context
|
|
876
|
+
clear_request_context()
|
|
877
|
+
except (ImportError, Exception):
|
|
878
|
+
pass
|
|
879
|
+
|
|
880
|
+
|
|
881
|
+
def _encode_stream_chunk(chunk: Any) -> bytes:
|
|
882
|
+
"""把 generator 产出的 chunk 归一成 bytes,方便 ASGI send。
|
|
883
|
+
|
|
884
|
+
支持:
|
|
885
|
+
- bytes / bytearray / memoryview → 原样(zero-copy 转 bytes)
|
|
886
|
+
- str → utf-8 编码
|
|
887
|
+
- dict / list / 其他 JSON 兼容 → json.dumps + '\\n'(NDJSON 风格)
|
|
888
|
+
|
|
889
|
+
返回 b"" 表示「空 chunk,跳过 send」(adapter 上层会过滤)。
|
|
890
|
+
|
|
891
|
+
设计取舍:dict 自动 NDJSON 化是给「不显式包 SSE 的最简流」一个合理默认,
|
|
892
|
+
比如 handler 写 `yield {"token": "x"}` 就能产出 `{"token":"x"}\\n`。如果
|
|
893
|
+
业务要严格 SSE,应该用 sse(...) 显式构造。
|
|
894
|
+
"""
|
|
895
|
+
if chunk is None:
|
|
896
|
+
return b""
|
|
897
|
+
if isinstance(chunk, (bytes, bytearray, memoryview)):
|
|
898
|
+
return bytes(chunk)
|
|
899
|
+
if isinstance(chunk, str):
|
|
900
|
+
return chunk.encode("utf-8")
|
|
901
|
+
# dict / list / 其他可 JSON 序列化的对象 → NDJSON 帧(一行一个 JSON)。
|
|
902
|
+
# 加 \n 是 NDJSON 规范要求;客户端用 readline 切帧会很方便。
|
|
903
|
+
try:
|
|
904
|
+
return (json.dumps(chunk, ensure_ascii=False, separators=(",", ":")) + "\n").encode("utf-8")
|
|
905
|
+
except (TypeError, ValueError):
|
|
906
|
+
# 兜底:repr 转字符串,加换行。这种情况通常是用户传了一个不可序列化
|
|
907
|
+
# 的对象(如 Future、Lock),日志里能看到原始 chunk 的 repr 帮 debug。
|
|
908
|
+
return (repr(chunk) + "\n").encode("utf-8")
|