stcode 0.0.1a0__py3-none-any.whl
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.
- stcode/__init__.py +10 -0
- stcode/cli/__init__.py +9 -0
- stcode/cli/bootstrap.py +70 -0
- stcode/cli/labels.py +621 -0
- stcode/cli/logic/__init__.py +0 -0
- stcode/cli/logic/commands.py +36 -0
- stcode/cli/logic/completion.py +43 -0
- stcode/cli/logic/connection.py +46 -0
- stcode/cli/logic/requests.py +18 -0
- stcode/cli/logic/sessions.py +67 -0
- stcode/cli/logic/settings.py +35 -0
- stcode/cli/logic/state.py +43 -0
- stcode/cli/logic/workspace.py +17 -0
- stcode/cli/main.py +290 -0
- stcode/cli/models.py +61 -0
- stcode/cli/services/__init__.py +0 -0
- stcode/cli/services/client.py +393 -0
- stcode/cli/services/launcher.py +159 -0
- stcode/cli/services/paths.py +18 -0
- stcode/cli/services/preferences.py +107 -0
- stcode/cli/ui/__init__.py +0 -0
- stcode/cli/ui/app.py +136 -0
- stcode/cli/ui/app.tcss +3 -0
- stcode/cli/ui/components/__init__.py +0 -0
- stcode/cli/ui/components/banner.py +78 -0
- stcode/cli/ui/components/cards.py +229 -0
- stcode/cli/ui/components/prompt.py +225 -0
- stcode/cli/ui/components/status.py +95 -0
- stcode/cli/ui/components/transcript/__init__.py +51 -0
- stcode/cli/ui/components/transcript/entries.py +232 -0
- stcode/cli/ui/components/transcript/formatting.py +188 -0
- stcode/cli/ui/components/transcript/view.py +101 -0
- stcode/cli/ui/screens/__init__.py +0 -0
- stcode/cli/ui/screens/chat/__init__.py +0 -0
- stcode/cli/ui/screens/chat/chat.tcss +12 -0
- stcode/cli/ui/screens/chat/presenter.py +187 -0
- stcode/cli/ui/screens/chat/screen.py +682 -0
- stcode/cli/ui/screens/connect.py +126 -0
- stcode/cli/ui/screens/connect.tcss +49 -0
- stcode/cli/ui/screens/dialog.tcss +40 -0
- stcode/cli/ui/screens/sessions.py +105 -0
- stcode/cli/ui/screens/settings.py +137 -0
- stcode/cli/ui/screens/settings.tcss +69 -0
- stcode/cli/ui/screens/trust.py +50 -0
- stcode/cli/ui/theme.py +221 -0
- stcode/core/__main__.py +7 -0
- stcode/core/agent/__init__.py +41 -0
- stcode/core/agent/agent.py +651 -0
- stcode/core/agent/events.py +97 -0
- stcode/core/agent/supervisor.py +226 -0
- stcode/core/agent/task.py +120 -0
- stcode/core/common/__init__.py +21 -0
- stcode/core/common/compat.py +27 -0
- stcode/core/common/ids.py +43 -0
- stcode/core/common/tools.py +50 -0
- stcode/core/common/trace.py +406 -0
- stcode/core/common/truncate.py +76 -0
- stcode/core/configs.py +490 -0
- stcode/core/daemon/__init__.py +32 -0
- stcode/core/daemon/autonomy.py +71 -0
- stcode/core/daemon/main.py +126 -0
- stcode/core/daemon/protocol.py +499 -0
- stcode/core/daemon/runner.py +359 -0
- stcode/core/daemon/server.py +682 -0
- stcode/core/daemon/workspace.py +88 -0
- stcode/core/harness/__init__.py +81 -0
- stcode/core/harness/approvals.py +85 -0
- stcode/core/harness/context.py +260 -0
- stcode/core/harness/errors.py +40 -0
- stcode/core/harness/harness.py +417 -0
- stcode/core/harness/mcp.py +530 -0
- stcode/core/harness/outputs.py +99 -0
- stcode/core/harness/prompts/__init__.py +138 -0
- stcode/core/harness/prompts/execute.py +51 -0
- stcode/core/harness/prompts/plan.py +57 -0
- stcode/core/harness/prompts/sections.py +238 -0
- stcode/core/harness/skills/__init__.py +30 -0
- stcode/core/harness/skills/loader.py +247 -0
- stcode/core/harness/tools/__init__.py +111 -0
- stcode/core/harness/tools/base.py +640 -0
- stcode/core/harness/tools/files.py +276 -0
- stcode/core/harness/tools/interact.py +72 -0
- stcode/core/harness/tools/registry.py +113 -0
- stcode/core/harness/tools/repl.py +97 -0
- stcode/core/harness/tools/schema.py +186 -0
- stcode/core/harness/tools/search.py +356 -0
- stcode/core/harness/tools/shell.py +372 -0
- stcode/core/harness/tools/skill.py +66 -0
- stcode/core/harness/tools/todo.py +62 -0
- stcode/core/harness/tools/web.py +163 -0
- stcode/core/providers/__init__.py +31 -0
- stcode/core/providers/anthropic_claude.py +243 -0
- stcode/core/providers/base.py +73 -0
- stcode/core/providers/gateway.py +267 -0
- stcode/core/providers/google_gemini.py +225 -0
- stcode/core/providers/openai_gpt.py +250 -0
- stcode/core/providers/registry.py +46 -0
- stcode/core/providers/types.py +148 -0
- stcode/core/repl/__init__.py +19 -0
- stcode/core/repl/_worker.py +179 -0
- stcode/core/repl/client.py +374 -0
- stcode/core/session/__init__.py +39 -0
- stcode/core/session/session.py +493 -0
- stcode/core/team/__init__.py +38 -0
- stcode/core/team/mailbox.py +249 -0
- stcode/core/team/tools.py +145 -0
- stcode-0.0.1a0.dist-info/METADATA +229 -0
- stcode-0.0.1a0.dist-info/RECORD +111 -0
- stcode-0.0.1a0.dist-info/WHEEL +4 -0
- stcode-0.0.1a0.dist-info/entry_points.txt +3 -0
- stcode-0.0.1a0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
"""Export agent spans through Langfuse v4 OTLP/HTTP and Phoenix REST, without SDKs.
|
|
2
|
+
|
|
3
|
+
Callers keep the small span/Recorder interface. Context variables preserve asyncio
|
|
4
|
+
parentage; a bounded worker queue keeps HTTP I/O outside the agent loop.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import contextlib
|
|
9
|
+
import logging
|
|
10
|
+
import json
|
|
11
|
+
import queue
|
|
12
|
+
import secrets
|
|
13
|
+
import threading
|
|
14
|
+
import time
|
|
15
|
+
from contextvars import ContextVar
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from datetime import datetime, timezone
|
|
18
|
+
from typing import Any, Iterator, Mapping, Protocol
|
|
19
|
+
from urllib.parse import quote, urlsplit
|
|
20
|
+
|
|
21
|
+
import httpx
|
|
22
|
+
|
|
23
|
+
from stcode.core.configs import TraceConfig
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger("stcode.trace")
|
|
26
|
+
INSTRUMENTATION_NAME = "stcode"
|
|
27
|
+
SERVICE_NAME = "stcode"
|
|
28
|
+
_CONTENT_KEYS = {"stcode.input", "stcode.output", "gen_ai.input.messages", "gen_ai.output.messages"}
|
|
29
|
+
_TRACE_KEYS = {
|
|
30
|
+
"gen_ai.conversation.id", "gen_ai.agent.name", "stcode.role", "stcode.approval_mode",
|
|
31
|
+
"langfuse.user.id", "langfuse.session.id", "langfuse.version",
|
|
32
|
+
"langfuse.release", "langfuse.environment",
|
|
33
|
+
}
|
|
34
|
+
_current: ContextVar[_Span | None] = ContextVar("stcode_span", default=None)
|
|
35
|
+
_exporter: _Exporter | None = None
|
|
36
|
+
_record_content = False
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _now() -> str:
|
|
40
|
+
return datetime.now(timezone.utc).isoformat()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class Recorder(Protocol):
|
|
44
|
+
def set(self, **attributes: Any) -> None: ...
|
|
45
|
+
def fail(self, exc: BaseException) -> None: ...
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class _NoSpan:
|
|
49
|
+
def set(self, **attributes: Any) -> None:
|
|
50
|
+
pass
|
|
51
|
+
|
|
52
|
+
def fail(self, exc: BaseException) -> None:
|
|
53
|
+
pass
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
_NO_SPAN = _NoSpan()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass
|
|
60
|
+
class _Span:
|
|
61
|
+
name: str
|
|
62
|
+
trace_id: str
|
|
63
|
+
parent_id: str | None
|
|
64
|
+
content: bool
|
|
65
|
+
trace_name: str = ""
|
|
66
|
+
kind: str = "internal"
|
|
67
|
+
span_id: str = field(default_factory=lambda: secrets.token_hex(8))
|
|
68
|
+
start: str = field(default_factory=_now)
|
|
69
|
+
end: str = ""
|
|
70
|
+
attributes: dict[str, Any] = field(default_factory=dict)
|
|
71
|
+
error: str = ""
|
|
72
|
+
|
|
73
|
+
def set(self, **attributes: Any) -> None:
|
|
74
|
+
for key, value in attributes.items():
|
|
75
|
+
key = key.replace("__", ".")
|
|
76
|
+
if value is not None and (self.content or key not in _CONTENT_KEYS):
|
|
77
|
+
self.attributes[key] = value
|
|
78
|
+
|
|
79
|
+
def fail(self, exc: BaseException) -> None:
|
|
80
|
+
self.error = f"{type(exc).__name__}: {exc}" if self.content else type(exc).__name__
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def operation(self) -> str:
|
|
84
|
+
op = self.attributes.get("gen_ai.operation.name")
|
|
85
|
+
if op == "chat":
|
|
86
|
+
return "LLM"
|
|
87
|
+
if op == "execute_tool":
|
|
88
|
+
return "TOOL"
|
|
89
|
+
return "AGENT" if self.name.startswith("agent.turn ") else "CHAIN"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _otel_value(value: Any) -> dict[str, Any]:
|
|
93
|
+
"""OTLP JSON AnyValue; structured attribute values are JSON strings."""
|
|
94
|
+
if isinstance(value, bool):
|
|
95
|
+
return {"boolValue": value}
|
|
96
|
+
if isinstance(value, int):
|
|
97
|
+
return {"intValue": str(value)}
|
|
98
|
+
if isinstance(value, float):
|
|
99
|
+
return {"doubleValue": value}
|
|
100
|
+
if isinstance(value, (list, tuple)):
|
|
101
|
+
return {"arrayValue": {"values": [_otel_value(item) for item in value]}}
|
|
102
|
+
return {"stringValue": value if isinstance(value, str) else json.dumps(value)}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _unix_nano(timestamp: str) -> str:
|
|
106
|
+
# Avoid float timestamps: contemporary epoch nanoseconds exceed float precision.
|
|
107
|
+
delta = datetime.fromisoformat(timestamp) - datetime(1970, 1, 1, tzinfo=timezone.utc)
|
|
108
|
+
return str(((delta.days * 86400 + delta.seconds) * 1000000 + delta.microseconds) * 1000)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _langfuse(span: _Span) -> dict[str, Any]:
|
|
112
|
+
original = span.attributes
|
|
113
|
+
attrs = {k: v for k, v in original.items() if k not in _CONTENT_KEYS}
|
|
114
|
+
attrs["langfuse.trace.name"] = span.trace_name
|
|
115
|
+
attrs["langfuse.observation.type"] = {
|
|
116
|
+
"AGENT": "agent", "LLM": "generation", "TOOL": "tool", "CHAIN": "chain",
|
|
117
|
+
}[span.operation]
|
|
118
|
+
if "gen_ai.conversation.id" in original:
|
|
119
|
+
attrs["langfuse.session.id"] = original["gen_ai.conversation.id"]
|
|
120
|
+
for key, value in original.items():
|
|
121
|
+
if key in _CONTENT_KEYS:
|
|
122
|
+
continue
|
|
123
|
+
if key.startswith("stcode.") or key == "gen_ai.agent.name":
|
|
124
|
+
level = "trace" if key in _TRACE_KEYS else "observation"
|
|
125
|
+
attrs[f"langfuse.{level}.metadata.{key.replace('.', '_')}"] = (
|
|
126
|
+
value if isinstance(value, str) else json.dumps(value)
|
|
127
|
+
)
|
|
128
|
+
for target, keys in (("input", ("stcode.input", "gen_ai.input.messages")),
|
|
129
|
+
("output", ("stcode.output", "gen_ai.output.messages"))):
|
|
130
|
+
for key in keys:
|
|
131
|
+
if key in original:
|
|
132
|
+
value = original[key]
|
|
133
|
+
attrs[f"langfuse.observation.{target}"] = (
|
|
134
|
+
value if isinstance(value, str) else json.dumps(value)
|
|
135
|
+
)
|
|
136
|
+
break
|
|
137
|
+
if span.operation == "LLM":
|
|
138
|
+
if "gen_ai.request.model" in original:
|
|
139
|
+
attrs["langfuse.observation.model.name"] = original["gen_ai.request.model"]
|
|
140
|
+
usage = {
|
|
141
|
+
target: original[source] for source, target in (
|
|
142
|
+
("gen_ai.usage.input_tokens", "input"),
|
|
143
|
+
("gen_ai.usage.output_tokens", "output"),
|
|
144
|
+
) if source in original
|
|
145
|
+
}
|
|
146
|
+
attrs["langfuse.observation.usage_details"] = json.dumps(usage)
|
|
147
|
+
body: dict[str, Any] = {
|
|
148
|
+
"traceId": span.trace_id, "spanId": span.span_id, "name": span.name,
|
|
149
|
+
"kind": {"internal": 1, "server": 2, "client": 3, "producer": 4, "consumer": 5}.get(span.kind, 1),
|
|
150
|
+
"startTimeUnixNano": _unix_nano(span.start), "endTimeUnixNano": _unix_nano(span.end),
|
|
151
|
+
"attributes": [{"key": k, "value": _otel_value(v)} for k, v in attrs.items()],
|
|
152
|
+
"status": {"code": 2, "message": span.error} if span.error else {"code": 1},
|
|
153
|
+
}
|
|
154
|
+
if span.parent_id:
|
|
155
|
+
body["parentSpanId"] = span.parent_id
|
|
156
|
+
return body
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _phoenix(span: _Span) -> dict[str, Any]:
|
|
160
|
+
attrs = {k: v for k, v in span.attributes.items() if k not in _CONTENT_KEYS}
|
|
161
|
+
mapping = {
|
|
162
|
+
"gen_ai.request.model": "llm.model_name",
|
|
163
|
+
"gen_ai.provider.name": "llm.provider",
|
|
164
|
+
"gen_ai.usage.input_tokens": "llm.token_count.prompt",
|
|
165
|
+
"gen_ai.usage.output_tokens": "llm.token_count.completion",
|
|
166
|
+
"gen_ai.usage.cache_read_input_tokens": "llm.token_count.prompt_details.cache_read",
|
|
167
|
+
"gen_ai.conversation.id": "session.id",
|
|
168
|
+
"gen_ai.tool.name": "tool.name",
|
|
169
|
+
}
|
|
170
|
+
for source, target in mapping.items():
|
|
171
|
+
if source in attrs:
|
|
172
|
+
attrs[target] = attrs[source]
|
|
173
|
+
for target, keys in (("input", ("stcode.input", "gen_ai.input.messages")),
|
|
174
|
+
("output", ("stcode.output", "gen_ai.output.messages"))):
|
|
175
|
+
for key in keys:
|
|
176
|
+
if key in span.attributes:
|
|
177
|
+
value = span.attributes[key]
|
|
178
|
+
attrs[f"{target}.value"] = value if isinstance(value, str) else json.dumps(value)
|
|
179
|
+
attrs[f"{target}.mime_type"] = "text/plain" if isinstance(value, str) else "application/json"
|
|
180
|
+
break
|
|
181
|
+
return {
|
|
182
|
+
"name": span.name, "context": {"trace_id": span.trace_id, "span_id": span.span_id},
|
|
183
|
+
"parent_id": span.parent_id, "span_kind": span.operation,
|
|
184
|
+
"start_time": span.start, "end_time": span.end,
|
|
185
|
+
"status_code": "ERROR" if span.error else "OK", "status_message": span.error,
|
|
186
|
+
"attributes": attrs,
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
@dataclass
|
|
191
|
+
class _Barrier:
|
|
192
|
+
ready: threading.Event = field(default_factory=threading.Event)
|
|
193
|
+
ok: bool = False
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
class _Exporter:
|
|
197
|
+
def __init__(self, provider: str, url: str, headers: dict[str, str], service: str) -> None:
|
|
198
|
+
self.provider, self.url, self.headers, self.service = provider, url, headers, service
|
|
199
|
+
self.queue: queue.Queue[_Span | _Barrier] = queue.Queue(maxsize=2048)
|
|
200
|
+
self.stopping = threading.Event()
|
|
201
|
+
self.failed = threading.Event()
|
|
202
|
+
self.thread = threading.Thread(target=self._run, name="stcode-trace", daemon=True)
|
|
203
|
+
self.thread.start()
|
|
204
|
+
|
|
205
|
+
def submit(self, span: _Span) -> None:
|
|
206
|
+
try:
|
|
207
|
+
self.queue.put_nowait(span)
|
|
208
|
+
except queue.Full:
|
|
209
|
+
self.failed.set()
|
|
210
|
+
logger.warning("Trace export queue full; span dropped")
|
|
211
|
+
|
|
212
|
+
def payload(self, spans: list[_Span]) -> dict[str, Any]:
|
|
213
|
+
if self.provider == "phoenix":
|
|
214
|
+
return {"data": [_phoenix(s) for s in spans]}
|
|
215
|
+
return {"resourceSpans": [{
|
|
216
|
+
"resource": {"attributes": [{"key": "service.name", "value": {"stringValue": self.service}}]},
|
|
217
|
+
"scopeSpans": [{"scope": {"name": INSTRUMENTATION_NAME},
|
|
218
|
+
"spans": [_langfuse(span) for span in spans]}],
|
|
219
|
+
}]}
|
|
220
|
+
|
|
221
|
+
def _send(self, client: httpx.Client, spans: list[_Span]) -> None:
|
|
222
|
+
# Build completed spans once. V4 does not guarantee deduplication on replay.
|
|
223
|
+
payload = self.payload(spans)
|
|
224
|
+
for attempt in range(3):
|
|
225
|
+
try:
|
|
226
|
+
response = client.post(self.url, headers=self.headers, json=payload)
|
|
227
|
+
retryable = response.status_code in (429, 502, 503, 504)
|
|
228
|
+
if self.provider == "phoenix" and response.status_code >= 500:
|
|
229
|
+
retryable = True
|
|
230
|
+
if retryable:
|
|
231
|
+
if attempt < 2:
|
|
232
|
+
time.sleep(0.2 * 2 ** attempt)
|
|
233
|
+
continue
|
|
234
|
+
response.raise_for_status()
|
|
235
|
+
result = response.json() if response.content else {}
|
|
236
|
+
if self.provider == "langfuse":
|
|
237
|
+
if response.status_code != 200:
|
|
238
|
+
raise ValueError("unexpected OTLP response status")
|
|
239
|
+
partial = result.get("partialSuccess", {})
|
|
240
|
+
if int(partial.get("rejectedSpans", 0)):
|
|
241
|
+
raise ValueError("OTLP ingestion rejected spans")
|
|
242
|
+
if partial.get("errorMessage"):
|
|
243
|
+
logger.warning("Langfuse accepted trace export with an ingestion warning")
|
|
244
|
+
elif result.get("total_queued") != len(spans):
|
|
245
|
+
raise ValueError("ingestion did not queue all spans")
|
|
246
|
+
return
|
|
247
|
+
except httpx.TransportError as exc:
|
|
248
|
+
# A read/write failure may follow a successful ingestion. Replaying
|
|
249
|
+
# that request can create duplicate observations on the v4 read path.
|
|
250
|
+
if self.provider == "langfuse" and not isinstance(
|
|
251
|
+
exc, (httpx.ConnectError, httpx.ConnectTimeout, httpx.PoolTimeout)
|
|
252
|
+
):
|
|
253
|
+
raise
|
|
254
|
+
if attempt < 2:
|
|
255
|
+
time.sleep(0.2 * 2 ** attempt)
|
|
256
|
+
continue
|
|
257
|
+
raise
|
|
258
|
+
|
|
259
|
+
def _run(self) -> None:
|
|
260
|
+
try:
|
|
261
|
+
with httpx.Client(timeout=10, follow_redirects=False) as client:
|
|
262
|
+
while not self.stopping.is_set() or not self.queue.empty():
|
|
263
|
+
try:
|
|
264
|
+
item = self.queue.get(timeout=0.1)
|
|
265
|
+
except queue.Empty:
|
|
266
|
+
continue
|
|
267
|
+
if isinstance(item, _Barrier):
|
|
268
|
+
item.ok = not self.failed.is_set()
|
|
269
|
+
item.ready.set()
|
|
270
|
+
continue
|
|
271
|
+
spans = [item]
|
|
272
|
+
barrier = None
|
|
273
|
+
while len(spans) < 64:
|
|
274
|
+
try:
|
|
275
|
+
next_item = self.queue.get_nowait()
|
|
276
|
+
except queue.Empty:
|
|
277
|
+
break
|
|
278
|
+
if isinstance(next_item, _Barrier):
|
|
279
|
+
barrier = next_item
|
|
280
|
+
break
|
|
281
|
+
spans.append(next_item)
|
|
282
|
+
try:
|
|
283
|
+
self._send(client, spans)
|
|
284
|
+
except Exception as exc:
|
|
285
|
+
self.failed.set()
|
|
286
|
+
# Response bodies, URLs and exception messages may contain secrets.
|
|
287
|
+
logger.warning("%s trace export failed (%s)", self.provider, type(exc).__name__)
|
|
288
|
+
if barrier is not None:
|
|
289
|
+
barrier.ok = not self.failed.is_set()
|
|
290
|
+
barrier.ready.set()
|
|
291
|
+
except Exception as exc:
|
|
292
|
+
self.failed.set()
|
|
293
|
+
logger.warning("Trace export worker stopped (%s)", type(exc).__name__)
|
|
294
|
+
|
|
295
|
+
def flush(self, timeout: float) -> bool:
|
|
296
|
+
barrier = _Barrier()
|
|
297
|
+
started = time.monotonic()
|
|
298
|
+
try:
|
|
299
|
+
self.queue.put(barrier, timeout=timeout)
|
|
300
|
+
except queue.Full:
|
|
301
|
+
return False
|
|
302
|
+
return barrier.ready.wait(max(0, timeout - (time.monotonic() - started))) and barrier.ok
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def configure(settings: TraceConfig) -> bool:
|
|
306
|
+
"""Start one exporter from `[trace]`. Credentials are resolved by the config."""
|
|
307
|
+
global _exporter, _record_content
|
|
308
|
+
if _exporter is not None or not settings.enabled:
|
|
309
|
+
return _exporter is not None
|
|
310
|
+
values = settings.resolved()
|
|
311
|
+
url = values["url"]
|
|
312
|
+
headers = {}
|
|
313
|
+
if settings.provider == "langfuse":
|
|
314
|
+
public, secret = values["public_key"], values["secret_key"]
|
|
315
|
+
if not public or not secret:
|
|
316
|
+
logger.warning("Langfuse trace credentials are missing; tracing disabled")
|
|
317
|
+
return False
|
|
318
|
+
import base64
|
|
319
|
+
headers["Authorization"] = "Basic " + base64.b64encode(f"{public}:{secret}".encode()).decode()
|
|
320
|
+
headers["x-langfuse-ingestion-version"] = "4"
|
|
321
|
+
headers["Accept"] = "application/json"
|
|
322
|
+
path = "/api/public/otel/v1/traces"
|
|
323
|
+
else:
|
|
324
|
+
project = values["project_name"]
|
|
325
|
+
if any(c in project for c in "/?#"):
|
|
326
|
+
logger.warning("Invalid Phoenix trace project name; tracing disabled")
|
|
327
|
+
return False
|
|
328
|
+
if values["api_key"]:
|
|
329
|
+
headers["Authorization"] = f"Bearer {values['api_key']}"
|
|
330
|
+
path = f"/v1/projects/{quote(project, safe='')}/spans"
|
|
331
|
+
try:
|
|
332
|
+
parsed = urlsplit(url)
|
|
333
|
+
if parsed.scheme not in ("http", "https") or not parsed.hostname or parsed.query or parsed.fragment or parsed.username:
|
|
334
|
+
raise ValueError("invalid base URL")
|
|
335
|
+
if parsed.path.rstrip("/").endswith(("/v1/traces", "/api/public/ingestion")):
|
|
336
|
+
raise ValueError("expected application base URL")
|
|
337
|
+
except ValueError:
|
|
338
|
+
logger.warning("Invalid trace base URL; tracing disabled")
|
|
339
|
+
return False
|
|
340
|
+
_exporter = _Exporter(settings.provider, url.rstrip("/") + path, headers, SERVICE_NAME)
|
|
341
|
+
_record_content = bool(settings.content)
|
|
342
|
+
return True
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def enabled() -> bool:
|
|
346
|
+
return _exporter is not None
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def records_content() -> bool:
|
|
350
|
+
return _record_content
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
@contextlib.contextmanager
|
|
354
|
+
def span(name: str, *, kind: str = "internal", attributes: Mapping[str, Any] | None = None) -> Iterator[Recorder]:
|
|
355
|
+
"""Record an operation, inheriting the current task's parent. Off is a no-op."""
|
|
356
|
+
exporter = _exporter
|
|
357
|
+
if exporter is None:
|
|
358
|
+
yield _NO_SPAN
|
|
359
|
+
return
|
|
360
|
+
parent = _current.get()
|
|
361
|
+
recorder = _Span(name, parent.trace_id if parent else secrets.token_hex(16),
|
|
362
|
+
parent.span_id if parent else None, _record_content,
|
|
363
|
+
trace_name=parent.trace_name if parent else name, kind=kind.lower())
|
|
364
|
+
recorder.set(**{"service.name": exporter.service})
|
|
365
|
+
if parent:
|
|
366
|
+
recorder.set(**{k: v for k, v in parent.attributes.items()
|
|
367
|
+
if k in _TRACE_KEYS or k.startswith("langfuse.trace.")})
|
|
368
|
+
recorder.set(**dict(attributes or {}))
|
|
369
|
+
token = _current.set(recorder)
|
|
370
|
+
try:
|
|
371
|
+
yield recorder
|
|
372
|
+
except GeneratorExit:
|
|
373
|
+
# Agent.run closes its generator after a terminal event; this is normal.
|
|
374
|
+
raise
|
|
375
|
+
except BaseException as exc:
|
|
376
|
+
recorder.fail(exc)
|
|
377
|
+
raise
|
|
378
|
+
finally:
|
|
379
|
+
recorder.end = _now()
|
|
380
|
+
_current.reset(token)
|
|
381
|
+
exporter.submit(recorder)
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def current_ids() -> tuple[str, str]:
|
|
385
|
+
current = _current.get() if enabled() else None
|
|
386
|
+
return (current.trace_id, current.span_id) if current else ("", "")
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def flush(timeout: float = 30) -> bool:
|
|
390
|
+
"""Wait for queued exports. False means a timeout or any export failure since configure."""
|
|
391
|
+
return _exporter.flush(timeout) if _exporter else True
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def shutdown(timeout: float = 30) -> None:
|
|
395
|
+
"""Drain queued exports and close the client. Async callers should use to_thread."""
|
|
396
|
+
global _exporter, _record_content
|
|
397
|
+
exporter, _exporter = _exporter, None
|
|
398
|
+
_record_content = False
|
|
399
|
+
if exporter is not None:
|
|
400
|
+
exporter.stopping.set()
|
|
401
|
+
exporter.thread.join(timeout)
|
|
402
|
+
if exporter.thread.is_alive():
|
|
403
|
+
logger.warning("Trace export shutdown timed out; pending spans may be lost")
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
__all__ = ["Recorder", "configure", "current_ids", "enabled", "records_content", "span", "flush", "shutdown"]
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Truncation — the 8192-char forcing function, in one place.
|
|
3
|
+
|
|
4
|
+
Tool output is elided, never LLM-summarised: summarising loses information, eliding does
|
|
5
|
+
not — **provided the full value is somewhere the model can reach**.
|
|
6
|
+
|
|
7
|
+
That proviso is the whole rule, so `hint` is a **complete clause the caller is
|
|
8
|
+
answerable for**, not a variable name this module wraps in backticks and hopes exists.
|
|
9
|
+
With a REPL attached, `tool_out_hint()` names where the payload really is; without one,
|
|
10
|
+
`NARROW_REQUEST_HINT` says to re-call with a tighter range.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
DEFAULT_VIEW_LIMIT = 8192
|
|
16
|
+
"""Chars of tool output the agent sees per call."""
|
|
17
|
+
|
|
18
|
+
HEAD_SHARE = 0.75
|
|
19
|
+
"""Fraction of the budget spent on the head. A traceback's last line, a test summary and
|
|
20
|
+
a `| tail -40` all live at the end, so the tail keeps a real share."""
|
|
21
|
+
|
|
22
|
+
NARROW_REQUEST_HINT = "call again with a narrower offset/limit to see the rest"
|
|
23
|
+
"""Recovery when no REPL is attached: re-call the same tool with a tighter range. Needs
|
|
24
|
+
nothing that may not exist."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def tool_out_hint(output_id: str) -> str:
|
|
28
|
+
"""The better recovery, legal only when the REPL really holds the value.
|
|
29
|
+
|
|
30
|
+
`Harness.invoke` injects the spilled payload into the worker's `tool_out` before the
|
|
31
|
+
model sees the elision. `Runtime.outputs_reachable` decides whether that happened —
|
|
32
|
+
not this module.
|
|
33
|
+
"""
|
|
34
|
+
return f'the whole value is in tool_out["{output_id}"] — slice it with `repl`'
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
_MIN_LIMIT = 32
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def elide(text: str, limit: int, *, hint: str | None = None) -> str:
|
|
41
|
+
"""Keep the head and tail of `text`, drop the middle.
|
|
42
|
+
|
|
43
|
+
The beginning says what ran and the end says how it went; the bulk between is what
|
|
44
|
+
you fetch deliberately rather than by accident.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
text: The full output.
|
|
48
|
+
limit: Char budget for the result, marker included.
|
|
49
|
+
hint: A complete clause saying how to reach what was cut. Only pass one you know
|
|
50
|
+
is true — a hint naming a place the model cannot reach turns a visible gap
|
|
51
|
+
into a failed retry.
|
|
52
|
+
"""
|
|
53
|
+
if limit <= 0:
|
|
54
|
+
return ""
|
|
55
|
+
if len(text) <= limit:
|
|
56
|
+
return text
|
|
57
|
+
|
|
58
|
+
limit = max(limit, _MIN_LIMIT)
|
|
59
|
+
where = f" — {hint}" if hint else ""
|
|
60
|
+
|
|
61
|
+
# The marker eats into the budget and its length depends on the count it prints.
|
|
62
|
+
# Two passes is enough for the digit count to settle.
|
|
63
|
+
marker = ""
|
|
64
|
+
body = limit
|
|
65
|
+
for _ in range(2):
|
|
66
|
+
marker = f"\n… [{len(text) - body:,} chars elided{where}] …\n"
|
|
67
|
+
body = limit - len(marker)
|
|
68
|
+
if body <= 0:
|
|
69
|
+
return text[:limit]
|
|
70
|
+
|
|
71
|
+
head = int(body * HEAD_SHARE)
|
|
72
|
+
tail = body - head
|
|
73
|
+
return text[:head] + marker + (text[-tail:] if tail else "")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
__all__ = ["DEFAULT_VIEW_LIMIT", "HEAD_SHARE", "NARROW_REQUEST_HINT", "elide", "tool_out_hint"]
|