edgeone 1.6.2 → 1.6.3-beta.1

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.
@@ -13,20 +13,76 @@ from typing import Any, Callable
13
13
  from urllib.parse import parse_qs
14
14
  import httpx
15
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)
16
+ # --- Observability bootstrap (deferred for fast cold start) ---
17
+ # Moved to _ensure_observability() called by background preload thread
18
+ # or lazily on first request. This avoids blocking startup with OTel imports.
19
+ _obs_initialized = False
20
+
21
+
22
+ def _ensure_observability():
23
+ """Initialize observability (idempotent). Called by preload thread or first request."""
24
+ global _obs_initialized
25
+ if _obs_initialized:
26
+ return
27
+ _obs_initialized = True
28
+ _obs_entries_raw = os.environ.get("AGENT_OBSERVABILITY_ENTRIES", "")
29
+ if _obs_entries_raw:
30
+ try:
31
+ from _platform._observability import setup as _obs_setup
32
+ _obs_setup(json.loads(_obs_entries_raw))
33
+ except Exception as _obs_err:
34
+ print(f"[observability] setup failed: {_obs_err}", file=sys.stderr, flush=True)
26
35
 
27
36
  from .store import InMemoryStore, BlobBackedStore
28
37
  from .runtime import AgentRuntime, RouteEntry
29
- from .context import StreamResponse
38
+ from .context import EoContext, StreamResponse
39
+
40
+
41
+ # --- Geo parsing (aligns with Node agent-node.ts parseEoConnectingGeo) ---
42
+
43
+ def _parse_eo(headers: dict) -> EoContext:
44
+ """Parse eo-connecting-geo header into structured EoContext.
45
+
46
+ Format: key=value key="quoted value"
47
+ Aligns with Node Agent parseEoConnectingGeo logic.
48
+ """
49
+ import urllib.parse as _up
50
+
51
+ geo_str = ""
52
+ if isinstance(headers, dict):
53
+ geo_str = _up.unquote(headers.get("eo-connecting-geo", ""))
54
+ if not geo_str:
55
+ return EoContext(geo={}, client_ip=str(headers.get("eo-connecting-ip", "")) if isinstance(headers, dict) else "")
56
+
57
+ result = {}
58
+ matches = __import__("re").findall(r'[a-z_]+="[^"]*"|[a-z_]+=[A-Za-z0-9.-]+', geo_str)
59
+ for m in matches:
60
+ key, val = m.split("=", 1)
61
+ result[key] = val.strip('"')
62
+
63
+ geo = {}
64
+ if "asn" in result:
65
+ geo["asn"] = result["asn"]
66
+ if "nation_name" in result:
67
+ geo["countryName"] = result["nation_name"]
68
+ if "region_code" in result:
69
+ geo["regionCode"] = result["region_code"]
70
+ geo["countryCodeAlpha2"] = result["region_code"].split("-")[0]
71
+ if "nation_numeric" in result:
72
+ geo["countryCodeNumeric"] = result["nation_numeric"]
73
+ if "region_name" in result:
74
+ geo["regionName"] = result["region_name"]
75
+ if "city_name" in result:
76
+ geo["cityName"] = result["city_name"]
77
+ if "latitude" in result:
78
+ geo["latitude"] = result["latitude"]
79
+ if "longitude" in result:
80
+ geo["longitude"] = result["longitude"]
81
+ if "network_operator" in result:
82
+ geo["cisp"] = result["network_operator"]
83
+
84
+ client_ip = str(headers.get("eo-connecting-ip", "")) if isinstance(headers, dict) else ""
85
+ return EoContext(geo=geo, client_ip=client_ip)
30
86
 
31
87
 
32
88
  # --- Platform response headers (align with Node agent + python-function) ---
@@ -408,22 +464,58 @@ _registry: dict[str, RouteEntry] = {}
408
464
  # 加载失败的路由:route_path → 失败原因。404 响应时回显,避免静默 404 难以排查。
409
465
  _failed_routes: dict[str, str] = {}
410
466
 
467
+
468
+ # --- Lazy route loading for fast cold start ---
469
+ # Routes are NOT imported at startup. Instead, each route module is loaded
470
+ # on first access (or by the background preload thread). This avoids blocking
471
+ # the health check with heavy imports (langchain, langgraph, pydantic, etc.).
472
+
473
+ class _LazyRouteEntry:
474
+ """Lazy-loading wrapper for RouteEntry. Defers importlib.import_module() to first access."""
475
+
476
+ __slots__ = ('_module_path', '_is_index', '_handler', '_loaded', '_load_error')
477
+
478
+ def __init__(self, module_path: str, is_index: bool):
479
+ self._module_path = module_path
480
+ self._is_index = is_index
481
+ self._handler = None
482
+ self._loaded = False
483
+ self._load_error: str | None = None
484
+
485
+ @property
486
+ def handler(self):
487
+ if not self._loaded:
488
+ self._do_load()
489
+ if self._load_error:
490
+ raise ImportError(self._load_error)
491
+ return self._handler
492
+
493
+ @property
494
+ def is_index(self):
495
+ return self._is_index
496
+
497
+ @property
498
+ def module_path(self):
499
+ return self._module_path
500
+
501
+ def _do_load(self):
502
+ """Actually import the module and resolve the handler."""
503
+ try:
504
+ mod = importlib.import_module(self._module_path)
505
+ handler = getattr(mod, "handler", None)
506
+ if handler is None:
507
+ self._load_error = f"module '{self._module_path}' has no 'handler' function"
508
+ else:
509
+ self._handler = handler
510
+ except Exception as e:
511
+ self._load_error = f"failed to import '{self._module_path}': {e}"
512
+ self._loaded = True
513
+
514
+
411
515
  for route_path, info in _route_table_raw.items():
