symphony-code 0.0.1__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 (169) hide show
  1. coding_agent/__init__.py +20 -0
  2. coding_agent/_lazy.py +17 -0
  3. coding_agent/addons/__init__.py +35 -0
  4. coding_agent/addons/approvals.py +224 -0
  5. coding_agent/addons/compaction/__init__.py +36 -0
  6. coding_agent/addons/compaction/addon.py +88 -0
  7. coding_agent/addons/compaction/compactor.py +138 -0
  8. coding_agent/addons/compaction/prompts.py +20 -0
  9. coding_agent/addons/compaction/transcript.py +118 -0
  10. coding_agent/addons/evaluation/__init__.py +68 -0
  11. coding_agent/addons/evaluation/addon.py +199 -0
  12. coding_agent/addons/evaluation/evaluators.py +245 -0
  13. coding_agent/addons/evaluation/handlers.py +66 -0
  14. coding_agent/addons/evaluation/policy.py +277 -0
  15. coding_agent/addons/evaluation/protocol.py +192 -0
  16. coding_agent/addons/evaluation/state.py +284 -0
  17. coding_agent/addons/langfuse/__init__.py +24 -0
  18. coding_agent/addons/langfuse/addon.py +465 -0
  19. coding_agent/addons/langfuse/install.py +79 -0
  20. coding_agent/addons/langfuse/serialize.py +158 -0
  21. coding_agent/addons/learning/__init__.py +33 -0
  22. coding_agent/addons/learning/addon.py +181 -0
  23. coding_agent/addons/learning/loop.py +247 -0
  24. coding_agent/addons/learning/prompts.py +26 -0
  25. coding_agent/addons/learning/sanitize.py +64 -0
  26. coding_agent/addons/learning/store.py +522 -0
  27. coding_agent/addons/manager/__init__.py +18 -0
  28. coding_agent/addons/manager/bridge.py +74 -0
  29. coding_agent/addons/manager/discovery.py +34 -0
  30. coding_agent/addons/manager/report.py +33 -0
  31. coding_agent/addons/manager/runtime.py +76 -0
  32. coding_agent/addons/manager/tool_changes.py +16 -0
  33. coding_agent/addons/persistence/__init__.py +41 -0
  34. coding_agent/addons/persistence/active.py +132 -0
  35. coding_agent/addons/persistence/artifacts.py +110 -0
  36. coding_agent/addons/persistence/collection.py +166 -0
  37. coding_agent/addons/persistence/compatibility.py +30 -0
  38. coding_agent/addons/persistence/images.py +133 -0
  39. coding_agent/addons/persistence/jsonl.py +1195 -0
  40. coding_agent/addons/persistence/presentation.py +104 -0
  41. coding_agent/addons/plugins/__init__.py +19 -0
  42. coding_agent/addons/plugins/install.py +37 -0
  43. coding_agent/addons/plugins/manager.py +140 -0
  44. coding_agent/addons/plugins/models.py +50 -0
  45. coding_agent/addons/skills/__init__.py +28 -0
  46. coding_agent/addons/skills/addon.py +26 -0
  47. coding_agent/addons/skills/args.py +78 -0
  48. coding_agent/addons/skills/bundled_skills/addon-authoring/SKILL.md +77 -0
  49. coding_agent/addons/skills/bundled_skills/context-budget/SKILL.md +12 -0
  50. coding_agent/addons/skills/bundled_skills/orient-first/SKILL.md +16 -0
  51. coding_agent/addons/skills/bundled_skills/security-boundary/SKILL.md +16 -0
  52. coding_agent/addons/skills/bundled_skills/skill-discipline/SKILL.md +13 -0
  53. coding_agent/addons/skills/bundled_skills/surgical-edit/SKILL.md +11 -0
  54. coding_agent/addons/skills/bundled_skills/systematic-debug/SKILL.md +18 -0
  55. coding_agent/addons/skills/bundled_skills/verify-before-done/SKILL.md +11 -0
  56. coding_agent/addons/skills/frontmatter.py +57 -0
  57. coding_agent/addons/skills/models.py +57 -0
  58. coding_agent/addons/skills/registry.py +109 -0
  59. coding_agent/addons/skills/tool.py +54 -0
  60. coding_agent/addons/voice/__init__.py +5 -0
  61. coding_agent/addons/voice/addon.py +106 -0
  62. coding_agent/addons/voice/credentials.py +42 -0
  63. coding_agent/addons/voice/playback.py +42 -0
  64. coding_agent/addons/voice/speak.py +287 -0
  65. coding_agent/addons/voice/stream.py +651 -0
  66. coding_agent/addons/voice/summary.py +126 -0
  67. coding_agent/agent.py +523 -0
  68. coding_agent/bench/__init__.py +1 -0
  69. coding_agent/bench/cli.py +200 -0
  70. coding_agent/bench/patch.py +78 -0
  71. coding_agent/commands/__init__.py +5 -0
  72. coding_agent/commands/catalog.py +179 -0
  73. coding_agent/commands/jev_argument.py +40 -0
  74. coding_agent/config/__init__.py +51 -0
  75. coding_agent/config/config.py +376 -0
  76. coding_agent/config/credentials.py +153 -0
  77. coding_agent/config/personalities.json +34 -0
  78. coding_agent/config/personalities.py +170 -0
  79. coding_agent/config/prompts.py +67 -0
  80. coding_agent/protocols/__init__.py +5 -0
  81. coding_agent/protocols/acp/README.md +36 -0
  82. coding_agent/protocols/acp/__init__.py +27 -0
  83. coding_agent/protocols/acp/mapping.py +164 -0
  84. coding_agent/protocols/acp/server.py +261 -0
  85. coding_agent/protocols/acp/transport.py +72 -0
  86. coding_agent/protocols/stdio/README.md +36 -0
  87. coding_agent/protocols/stdio/__init__.py +37 -0
  88. coding_agent/protocols/stdio/catalog.py +49 -0
  89. coding_agent/protocols/stdio/commands.py +218 -0
  90. coding_agent/protocols/stdio/server.py +181 -0
  91. coding_agent/protocols/stdio/transport.py +71 -0
  92. coding_agent/py.typed +0 -0
  93. coding_agent/resources/__init__.py +5 -0
  94. coding_agent/resources/usage.py +264 -0
  95. coding_agent/run/__init__.py +1 -0
  96. coding_agent/run/cli.py +99 -0
  97. coding_agent/run/detach.py +37 -0
  98. coding_agent/run/interrupted.py +67 -0
  99. coding_agent/run/log_sink.py +46 -0
  100. coding_agent/tools/__init__.py +73 -0
  101. coding_agent/tools/ask_user.py +61 -0
  102. coding_agent/tools/base.py +136 -0
  103. coding_agent/tools/bash.py +288 -0
  104. coding_agent/tools/bash_jobs.py +116 -0
  105. coding_agent/tools/generate_image.py +123 -0
  106. coding_agent/tools/memory.py +100 -0
  107. coding_agent/tools/patch.py +218 -0
  108. coding_agent/tools/read_file.py +167 -0
  109. coding_agent/tools/search.py +433 -0
  110. coding_agent/tools/web_search.py +151 -0
  111. coding_agent/tools/write_file.py +40 -0
  112. coding_agent/tui/EFFICIENCY_PLAN.md +25 -0
  113. coding_agent/tui/__init__.py +22 -0
  114. coding_agent/tui/__main__.py +122 -0
  115. coding_agent/tui/app.py +456 -0
  116. coding_agent/tui/chrome/__init__.py +33 -0
  117. coding_agent/tui/chrome/footer.py +274 -0
  118. coding_agent/tui/chrome/topbar.py +259 -0
  119. coding_agent/tui/commands/__init__.py +45 -0
  120. coding_agent/tui/commands/catalog.py +35 -0
  121. coding_agent/tui/commands/manager.py +557 -0
  122. coding_agent/tui/composer/__init__.py +18 -0
  123. coding_agent/tui/composer/input.py +219 -0
  124. coding_agent/tui/composer/slash_menu.py +341 -0
  125. coding_agent/tui/composer/surface.py +330 -0
  126. coding_agent/tui/composer/voice.py +267 -0
  127. coding_agent/tui/composer/voice_mode.py +491 -0
  128. coding_agent/tui/runtime/__init__.py +23 -0
  129. coding_agent/tui/runtime/events.py +728 -0
  130. coding_agent/tui/runtime/sink.py +115 -0
  131. coding_agent/tui/runtime/state.py +145 -0
  132. coding_agent/tui/runtime/subagent.py +531 -0
  133. coding_agent/tui/runtime/turn.py +206 -0
  134. coding_agent/tui/screens/__init__.py +54 -0
  135. coding_agent/tui/screens/ask.py +69 -0
  136. coding_agent/tui/screens/context.py +195 -0
  137. coding_agent/tui/screens/dashboard.py +91 -0
  138. coding_agent/tui/screens/diff.py +302 -0
  139. coding_agent/tui/screens/extensions.py +68 -0
  140. coding_agent/tui/screens/file_selector.py +167 -0
  141. coding_agent/tui/screens/history.py +458 -0
  142. coding_agent/tui/screens/langfuse.py +142 -0
  143. coding_agent/tui/screens/learning.py +113 -0
  144. coding_agent/tui/screens/modal.py +131 -0
  145. coding_agent/tui/screens/onboard.py +455 -0
  146. coding_agent/tui/screens/resume.py +163 -0
  147. coding_agent/tui/theme/__init__.py +49 -0
  148. coding_agent/tui/theme/load.py +325 -0
  149. coding_agent/tui/theme/style.py +95 -0
  150. coding_agent/tui/theme/theme.toml +1687 -0
  151. coding_agent/tui/tools/__init__.py +53 -0
  152. coding_agent/tui/tools/activity.py +184 -0
  153. coding_agent/tui/tools/calls.py +560 -0
  154. coding_agent/tui/tools/code_view.py +52 -0
  155. coding_agent/tui/tools/images.py +250 -0
  156. coding_agent/tui/tools/labels.py +135 -0
  157. coding_agent/tui/tools/modal.py +137 -0
  158. coding_agent/tui/tools/partial_args.py +19 -0
  159. coding_agent/tui/tools/snapshots.py +382 -0
  160. coding_agent/tui/transcript/__init__.py +36 -0
  161. coding_agent/tui/transcript/messages.py +408 -0
  162. coding_agent/tui/transcript/process.py +504 -0
  163. coding_agent/tui/transcript/surface.py +345 -0
  164. coding_agent/tui/transcript/thinking_markdown.py +314 -0
  165. symphony_code-0.0.1.dist-info/METADATA +66 -0
  166. symphony_code-0.0.1.dist-info/RECORD +169 -0
  167. symphony_code-0.0.1.dist-info/WHEEL +4 -0
  168. symphony_code-0.0.1.dist-info/entry_points.txt +5 -0
  169. symphony_code-0.0.1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,465 @@
