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.
Files changed (111) hide show
  1. stcode/__init__.py +10 -0
  2. stcode/cli/__init__.py +9 -0
  3. stcode/cli/bootstrap.py +70 -0
  4. stcode/cli/labels.py +621 -0
  5. stcode/cli/logic/__init__.py +0 -0
  6. stcode/cli/logic/commands.py +36 -0
  7. stcode/cli/logic/completion.py +43 -0
  8. stcode/cli/logic/connection.py +46 -0
  9. stcode/cli/logic/requests.py +18 -0
  10. stcode/cli/logic/sessions.py +67 -0
  11. stcode/cli/logic/settings.py +35 -0
  12. stcode/cli/logic/state.py +43 -0
  13. stcode/cli/logic/workspace.py +17 -0
  14. stcode/cli/main.py +290 -0
  15. stcode/cli/models.py +61 -0
  16. stcode/cli/services/__init__.py +0 -0
  17. stcode/cli/services/client.py +393 -0
  18. stcode/cli/services/launcher.py +159 -0
  19. stcode/cli/services/paths.py +18 -0
  20. stcode/cli/services/preferences.py +107 -0
  21. stcode/cli/ui/__init__.py +0 -0
  22. stcode/cli/ui/app.py +136 -0
  23. stcode/cli/ui/app.tcss +3 -0
  24. stcode/cli/ui/components/__init__.py +0 -0
  25. stcode/cli/ui/components/banner.py +78 -0
  26. stcode/cli/ui/components/cards.py +229 -0
  27. stcode/cli/ui/components/prompt.py +225 -0
  28. stcode/cli/ui/components/status.py +95 -0
  29. stcode/cli/ui/components/transcript/__init__.py +51 -0
  30. stcode/cli/ui/components/transcript/entries.py +232 -0
  31. stcode/cli/ui/components/transcript/formatting.py +188 -0
  32. stcode/cli/ui/components/transcript/view.py +101 -0
  33. stcode/cli/ui/screens/__init__.py +0 -0
  34. stcode/cli/ui/screens/chat/__init__.py +0 -0
  35. stcode/cli/ui/screens/chat/chat.tcss +12 -0
  36. stcode/cli/ui/screens/chat/presenter.py +187 -0
  37. stcode/cli/ui/screens/chat/screen.py +682 -0
  38. stcode/cli/ui/screens/connect.py +126 -0
  39. stcode/cli/ui/screens/connect.tcss +49 -0
  40. stcode/cli/ui/screens/dialog.tcss +40 -0
  41. stcode/cli/ui/screens/sessions.py +105 -0
  42. stcode/cli/ui/screens/settings.py +137 -0
  43. stcode/cli/ui/screens/settings.tcss +69 -0
  44. stcode/cli/ui/screens/trust.py +50 -0
  45. stcode/cli/ui/theme.py +221 -0
  46. stcode/core/__main__.py +7 -0
  47. stcode/core/agent/__init__.py +41 -0
  48. stcode/core/agent/agent.py +651 -0
  49. stcode/core/agent/events.py +97 -0
  50. stcode/core/agent/supervisor.py +226 -0
  51. stcode/core/agent/task.py +120 -0
  52. stcode/core/common/__init__.py +21 -0
  53. stcode/core/common/compat.py +27 -0
  54. stcode/core/common/ids.py +43 -0
  55. stcode/core/common/tools.py +50 -0
  56. stcode/core/common/trace.py +406 -0
  57. stcode/core/common/truncate.py +76 -0
  58. stcode/core/configs.py +490 -0
  59. stcode/core/daemon/__init__.py +32 -0
  60. stcode/core/daemon/autonomy.py +71 -0
  61. stcode/core/daemon/main.py +126 -0
  62. stcode/core/daemon/protocol.py +499 -0
  63. stcode/core/daemon/runner.py +359 -0
  64. stcode/core/daemon/server.py +682 -0
  65. stcode/core/daemon/workspace.py +88 -0
  66. stcode/core/harness/__init__.py +81 -0
  67. stcode/core/harness/approvals.py +85 -0
  68. stcode/core/harness/context.py +260 -0
  69. stcode/core/harness/errors.py +40 -0
  70. stcode/core/harness/harness.py +417 -0
  71. stcode/core/harness/mcp.py +530 -0
  72. stcode/core/harness/outputs.py +99 -0
  73. stcode/core/harness/prompts/__init__.py +138 -0
  74. stcode/core/harness/prompts/execute.py +51 -0
  75. stcode/core/harness/prompts/plan.py +57 -0
  76. stcode/core/harness/prompts/sections.py +238 -0
  77. stcode/core/harness/skills/__init__.py +30 -0
  78. stcode/core/harness/skills/loader.py +247 -0
  79. stcode/core/harness/tools/__init__.py +111 -0
  80. stcode/core/harness/tools/base.py +640 -0
  81. stcode/core/harness/tools/files.py +276 -0
  82. stcode/core/harness/tools/interact.py +72 -0
  83. stcode/core/harness/tools/registry.py +113 -0
  84. stcode/core/harness/tools/repl.py +97 -0
  85. stcode/core/harness/tools/schema.py +186 -0
  86. stcode/core/harness/tools/search.py +356 -0
  87. stcode/core/harness/tools/shell.py +372 -0
  88. stcode/core/harness/tools/skill.py +66 -0
  89. stcode/core/harness/tools/todo.py +62 -0
  90. stcode/core/harness/tools/web.py +163 -0
  91. stcode/core/providers/__init__.py +31 -0
  92. stcode/core/providers/anthropic_claude.py +243 -0
  93. stcode/core/providers/base.py +73 -0
  94. stcode/core/providers/gateway.py +267 -0
  95. stcode/core/providers/google_gemini.py +225 -0
  96. stcode/core/providers/openai_gpt.py +250 -0
  97. stcode/core/providers/registry.py +46 -0
  98. stcode/core/providers/types.py +148 -0
  99. stcode/core/repl/__init__.py +19 -0
  100. stcode/core/repl/_worker.py +179 -0
  101. stcode/core/repl/client.py +374 -0
  102. stcode/core/session/__init__.py +39 -0
  103. stcode/core/session/session.py +493 -0
  104. stcode/core/team/__init__.py +38 -0
  105. stcode/core/team/mailbox.py +249 -0
  106. stcode/core/team/tools.py +145 -0
  107. stcode-0.0.1a0.dist-info/METADATA +229 -0
  108. stcode-0.0.1a0.dist-info/RECORD +111 -0
  109. stcode-0.0.1a0.dist-info/WHEEL +4 -0
  110. stcode-0.0.1a0.dist-info/entry_points.txt +3 -0
  111. 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"]