edgeone 1.5.9 → 1.6.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.
Files changed (47) hide show
  1. package/README.md +26 -26
  2. package/edgeone-bin/edgeone.js +3 -3
  3. package/edgeone-dist/cli.js +86890 -2307
  4. package/edgeone-dist/libs-pages-agent-toolkit/README.md +8 -0
  5. package/edgeone-dist/libs-pages-agent-toolkit/pages_agent_toolkit-0.1.40-py3-none-any.whl +0 -0
  6. package/edgeone-dist/libs-pages-blob-python/README.md +38 -0
  7. package/edgeone-dist/libs-pages-blob-python/pages_blob_python-0.12.0-py3-none-any.whl +0 -0
  8. package/edgeone-dist/pages/dev/runner-worker.js +86532 -2090
  9. package/edgeone-dist/pages/observability-python/__init__.py +32 -0
  10. package/edgeone-dist/pages/observability-python/_compat.py +69 -0
  11. package/edgeone-dist/pages/observability-python/apm/__init__.py +13 -0
  12. package/edgeone-dist/pages/observability-python/apm/config.py +85 -0
  13. package/edgeone-dist/pages/observability-python/apm/llm_semconv.py +53 -0
  14. package/edgeone-dist/pages/observability-python/apm/metrics_bridge.py +226 -0
  15. package/edgeone-dist/pages/observability-python/apm/span_exporter.py +384 -0
  16. package/edgeone-dist/pages/observability-python/bootstrap.py +158 -0
  17. package/edgeone-dist/pages/observability-python/build.py +119 -0
  18. package/edgeone-dist/pages/observability-python/context_patches.py +167 -0
  19. package/edgeone-dist/pages/observability-python/context_propagator.py +78 -0
  20. package/edgeone-dist/pages/observability-python/registry.json +95 -0
  21. package/edgeone-dist/pages/observability-python/registry.py +141 -0
  22. package/edgeone-dist/pages/observability-python/telemetry.py +214 -0
  23. package/edgeone-dist/pages/observability-python/tracer.py +165 -0
  24. package/edgeone-dist/pages/templates/agent-python/__init__.py +11 -0
  25. package/edgeone-dist/pages/templates/agent-python/adapter.py +908 -0
  26. package/edgeone-dist/pages/templates/agent-python/context.py +689 -0
  27. package/edgeone-dist/pages/templates/agent-python/local_blob_store.py +172 -0
  28. package/edgeone-dist/pages/templates/agent-python/memory.py +2301 -0
  29. package/edgeone-dist/pages/templates/agent-python/runtime.py +839 -0
  30. package/edgeone-dist/pages/templates/agent-python/store.py +204 -0
  31. package/edgeone-dist/studio/ui/assets/agent-obs-Dvi4IpEy.js +4 -0
  32. package/edgeone-dist/studio/ui/assets/agent-obs-qDJCE0TQ.css +1 -0
  33. package/edgeone-dist/studio/ui/assets/highlight-ClXAL37H.js +3 -0
  34. package/edgeone-dist/studio/ui/assets/index-Cz5oQnXW.css +1 -0
  35. package/edgeone-dist/studio/ui/assets/index-DD3d108t.js +1 -0
  36. package/edgeone-dist/studio/ui/assets/moment-BYRO94Ou.js +10 -0
  37. package/edgeone-dist/studio/ui/assets/react-dom-ZzBHVjtL.js +24 -0
  38. package/edgeone-dist/studio/ui/assets/react-hnpCyKql.js +17 -0
  39. package/edgeone-dist/studio/ui/assets/tea-CADagUwM.css +1 -0
  40. package/edgeone-dist/studio/ui/assets/tea-Slf_ajmf.js +334 -0
  41. package/edgeone-dist/studio/ui/favicon.ico +0 -0
  42. package/edgeone-dist/studio/ui/index.html +31 -0
  43. package/libs-pages-agent-toolkit/README.md +8 -0
  44. package/libs-pages-agent-toolkit/pages_agent_toolkit-0.1.40-py3-none-any.whl +0 -0
  45. package/libs-pages-blob-python/README.md +38 -0
  46. package/libs-pages-blob-python/pages_blob_python-0.12.0-py3-none-any.whl +0 -0
  47. package/package.json +33 -7