1
+ """Harness add-on that records coding-agent runs in Langfuse.
2
+
3
+ One Langfuse trace is one ``CoreHarness.run`` (one agent task). Nested
4
+ observations capture the conversation actually sent on each model turn, each
5
+ tool call, and compaction. Credentials come from the environment
6
+ (``LANGFUSE_PUBLIC_KEY`` / ``LANGFUSE_SECRET_KEY`` / optional
7
+ ``LANGFUSE_BASE_URL``). Missing keys or a missing SDK keep the add-on silent.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import logging
13
+ import os
14
+ import random
15
+ from typing import Any, Optional
16
+
17
+ from core_ai.types import Message
18
+ from core_harness.addons import Addon
19
+
20
+ from coding_agent.config import LangfuseConfig
21
+ from coding_agent.addons.langfuse.serialize import (
22
+ serialize_messages,
23
+ serialize_run_output,
24
+ serialize_task,
25
+ serialize_tool_arguments,
26
+ serialize_tool_result,
27
+ serialize_turn_output,
28
+ usage_payload,
29
+ )
30
+
31
+ logger = logging.getLogger(__name__)
32
+
33
+
34
+ def _env_enabled() -> bool:
35
+ public_key = os.environ.get("LANGFUSE_PUBLIC_KEY", "").strip()
36
+ secret_key = os.environ.get("LANGFUSE_SECRET_KEY", "").strip()
37
+ return bool(public_key and secret_key)
38
+
39
+
40
+ class LangfuseAddon(Addon):
41
+ """Trace agent runs, model turns, and tools when Langfuse is configured."""
42
+
43
+ name = "langfuse"
44
+
45
+ def __init__(
46
+ self,
47
+ *,
48
+ enabled: bool = True,
49
+ sample_rate: float = 1.0,
50
+ max_payload_chars: int = 32_000,
51
+ client: Any = None,
52
+ ) -> None:
53
+ self.enabled = enabled
54
+ self.sample_rate = sample_rate
55
+ self.max_payload_chars = max_payload_chars
56
+ self._injected_client = client
57
+ self._client: Any = None
58
+ self._harness: Any = None
59
+ self._run_observation: Any = None
60
+ self._turn_observation: Any = None
61
+ self._tool_observations: dict[str, Any] = {}
62
+
63
+ def attach(self, harness: Any) -> None:
64
+ self._harness = harness
65
+ if self._injected_client is not None:
66
+ self._client = self._injected_client
67
+ return
68
+ if not self.enabled or not _env_enabled():
69
+ return
70
+ try:
71
+ from langfuse import Langfuse
72
+ except ImportError:
73
+ logger.warning(
74
+ "Langfuse tracing is enabled but the langfuse package is not installed. "
75
+ "Run /langfuse in the TUI to install it, or: "
76
+ "uv sync --package symphony-code --extra langfuse"
77
+ )
78
+ return
79
+ # Pass credentials explicitly instead of relying on the SDK to read
80
+ # them from its own environment. This matters when the TUI has just
81
+ # written ~/.symphony/.env (or when the addon is used as a library).
82
+ kwargs: dict[str, Any] = {
83
+ "public_key": os.environ["LANGFUSE_PUBLIC_KEY"].strip(),
84
+ "secret_key": os.environ["LANGFUSE_SECRET_KEY"].strip(),
85
+ }
86
+ # LANGFUSE_HOST is the name used by the Langfuse SDK/documentation;
87
+ # BASE_URL is retained for Symphony's existing setup screen.
88
+ base_url = (
89
+ os.environ.get("LANGFUSE_BASE_URL", "").strip()
90
+ or os.environ.get("LANGFUSE_HOST", "").strip()
91
+ )
92
+ if base_url:
93
+ kwargs["base_url"] = base_url
94
+ kwargs["host"] = base_url
95
+ try:
96
+ self._client = Langfuse(**kwargs)
97
+ except TypeError:
98
+ kwargs.pop("base_url", None)
99
+ try:
100
+ self._client = Langfuse(**kwargs)
101
+ except Exception:
102
+ logger.exception("Failed to initialize Langfuse client")
103
+ self._client = None
104
+ except Exception:
105
+ logger.exception("Failed to initialize Langfuse client")
106
+ self._client = None
107
+
108
+ def fork_for_child(self, parent_harness: Any) -> LangfuseAddon:
109
+ """Inherit: child runs get their own tracer sharing this client."""
110
+ del parent_harness
111
+ return LangfuseAddon(
112
+ enabled=self.enabled,
113
+ sample_rate=self.sample_rate,
114
+ max_payload_chars=self.max_payload_chars,
115
+ client=self._injected_client if self._injected_client is not None else self._client,
116
+ )
117
+
118
+ def _active(self) -> bool:
119
+ return self._client is not None and self._run_observation is not None
120
+
121
+ def _observation_kwargs(self, **kwargs: Any) -> dict[str, Any]:
122
+ """Drop keys Langfuse v3/v4 ``start_observation`` / ``update`` reject."""
123
+ blocked = {"session_id", "user_id", "tags", "trace_id"}
124
+ return {key: value for key, value in kwargs.items() if key not in blocked and value is not None}
125
+
126
+ def _trace_context(self, parent: Any) -> Optional[dict[str, str]]:
127
+ if parent is None:
128
+ return None
129
+ trace_id = getattr(parent, "trace_id", None)
130
+ span_id = getattr(parent, "id", None)
131
+ if not trace_id:
132
+ return None
133
+ context: dict[str, str] = {"trace_id": str(trace_id)}
134
+ if span_id:
135
+ context["parent_span_id"] = str(span_id)
136
+ return context
137
+
138
+ def _call_start(self, host: Any, *, name: str, as_type: str, payload: dict[str, Any]) -> Any:
139
+ starter = getattr(host, "start_observation", None)
140
+ if not callable(starter):
141
+ return None
142
+ try:
143
+ return starter(name=name, as_type=as_type, **payload)
144
+ except TypeError:
145
+ try:
146
+ return starter(name=name, **payload)
147
+ except TypeError:
148
+ trimmed = dict(payload)
149
+ trimmed.pop("trace_context", None)
150
+ try:
151
+ return starter(name=name, as_type=as_type, **trimmed)
152
+ except Exception:
153
+ logger.exception("Langfuse start_observation failed")
154
+ return None
155
+ except Exception:
156
+ logger.exception("Langfuse start_observation failed")
157
+ return None
158
+ except Exception:
159
+ logger.exception("Langfuse start_observation failed")
160
+ return None
161
+
162
+ def _start(
163
+ self,
164
+ *,
165
+ name: str,
166
+ as_type: str,
167
+ parent: Any = None,
168
+ **kwargs: Any,
169
+ ) -> Any:
170
+ payload = self._observation_kwargs(**kwargs)
171
+ observation = None
172
+ if parent is not None:
173
+ observation = self._call_start(parent, name=name, as_type=as_type, payload=payload)
174
+ if observation is None and self._client is not None:
175
+ context = self._trace_context(parent)
176
+ client_payload = dict(payload)
177
+ if context:
178
+ client_payload["trace_context"] = context
179
+ observation = self._call_start(
180
+ self._client, name=name, as_type=as_type, payload=client_payload
181
+ )
182
+ self._apply_trace_attributes(observation)
183
+ return observation
184
+
185
+ def _update(self, observation: Any, **kwargs: Any) -> None:
186
+ if observation is None or not kwargs:
187
+ return
188
+ updater = getattr(observation, "update", None)
189
+ if not callable(updater):
190
+ return
191
+ payload = self._observation_kwargs(**kwargs)
192
+ if not payload:
193
+ return
194
+ try:
195
+ updater(**payload)
196
+ except TypeError:
197
+ logger.exception("Langfuse observation update failed")
198
+ except Exception:
199
+ logger.exception("Langfuse observation update failed")
200
+
201
+ def _end(self, observation: Any, **kwargs: Any) -> None:
202
+ if observation is None:
203
+ return
204
+ if kwargs:
205
+ self._update(observation, **kwargs)
206
+ ender = getattr(observation, "end", None)
207
+ if callable(ender):
208
+ try:
209
+ ender()
210
+ except Exception:
211
+ logger.exception("Langfuse observation end failed")
212
+
213
+ def _session_id(self) -> Optional[str]:
214
+ if self._harness is None:
215
+ return None
216
+ return getattr(self._harness, "_active_session_id", None) or getattr(self._harness, "session_id", None)
217
+
218
+ def _run_id(self) -> Optional[str]:
219
+ if self._harness is None:
220
+ return None
221
+ return getattr(self._harness, "_active_run_id", None)
222
+
223
+ def _model_id(self) -> Optional[str]:
224
+ if self._harness is None:
225
+ return None
226
+ return getattr(self._harness, "model_id", None)
227
+
228
+ def _metadata(self, extra: Optional[dict[str, Any]] = None) -> dict[str, Any]:
229
+ payload: dict[str, Any] = {
230
+ "agent": "symphony-code",
231
+ "model_id": self._model_id(),
232
+ "run_id": self._run_id(),
233
+ "agent_id": getattr(self._harness, "agent_id", None) if self._harness else None,
234
+ "parent_id": getattr(self._harness, "parent_id", None) if self._harness else None,
235
+ }
236
+ if extra:
237
+ payload.update(extra)
238
+ return {key: value for key, value in payload.items() if value is not None}
239
+
240
+ def _apply_trace_attributes(self, observation: Any) -> None:
241
+ """Attach session_id without passing it to ``start_observation``.
242
+
243
+ Langfuse v4 ``start_observation`` rejects ``session_id`` (that TypeError
244
+ was swallowed, so every run created zero traces). v4 stores the id on
245
+ the OTEL span as ``session.id``; v3 used ``update_trace``.
246
+ """
247
+ if observation is None:
248
+ return
249
+ session_id = self._session_id()
250
+ if not session_id:
251
+ return
252
+ otel_span = getattr(observation, "_otel_span", None)
253
+ set_attribute = getattr(otel_span, "set_attribute", None)
254
+ if callable(set_attribute):
255
+ try:
256
+ set_attribute("session.id", session_id)
257
+ except Exception:
258
+ logger.exception("Langfuse session attribute failed")
259
+ setter = getattr(observation, "update_trace", None)
260
+ if callable(setter):
261
+ try:
262
+ setter(session_id=session_id)
263
+ except Exception:
264
+ logger.exception("Langfuse update_trace failed")
265
+
266
+ async def before_run(self, **payload: Any) -> None:
267
+ if self._client is None:
268
+ return
269
+ if self.sample_rate < 1.0 and random.random() >= self.sample_rate:
270
+ return
271
+ messages = payload.get("messages") or []
272
+ task = ""
273
+ for message in reversed(list(messages)):
274
+ if isinstance(message, Message) and message.role == "user":
275
+ task = serialize_task(message.content, max_chars=self.max_payload_chars)
276
+ break
277
+ self._run_observation = self._start(
278
+ name="coding-agent-run",
279
+ as_type="span",
280
+ input=task,
281
+ metadata=self._metadata({"message_count": len(messages)}),
282
+ )
283
+ self._update(
284
+ self._run_observation,
285
+ input=task,
286
+ metadata=self._metadata({"message_count": len(messages)}),
287
+ )
288
+
289
+ async def before_turn(self, **payload: Any) -> None:
290
+ if not self._active():
291
+ return
292
+ messages = payload.get("messages") or []
293
+ turn = payload.get("turn")
294
+ serialized = serialize_messages(messages, max_chars=self.max_payload_chars)
295
+ self._end_turn()
296
+ self._turn_observation = self._start(
297
+ name="model-turn",
298
+ as_type="generation",
299
+ parent=self._run_observation,
300
+ model=self._model_id(),
301
+ input=serialized,
302
+ metadata=self._metadata({"turn": turn, "message_count": len(messages)}),
303
+ )
304
+ self._update(
305
+ self._turn_observation,
306
+ input=serialized,
307
+ metadata=self._metadata({"turn": turn, "message_count": len(messages)}),
308
+ )
309
+
310
+ async def after_turn(self, **payload: Any) -> None:
311
+ if not self._active():
312
+ return
313
+ messages = payload.get("messages") or []
314
+ assistant: Any = ""
315
+ tool_calls: list[Any] = []
316
+ for message in reversed(list(messages)):
317
+ if isinstance(message, Message) and message.role == "assistant":
318
+ assistant = message.content
319
+ tool_calls = list(message.tool_calls or [])
320
+ break
321
+ self._end_turn(
322
+ output=serialize_turn_output(
323
+ assistant, tool_calls, max_chars=self.max_payload_chars
324
+ ),
325
+ metadata=self._metadata({
326
+ "turn": payload.get("turn"),
327
+ "had_tool_calls": payload.get("had_tool_calls"),
328
+ }),
329
+ )
330
+
331
+ async def before_tool(self, **payload: Any) -> Optional[str]:
332
+ if not self._active():
333
+ return None
334
+ tool_call = payload.get("tool_call")
335
+ tool_name = payload.get("tool_name") or getattr(tool_call, "name", "tool")
336
+ arguments = payload.get("arguments")
337
+ if arguments is None:
338
+ arguments = getattr(tool_call, "arguments", {})
339
+ call_id = getattr(tool_call, "id", None) or str(tool_name)
340
+ observation = self._start(
341
+ name=str(tool_name),
342
+ as_type="tool",
343
+ parent=self._run_observation,
344
+ input=serialize_tool_arguments(arguments, max_chars=self.max_payload_chars),
345
+ metadata=self._metadata({"tool_call_id": call_id}),
346
+ )
347
+ self._tool_observations[str(call_id)] = observation
348
+ return None
349
+
350
+ async def on_tool(self, **payload: Any) -> None:
351
+ if not self._active():
352
+ return
353
+ tool_call = payload.get("tool_call")
354
+ call_id = getattr(tool_call, "id", None)
355
+ if not call_id:
356
+ return
357
+ observation = self._tool_observations.pop(str(call_id), None)
358
+ result = payload.get("result")
359
+ status = getattr(result, "status", None)
360
+ level = "ERROR" if status not in (None, "success") else "DEFAULT"
361
+ self._end(
362
+ observation,
363
+ output=serialize_tool_result(result, max_chars=self.max_payload_chars),
364
+ level=level,
365
+ metadata=self._metadata({"tool_name": getattr(tool_call, "name", None), "status": status}),
366
+ )
367
+
368
+ async def on_evaluation(self, **payload: Any) -> None:
369
+ if self._client is None:
370
+ return
371
+ result = payload.get("result")
372
+ phase = payload.get("phase") or "evaluation"
373
+ # Do not pass the run observation as parent: Jev is intentionally a
374
+ # separate Langfuse trace. ``_apply_trace_attributes`` still attaches
375
+ # the harness session id to that trace.
376
+ observation = self._start(
377
+ name="evaluator",
378
+ as_type="span",
379
+ input={"phase": phase, "evaluator": payload.get("evaluator", "jev")},
380
+ metadata=self._metadata({"phase": phase, "evaluator": payload.get("evaluator", "jev")}),
381
+ )
382
+ self._end(
383
+ observation,
384
+ output={
385
+ "status": getattr(result, "status", None),
386
+ "findings": [
387
+ {
388
+ "question": finding.question,
389
+ "kind": finding.kind,
390
+ "label": finding.label,
391
+ "rationale": finding.rationale,
392
+ }
393
+ for finding in (getattr(result, "findings", None) or [])
394
+ ],
395
+ },
396
+ )
397
+ flusher = getattr(self._client, "flush", None)
398
+ if callable(flusher):
399
+ try:
400
+ flusher()
401
+ except Exception:
402
+ logger.exception("Langfuse evaluator flush failed")
403
+
404
+ async def on_compact(self, **payload: Any) -> None:
405
+ if not self._active():
406
+ return
407
+ messages = payload.get("messages") or []
408
+ observation = self._start(
409
+ name="compaction",
410
+ as_type="span",
411
+ parent=self._run_observation,
412
+ input={
413
+ "turn": payload.get("turn"),
414
+ "tokens_used": payload.get("tokens_used"),
415
+ "context_limit": payload.get("context_limit"),
416
+ "context_left": payload.get("context_left"),
417
+ },
418
+ output={"message_count": len(messages)},
419
+ metadata=self._metadata({"turn": payload.get("turn")}),
420
+ )
421
+ self._end(observation)
422
+
423
+ async def after_run(self, **payload: Any) -> None:
424
+ if self._client is None or self._run_observation is None:
425
+ return
426
+ self._end_turn()
427
+ for observation in list(self._tool_observations.values()):
428
+ self._end(observation)
429
+ self._tool_observations.clear()
430
+ result = payload.get("result")
431
+ output = serialize_run_output(
432
+ getattr(result, "output_text", None) or payload.get("task"),
433
+ max_chars=self.max_payload_chars,
434
+ )
435
+ usage = usage_payload(getattr(result, "usage", None))
436
+ update: dict[str, Any] = {"output": output, "metadata": self._metadata()}
437
+ if usage:
438
+ update["usage_details"] = usage
439
+ self._end(self._run_observation, **update)
440
+ self._run_observation = None
441
+ flusher = getattr(self._client, "flush", None)
442
+ if callable(flusher):
443
+ try:
444
+ flusher()
445
+ except Exception:
446
+ logger.exception("Langfuse flush failed")
447
+
448
+ def _end_turn(self, **kwargs: Any) -> None:
449
+ observation = self._turn_observation
450
+ self._turn_observation = None
451
+ self._end(observation, **kwargs)
452
+
453
+
454
+ def langfuse_from_config(config: LangfuseConfig) -> Optional[LangfuseAddon]:
455
+ """Build the add-on when config enables it. Attach still no-ops without keys."""
456
+ if not config.enabled:
457
+ return None
458
+ return LangfuseAddon(
459
+ enabled=True,
460
+ sample_rate=config.sample_rate,
461
+ max_payload_chars=config.max_payload_chars,
462
+ )
463
+
464
+
465
+ __all__ = ["LangfuseAddon", "langfuse_from_config"]
@@ -0,0 +1,79 @@
1
+ """Install the optional Langfuse SDK into the current interpreter."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib
6
+ import os
7
+ import shutil
8
+ import subprocess
9
+ import sys
10
+ from dataclasses import dataclass
11
+
12
+ LANGFUSE_PACKAGE = "langfuse>=3.0.0"
13
+ INSTALL_TIMEOUT_SECONDS = 120
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class InstallResult:
18
+ installed: bool
19
+ already_present: bool
20
+ message: str
21
+
22
+
23
+ def langfuse_available() -> bool:
24
+ """Return True when ``import langfuse`` succeeds in this process."""
25
+ try:
26
+ importlib.import_module("langfuse")
27
+ return True
28
+ except ImportError:
29
+ return False
30
+
31
+
32
+ def ensure_langfuse_installed() -> InstallResult:
33
+ """Install ``langfuse`` if missing. No-op when the import already works."""
34
+ if langfuse_available():
35
+ return InstallResult(True, True, "Langfuse SDK already installed")
36
+ error = _install()
37
+ importlib.invalidate_caches()
38
+ if langfuse_available():
39
+ return InstallResult(True, False, "Installed Langfuse SDK")
40
+ detail = error or "langfuse is not importable after install"
41
+ return InstallResult(
42
+ False,
43
+ False,
44
+ f"Could not install the Langfuse SDK ({detail}). "
45
+ "Install it with: uv sync --package symphony-code --extra langfuse",
46
+ )
47
+
48
+
49
+ def _install() -> str:
50
+ commands: list[list[str]] = []
51
+ uv = shutil.which("uv")
52
+ if uv:
53
+ commands.append([uv, "pip", "install", LANGFUSE_PACKAGE, "--python", sys.executable])
54
+ commands.append([sys.executable, "-m", "pip", "install", LANGFUSE_PACKAGE])
55
+ errors: list[str] = []
56
+ for command in commands:
57
+ try:
58
+ completed = subprocess.run(
59
+ command,
60
+ check=False,
61
+ capture_output=True,
62
+ text=True,
63
+ timeout=INSTALL_TIMEOUT_SECONDS,
64
+ env=os.environ.copy(),
65
+ )
66
+ except subprocess.TimeoutExpired:
67
+ errors.append(f"{command[0]} timed out")
68
+ continue
69
+ except OSError as exc:
70
+ errors.append(f"{command[0]}: {exc}")
71
+ continue
72
+ if completed.returncode == 0:
73
+ return ""
74
+ detail = (completed.stderr or completed.stdout or "").strip()
75
+ errors.append(detail.splitlines()[-1] if detail else f"{command[0]} exit {completed.returncode}")
76
+ return "; ".join(errors[:2]) if errors else "no installer available"
77
+
78
+
79
+ __all__ = ["InstallResult", "ensure_langfuse_installed", "langfuse_available"]
@@ -0,0 +1,158 @@
1
+ """Serialize harness payloads for Langfuse without sending secrets or images."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Iterable, Optional
6
+
7
+ from core_ai.content import text_from_content
8
+ from core_ai.types import Message
9
+
10
+ from coding_agent.addons.learning.sanitize import redact_secrets
11
+
12
+ DEFAULT_MAX_CHARS = 32_000
13
+
14
+
15
+ def _truncate(text: str, max_chars: int) -> str:
16
+ if max_chars <= 0 or len(text) <= max_chars:
17
+ return text
18
+ if max_chars <= 3:
19
+ return text[:max_chars]
20
+ return text[: max_chars - 3].rstrip() + "..."
21
+
22
+
23
+ def _jsonish(value: Any) -> Any:
24
+ if isinstance(value, (str, int, float, bool)) or value is None:
25
+ return value
26
+ dump = getattr(value, "model_dump", None)
27
+ if callable(dump):
28
+ try:
29
+ return dump()
30
+ except TypeError:
31
+ pass
32
+ return value
33
+
34
+
35
+ def _redact(value: Any) -> Any:
36
+ value = _jsonish(value)
37
+ if isinstance(value, str):
38
+ return redact_secrets(value)
39
+ if isinstance(value, dict):
40
+ return {str(key): _redact(item) for key, item in value.items()}
41
+ if isinstance(value, list):
42
+ return [_redact(item) for item in value]
43
+ if isinstance(value, tuple):
44
+ return [_redact(item) for item in value]
45
+ if value is None or isinstance(value, (int, float, bool)):
46
+ return value
47
+ return redact_secrets(str(value))
48
+
49
+
50
+ def _truncate_value(value: Any, max_chars: int) -> Any:
51
+ if isinstance(value, str):
52
+ return _truncate(value, max_chars)
53
+ if isinstance(value, dict):
54
+ return {str(key): _truncate_value(item, max_chars) for key, item in value.items()}
55
+ if isinstance(value, list):
56
+ return [_truncate_value(item, max_chars) for item in value]
57
+ if isinstance(value, tuple):
58
+ return [_truncate_value(item, max_chars) for item in value]
59
+ return value
60
+
61
+
62
+ def _sanitize(value: Any, max_chars: int) -> Any:
63
+ return _truncate_value(_redact(value), max_chars)
64
+
65
+
66
+ def serialize_content(content: Any, *, max_chars: int = DEFAULT_MAX_CHARS) -> Any:
67
+ """Return text-only content; image parts become filename placeholders."""
68
+ if content is None:
69
+ return ""
70
+ if isinstance(content, str):
71
+ return _truncate(redact_secrets(content), max_chars)
72
+ try:
73
+ return _truncate(redact_secrets(text_from_content(content)), max_chars)
74
+ except Exception:
75
+ return _truncate(redact_secrets(str(content)), max_chars)
76
+
77
+
78
+ def serialize_task(task: Any, *, max_chars: int = DEFAULT_MAX_CHARS) -> str:
79
+ """Bound, redacted run input (the user task text)."""
80
+ return serialize_content(task, max_chars=max_chars)
81
+
82
+
83
+ def serialize_run_output(output: Any, *, max_chars: int = DEFAULT_MAX_CHARS) -> str:
84
+ """Bound, redacted final run output (``output_text`` or the original task)."""
85
+ return serialize_content(output, max_chars=max_chars)
86
+
87
+
88
+ def serialize_tool_arguments(arguments: Any, *, max_chars: int = DEFAULT_MAX_CHARS) -> Any:
89
+ """Redact and bound tool-call arguments before they leave the process."""
90
+ if arguments is None:
91
+ return {}
92
+ return _sanitize(arguments, max_chars)
93
+
94
+
95
+ def serialize_turn_output(
96
+ text: Any = "",
97
+ tool_calls: Any = None,
98
+ *,
99
+ max_chars: int = DEFAULT_MAX_CHARS,
100
+ ) -> dict[str, Any]:
101
+ """Bound, redacted generation output: assistant text plus any tool calls."""
102
+ payload: dict[str, Any] = {"text": serialize_content(text, max_chars=max_chars)}
103
+ if tool_calls:
104
+ payload["tool_calls"] = serialize_tool_arguments(tool_calls, max_chars=max_chars)
105
+ return payload
106
+
107
+
108
+ def serialize_message(message: Message, *, max_chars: int = DEFAULT_MAX_CHARS) -> dict[str, Any]:
109
+ """Dump one conversation message as it would be sent to the model."""
110
+ payload: dict[str, Any] = {
111
+ "role": message.role,
112
+ "content": serialize_content(message.content, max_chars=max_chars),
113
+ }
114
+ if message.tool_calls:
115
+ payload["tool_calls"] = serialize_tool_arguments(message.tool_calls, max_chars=max_chars)
116
+ if message.tool_call_id:
117
+ payload["tool_call_id"] = message.tool_call_id
118
+ return payload
119
+
120
+
121
+ def serialize_messages(
122
+ messages: Iterable[Any],
123
+ *,
124
+ max_chars: int = DEFAULT_MAX_CHARS,
125
+ ) -> list[dict[str, Any]]:
126
+ serialized: list[dict[str, Any]] = []
127
+ for message in messages:
128
+ if isinstance(message, Message):
129
+ serialized.append(serialize_message(message, max_chars=max_chars))
130
+ else:
131
+ serialized.append({"role": "unknown", "content": serialize_content(message, max_chars=max_chars)})
132
+ return serialized
133
+
134
+
135
+ def serialize_tool_result(result: Any, *, max_chars: int = DEFAULT_MAX_CHARS) -> dict[str, Any]:
136
+ content = getattr(result, "content", result)
137
+ payload: dict[str, Any] = {
138
+ "status": getattr(result, "status", None),
139
+ "content": serialize_content(content, max_chars=max_chars),
140
+ }
141
+ error_type = getattr(result, "error_type", None)
142
+ if error_type:
143
+ payload["error_type"] = error_type
144
+ return payload
145
+
146
+
147
+ def usage_payload(usage: Any) -> Optional[dict[str, int]]:
148
+ if usage is None:
149
+ return None
150
+ dump = getattr(usage, "model_dump", None)
151
+ if callable(dump):
152
+ data = dump()
153
+ return {
154
+ "input": int(data.get("prompt_tokens") or 0),
155
+ "output": int(data.get("completion_tokens") or 0),
156
+ "total": int(data.get("total_tokens") or 0),
157
+ }
158
+ return None