412
516
  module_path = info["module"]
413
517
  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
518
+ _registry[route_path] = _LazyRouteEntry(module_path, is_index)
427
519
 
428
520
  _runtime = AgentRuntime(
429
521
  registry=_registry,
@@ -433,13 +525,46 @@ _runtime = AgentRuntime(
433
525
  failed_routes=_failed_routes,
434
526
  )
435
527
 
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
- )
528
+ print(f"[agent-python] Registered {len(_registry)} routes (lazy), timeout={_timeout}s", file=sys.stderr, flush=True)
529
+
530
+
531
+ # --- Background preload thread ---
532
+ # After uvicorn binds the port and health check passes, this thread
533
+ # pre-imports all route modules in the background. If a request arrives
534
+ # before preload completes, the lazy handler property will block and
535
+ # import synchronously (no failure, just slower first request).
536
+
537
+ import threading
538
+
539
+
540
+ def _background_preload():
541
+ """Preload routes + observability in background after startup."""
542
+ import time
543
+ time.sleep(0.05) # yield to let uvicorn finish port binding
544
+
545
+ # 1. Initialize observability first (lighter, needed for spans)
546
+ _ensure_observability()
547
+
548
+ # 2. Trigger lazy loading for all routes
549
+ for route_path, entry in _registry.items():
550
+ try:
551
+ _ = entry.handler
552
+ except Exception as e:
553
+ _failed_routes[route_path] = str(e)
554
+ print(f"[agent-python] ERROR loading {entry.module_path}: {e}", file=sys.stderr, flush=True)
555
+
556
+ loaded_count = sum(1 for e in _registry.values() if e._loaded and e._load_error is None)
557
+ if _failed_routes:
558
+ print(
559
+ f"[agent-python] Preload done: {loaded_count}/{len(_registry)} routes loaded, "
560
+ f"{len(_failed_routes)} failed: {', '.join(_failed_routes.keys())}",
561
+ file=sys.stderr, flush=True,
562
+ )
563
+ else:
564
+ print(f"[agent-python] Preload done: {loaded_count}/{len(_registry)} routes loaded", file=sys.stderr, flush=True)
565
+
566
+
567
+ threading.Thread(target=_background_preload, daemon=True, name="agent-preload").start()
443
568
 
444
569
 
445
570
  # --- ASGI app ---
@@ -464,6 +589,11 @@ async def app(scope: dict, receive, send) -> None:
464
589
  if scope["type"] != "http":
465
590
  return
466
591
 
592
+ # Ensure observability is initialized before processing requests.
593
+ # Usually already done by background preload, but if a request arrives
594
+ # before preload completes, this ensures instrumentation is active.
595
+ _ensure_observability()
596
+
467
597
  # Read body
468
598
  body_parts = []
469
599
  while True:
@@ -571,7 +701,9 @@ async def app(scope: dict, receive, send) -> None:
571
701
  except (ImportError, Exception):
572
702
  pass
573
703
 
574
- dispatch_task = asyncio.create_task(_runtime.handle(path, method, headers, query, body))
704
+ dispatch_task = asyncio.create_task(
705
+ _runtime.handle(path, method, headers, query, body, eo=_parse_eo(headers))
706
+ )
575
707
 
576
708
  # 用 Event 让 watcher 区分「流被业务自己 cancel 了」和「client 真的断开」:
577
709
  # 流式 generator 内部 await asyncio.sleep 时如果业务调了 task.cancel,
@@ -105,12 +105,24 @@ def _resolve_sandbox_api_env(env: Mapping[str, str], configured_api_env: str = "
105
105
  return ""
106
106
 
107
107
 
108
+ @dataclass
109
+ class EoContext:
110
+ """EdgeOne 请求元数据,与 Node Agent 的 AgentEoContext 对齐。
111
+
112
+ 包含从 eo-connecting-geo / eo-connecting-ip 请求头解析的地理位置信息。
113
+ 在 adapter 层解析后传入 runtime,挂载到 ctx.eo 和 ctx.request.eo。
114
+ """
115
+ geo: dict = field(default_factory=dict)
116
+ client_ip: str = ""
117
+
118
+
108
119
  @dataclass
109
120
  class RequestInfo:
110
121
  body: dict
111
122
  headers: dict
112
123
  query: dict # URL query parameters, e.g. ?foo=bar → {"foo": "bar"}
113
124
  signal: asyncio.Event # is_set() → cancelled
125
+ eo: EoContext = field(default_factory=EoContext)
114
126
 
115
127
  @property
116
128
  def is_cancelled(self) -> bool:
@@ -143,6 +155,9 @@ class AgentContext:
143
155
  env: dict
144
156
  kv: Any # Per-route KV store (BlobBackedStore or InMemoryStore)
145
157
  agents: Any # AgentsApi (avoid circular import)
158
+ # EdgeOne 请求元数据,与 Node Agent ctx.eo 对齐
159
+ # 包含 geo(地理位置)和 client_ip(客户端 IP),从 eo-connecting-geo/ip 请求头解析
160
+ eo: EoContext = field(default_factory=EoContext)
146
161
  # 同 conversation 的活跃 run_id(如果有)。供业务自管并发场景使用:
147
162
  # 对齐 Node `AgentContext.active_run_id`,业务在自定义 stop / cancel handler 中
148
163
  # 可以根据这个字段判断是否需要主动 abort 旧 run;非 index 路由不会自动 409。