@@ -0,0 +1,32 @@
1
+ """EdgeOne Agent Python Observability.
2
+
3
+ Public API:
4
+ setup(entry_names) → AgentTracer
5
+ get_tracer() → AgentTracer | None
6
+ start_request_span(name) → (Span, Context)
7
+ end_request_span(span) → None
8
+ shutdown() → None
9
+ """
10
+ from __future__ import annotations
11
+
12
+ from .bootstrap import setup, get_tracer, start_request_span, end_request_span, shutdown
13
+ from .tracer import AgentTracer, NoOpTracer
14
+ from .context_patches import set_request_context, clear_request_context
15
+ from .context_propagator import (
16
+ set_agent_conversation_id,
17
+ reset_agent_conversation_id,
18
+ )
19
+
20
+ __all__ = [
21
+ "setup",
22
+ "get_tracer",
23
+ "start_request_span",
24
+ "end_request_span",
25
+ "shutdown",
26
+ "AgentTracer",
27
+ "NoOpTracer",
28
+ "set_request_context",
29
+ "clear_request_context",
30
+ "set_agent_conversation_id",
31
+ "reset_agent_conversation_id",
32
+ ]
@@ -0,0 +1,69 @@
1
+ """OTel SDK compatibility patches.
2
+
3
+ - flags patch: Force span flags=0 for Tencent APM parent_span_id detection.
4
+ - detach patch: Suppress ValueError when detaching tokens across async contexts.
5
+ """
6
+ from __future__ import annotations
7
+
8
+
9
+ def apply_compat_patches() -> None:
10
+ """Apply OTel SDK compatibility patches. Call before any span export."""
11
+ _patch_span_flags()
12
+ _patch_context_detach()
13
+
14
+
15
+ def _patch_span_flags() -> None:
16
+ """Force OTLP encoder to emit flags=0.
17
+
18
+ Python OTel SDK 1.27+ encodes non-zero flags on OTLP spans.
19
+ Tencent APM silently drops parent_span_id when flags != 0.
20
+ """
21
+ try:
22
+ from opentelemetry.exporter.otlp.proto.common._internal import (
23
+ trace_encoder as _enc,
24
+ )
25
+ _enc._span_flags = lambda _parent_span_context: 0 # type: ignore[assignment]
26
+ except ImportError:
27
+ pass
28
+
29
+
30
+ def _patch_context_detach() -> None:
31
+ """Make context.detach() tolerant of cross-context token resets.
32
+
33
+ Problem: OpenInference instrumentors wrap async generators and call
34
+ context_api.detach(token) in their finally block. When the generator
35
+ is closed via aclose() from a different async task (e.g. adapter's
36
+ streaming loop cancels the generator on client disconnect), the
37
+ ContextVar.reset(token) raises ValueError because the token was
38
+ created in a different execution context.
39
+
40
+ This is harmless — the context will be garbage-collected anyway —
41
+ but produces noisy "Failed to detach context" tracebacks in logs.
42
+
43
+ Fix: patch ContextVarsRuntimeContext.detach to suppress the ValueError.
44
+ """
45
+ try:
46
+ from opentelemetry.context.contextvars_context import (
47
+ ContextVarsRuntimeContext,
48
+ )
49
+ except ImportError:
50
+ return
51
+
52
+ if getattr(ContextVarsRuntimeContext, "_eo_detach_patched", False):
53
+ return
54
+
55
+ _orig_detach = ContextVarsRuntimeContext.detach
56
+
57
+ def _safe_detach(self, token): # type: ignore[no-untyped-def]
58
+ try:
59
+ return _orig_detach(self, token)
60
+ except (ValueError, RuntimeError):
61
+ # ValueError: token was created in a different Context — happens
62
+ # when an async generator is closed from a different task.
63
+ # RuntimeError: token has already been used — happens when detach
64
+ # is called twice (e.g. generator finally + outer finally).
65
+ # Both are harmless cleanup failures, suppress silently.
66
+ pass
67
+
68
+ ContextVarsRuntimeContext.detach = _safe_detach # type: ignore[assignment]
69
+ ContextVarsRuntimeContext._eo_detach_patched = True # type: ignore[attr-defined]
@@ -0,0 +1,13 @@
1
+ """EdgeOne Agent Observability — APM adapter layer."""
2
+ from .config import resolve_apm_endpoint, resolve_service_name, apm_identity_attrs
3
+ from .span_exporter import ApmSpanExporter, TranslatorContext
4
+ from .metrics_bridge import ApmMetricsBridge
5
+
6
+ __all__ = [
7
+ "resolve_apm_endpoint",
8
+ "resolve_service_name",
9
+ "apm_identity_attrs",
10
+ "ApmSpanExporter",
11
+ "TranslatorContext",
12
+ "ApmMetricsBridge",
13
+ ]
@@ -0,0 +1,85 @@
1
+ """Pinned runtime constants for the APM adapter layer.
2
+
3
+ Mirrors the Node-side config (src/agent/observability/apm/config.ts) and
4
+ the demo (frameworks-py/src/runner/config.py). Keep in lockstep.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import os
9
+ import socket
10
+ from datetime import datetime
11
+ from typing import Mapping
12
+
13
+
14
+ # --- APM endpoint resolution (same env protocol as Node) ---
15
+
16
+ APM_ENDPOINTS: dict[str, str] = {
17
+ "ap-beijing": "http://ap-beijing.apm.tencentcs.com:55681",
18
+ "ap-singapore": "http://ap-singapore.apm.tencentcs.com:55681",
19
+ }
20
+ DEFAULT_REGION = "ap-singapore"
21
+
22
+
23
+ def resolve_apm_endpoint() -> str:
24
+ """Resolve APM endpoint from TENCENTCLOUD_REGION env var."""
25
+ region = os.environ.get("TENCENTCLOUD_REGION", DEFAULT_REGION)
26
+ return APM_ENDPOINTS.get(region, APM_ENDPOINTS[DEFAULT_REGION])
27
+
28
+
29
+ _cached_dev_service_name: str | None = None
30
+
31
+
32
+ def resolve_service_name() -> str:
33
+ """Resolve service_name: {projectId}-{deploymentId}, fallback to dir+datetime."""
34
+ project_id = os.environ.get("PAGES_PROJECT_ID", "")
35
+ deployment_id = os.environ.get("PAGES_DEPLOYMENT_ID", "")
36
+ if project_id and deployment_id:
37
+ return f"{project_id}-{deployment_id}"
38
+ if os.environ.get("OTEL_SERVICE_NAME"):
39
+ return os.environ["OTEL_SERVICE_NAME"]
40
+ # Dev mode: parent process (agent-observability) generates a unified
41
+ # service name via EDGEONE_DEV_SERVICE_NAME so that Node.js and Python
42
+ # runtimes share the same name on APM dual-export.
43
+ if os.environ.get("EDGEONE_DEV_SERVICE_NAME"):
44
+ return os.environ["EDGEONE_DEV_SERVICE_NAME"]
45
+ # Fallback dev mode: project name + datetime (cached per process).
46
+ global _cached_dev_service_name
47
+ if _cached_dev_service_name is None:
48
+ basename = os.path.basename(os.path.dirname(os.path.dirname(os.getcwd()))) or "agent"
49
+ date_str = datetime.now().strftime("%Y%m%d%H%M%S")
50
+ _cached_dev_service_name = f"{basename}-{date_str}"
51
+ return _cached_dev_service_name
52
+
53
+
54
+ # --- Pinned constants (must match Node side exactly) ---
55
+
56
+ MIN_LLM_CALL_DURATION_MS = 50
57
+
58
+ METRIC_EXPORT_INTERVAL_MS = 300_000
59
+ METRIC_EXPORT_TIMEOUT_MS = 30_000
60
+
61
+ APM_IDENTITY_LIBRARY_NAME = "langfuse-sdk"
62
+ APM_IDENTITY_LIBRARY_VERSION = "4.3.1"
63
+
64
+
65
+ # --- Identity attrs ---
66
+
67
+ def _local_ip() -> str:
68
+ try:
69
+ with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
70
+ s.connect(("8.8.8.8", 80))
71
+ return s.getsockname()[0]
72
+ except OSError:
73
+ return "127.0.0.1"
74
+
75
+
76
+ def apm_identity_attrs() -> Mapping[str, str]:
77
+ """Identity fields APM expects on recognised LLM services."""
78
+ hostname = socket.gethostname()
79
+ return {
80
+ "instrumentation.library.name": APM_IDENTITY_LIBRARY_NAME,
81
+ "instrumentation.library.version": APM_IDENTITY_LIBRARY_VERSION,
82
+ "host.name": hostname,
83
+ "ip": _local_ip(),
84
+ "service.instance": hostname,
85
+ }
@@ -0,0 +1,53 @@
1
+ """OpenInference <-> OpenLLMetry semantic translation.
2
+
3
+ Mirrors frameworks-py/src/runner/llm_semconv.py exactly.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ from typing import Literal
8
+
9
+
10
+ OI_LLM_KINDS: tuple[str, ...] = (
11
+ "LLM",
12
+ "CHAIN",
13
+ "AGENT",
14
+ "TOOL",
15
+ "EMBEDDING",
16
+ "RETRIEVER",
17
+ "RERANKER",
18
+ )
19
+ OI_LLM_KIND_SET: frozenset[str] = frozenset(OI_LLM_KINDS)
20
+
21
+ OI_LLM_LEAF_KIND_SET: frozenset[str] = frozenset({"LLM"})
22
+
23
+
24
+ def oi_kind_to_operation(kind: str) -> Literal["chat", "embedding", "rerank"]:
25
+ if kind == "EMBEDDING":
26
+ return "embedding"
27
+ if kind == "RERANKER":
28
+ return "rerank"
29
+ return "chat"
30
+
31
+
32
+ def oi_kind_to_genai_span_kind(kind: str) -> str:
33
+ return "generation" if kind == "LLM" else kind.lower()
34
+
35
+
36
+ # Attribute mirror: (dest_key, src_key, coerce)
37
+ OI_TO_GENAI_MIRROR: tuple[tuple[str, str, str | None], ...] = (
38
+ ("gen_ai.usage.prompt_tokens", "llm.token_count.prompt", "number"),
39
+ ("gen_ai.usage.completion_tokens", "llm.token_count.completion", "number"),
40
+ ("llm.usage.total_tokens", "llm.token_count.total", "number"),
41
+ ("gen_ai.usage.input_tokens", "llm.token_count.prompt", "number"),
42
+ ("gen_ai.usage.output_tokens", "llm.token_count.completion", "number"),
43
+ ("gen_ai.usage.total_tokens", "llm.token_count.total", "number"),
44
+ (
45
+ "gen_ai.usage.cache_read.input_tokens",
46
+ "llm.token_count.prompt_details.cache_read",
47
+ "number",
48
+ ),
49
+ ("gen_ai.request.model", "llm.model_name", None),
50
+ ("gen_ai.response.model", "llm.model_name", None),
51
+ ("gen_ai.system", "llm.provider", None),
52
+ ("gen_ai.provider.name", "llm.provider", None),
53
+ )
@@ -0,0 +1,226 @@
1
+ """APM Metrics bridge — drives gen_ai.client.operation.duration histogram.
2
+
3
+ Mirrors frameworks-py/src/runner/apm_metrics_bridge.py.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ from typing import Any
8
+
9
+ from opentelemetry.context import Context
10
+ from opentelemetry.exporter.otlp.proto.http.metric_exporter import OTLPMetricExporter
11
+ from opentelemetry.sdk.metrics import MeterProvider
12
+ from opentelemetry.sdk.metrics.export import (
13
+ AggregationTemporality,
14
+ PeriodicExportingMetricReader,
15
+ )
16
+ from opentelemetry.sdk.metrics._internal.instrument import (
17
+ Counter,
18
+ Histogram,
19
+ ObservableCounter,
20
+ ObservableGauge,
21
+ ObservableUpDownCounter,
22
+ UpDownCounter,
23
+ )
24
+ from opentelemetry.sdk.resources import Resource
25
+ from opentelemetry.sdk.trace import ReadableSpan, Span
26
+ from opentelemetry.sdk.trace.export import SpanProcessor
27
+ from opentelemetry.trace.status import StatusCode
28
+
29
+ from .config import (
30
+ METRIC_EXPORT_INTERVAL_MS,
31
+ METRIC_EXPORT_TIMEOUT_MS,
32
+ MIN_LLM_CALL_DURATION_MS,
33
+ )
34
+ from .llm_semconv import (
35
+ OI_LLM_KIND_SET,
36
+ OI_LLM_LEAF_KIND_SET,
37
+ oi_kind_to_operation,
38
+ )
39
+
40
+ # 与 span_exporter._INTERRUPT_EXCEPTION_NAMES 保持一致:LangGraph 控制流异常名,
41
+ # 外加 interrupt 触发后流拆除 / 取消的 teardown 信号 GeneratorExit / CancelledError。
42
+ _INTERRUPT_EXCEPTION_NAMES = frozenset({
43
+ "GraphInterrupt",
44
+ "NodeInterrupt",
45
+ "ParentCommand",
46
+ "GeneratorExit",
47
+ "CancelledError",
48
+ })
49
+
50
+ # 与 span_exporter._CREWAI_INTERRUPT_EXCEPTION_NAMES 保持一致:CrewAI @human_feedback
51
+ # 暂停信号 HumanFeedbackPending,非业务错误,仅在 crewai instrumentation scope 内归一,
52
+ # 避免把人工反馈暂停计入 LLM 错误指标。
53
+ _CREWAI_INTERRUPT_EXCEPTION_NAMES = frozenset({
54
+ "HumanFeedbackPending",
55
+ })
56
+
57
+
58
+ def _is_crewai_scope(span: ReadableSpan) -> bool:
59
+ scope = getattr(span, "instrumentation_scope", None)
60
+ name = getattr(scope, "name", None)
61
+ return isinstance(name, str) and "crewai" in name.lower()
62
+
63
+
64
+ def _iter_exception_type_names(span: ReadableSpan):
65
+ attrs = span.attributes or {}
66
+ for key in ("exception.type", "error.type"):
67
+ v = attrs.get(key)
68
+ if isinstance(v, str) and v:
69
+ yield v.rsplit(".", 1)[-1]
70
+ for ev in getattr(span, "events", None) or ():
71
+ if getattr(ev, "name", None) != "exception":
72
+ continue
73
+ ev_attrs = getattr(ev, "attributes", None) or {}
74
+ v = ev_attrs.get("exception.type")
75
+ if isinstance(v, str) and v:
76
+ yield v.rsplit(".", 1)[-1]
77
+
78
+
79
+ def _is_interrupt_span(span: ReadableSpan) -> bool:
80
+ names = list(_iter_exception_type_names(span))
81
+ if any(n in _INTERRUPT_EXCEPTION_NAMES for n in names):
82
+ return True
83
+ if _is_crewai_scope(span) and any(
84
+ n in _CREWAI_INTERRUPT_EXCEPTION_NAMES for n in names
85
+ ):
86
+ return True
87
+ return False
88
+
89
+
90
+ def _pick_str(span: ReadableSpan, *keys: str) -> str | None:
91
+ attrs = span.attributes or {}
92
+ for k in keys:
93
+ v = attrs.get(k)
94
+ if isinstance(v, str) and v:
95
+ return v
96
+ return None
97
+
98
+
99
+ def _shared_attrs(span: ReadableSpan) -> dict[str, Any]:
100
+ model = (
101
+ _pick_str(span, "llm.model_name", "gen_ai.response.model", "gen_ai.request.model")
102
+ or "unknown"
103
+ )
104
+ system = (
105
+ _pick_str(span, "llm.provider", "gen_ai.system", "gen_ai.provider.name")
106
+ or "unknown"
107
+ )
108
+ oi_kind = _pick_str(span, "openinference.span.kind") or ""
109
+ request_type = _pick_str(span, "llm.request.type") or oi_kind_to_operation(oi_kind)
110
+ out: dict[str, Any] = {
111
+ "gen_ai.system": system,
112
+ "gen_ai.response.model": model,
113
+ "gen_ai.operation.name": request_type,
114
+ }
115
+ streaming = (span.attributes or {}).get("llm.is_streaming")
116
+ if streaming is True or streaming == "true":
117
+ out["stream"] = True
118
+ return out
119
+
120
+
121
+ def _is_oi_llm_leaf(span: ReadableSpan) -> bool:
122
+ kind = _pick_str(span, "openinference.span.kind")
123
+ return kind is not None and kind in OI_LLM_LEAF_KIND_SET
124
+
125
+
126
+ def _is_oi_llm_span(span: ReadableSpan) -> bool:
127
+ kind = _pick_str(span, "openinference.span.kind")
128
+ return kind is not None and kind in OI_LLM_KIND_SET
129
+
130
+
131
+ def _duration_seconds(span: ReadableSpan) -> float:
132
+ if span.start_time is None or span.end_time is None:
133
+ return 0.0
134
+ return (span.end_time - span.start_time) / 1e9
135
+
136
+
137
+ _DELTA_PREF = {
138
+ Counter: AggregationTemporality.DELTA,
139
+ UpDownCounter: AggregationTemporality.CUMULATIVE,
140
+ Histogram: AggregationTemporality.DELTA,
141
+ ObservableCounter: AggregationTemporality.DELTA,
142
+ ObservableUpDownCounter: AggregationTemporality.CUMULATIVE,
143
+ ObservableGauge: AggregationTemporality.CUMULATIVE,
144
+ }
145
+
146
+
147
+ class ApmMetricsBridge:
148
+ def __init__(self, *, endpoint: str, apm_token: str, resource: Resource) -> None:
149
+ self._exporter = OTLPMetricExporter(
150
+ endpoint=f"{endpoint.rstrip('/')}/v1/metrics",
151
+ preferred_temporality=_DELTA_PREF,
152
+ )
153
+ self._reader = PeriodicExportingMetricReader(
154
+ self._exporter,
155
+ export_interval_millis=METRIC_EXPORT_INTERVAL_MS,
156
+ export_timeout_millis=METRIC_EXPORT_TIMEOUT_MS,
157
+ )
158
+ self._meter_provider = MeterProvider(
159
+ resource=resource,
160
+ metric_readers=[self._reader],
161
+ )
162
+ meter = self._meter_provider.get_meter(
163
+ "edgeone-agent-apm-bridge",
164
+ "0.1.0",
165
+ )
166
+ self._duration_histogram = meter.create_histogram(
167
+ name="gen_ai.client.operation.duration",
168
+ unit="s",
169
+ description="GenAI operation duration",
170
+ )
171
+ self._processor = _BridgeSpanProcessor(self)
172
+
173
+ @property
174
+ def span_processor(self) -> SpanProcessor:
175
+ return self._processor
176
+
177
+ def record(self, span: ReadableSpan) -> None:
178
+ if not _is_oi_llm_span(span):
179
+ return
180
+ if not _is_oi_llm_leaf(span):
181
+ return
182
+ if not _pick_str(
183
+ span,
184
+ "llm.model_name",
185
+ "gen_ai.response.model",
186
+ "gen_ai.request.model",
187
+ ):
188
+ return
189
+ dur = _duration_seconds(span)
190
+ if dur * 1000 < MIN_LLM_CALL_DURATION_MS:
191
+ return
192
+ attrs = _shared_attrs(span)
193
+ if (
194
+ span.status
195
+ and span.status.status_code == StatusCode.ERROR
196
+ and not _is_interrupt_span(span)
197
+ ):
198
+ err = _pick_str(span, "error.type", "exception.type") or "error"
199
+ attrs["error.type"] = err
200
+ self._duration_histogram.record(dur, attributes=attrs)
201
+
202
+ def shutdown(self) -> None:
203
+ try:
204
+ self._meter_provider.shutdown()
205
+ except Exception:
206
+ pass
207
+
208
+
209
+ class _BridgeSpanProcessor(SpanProcessor):
210
+ def __init__(self, bridge: ApmMetricsBridge) -> None:
211
+ self._bridge = bridge
212
+
213
+ def on_start(self, span: Span, parent_context: Context | None = None) -> None:
214
+ return None
215
+
216
+ def on_end(self, span: ReadableSpan) -> None:
217
+ try:
218
+ self._bridge.record(span)
219
+ except Exception:
220
+ pass
221
+
222
+ def shutdown(self) -> None:
223
+ return None
224
+
225
+ def force_flush(self, timeout_millis: int = 30_000) -> bool:
226
+ return True