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.
- coding_agent/__init__.py +20 -0
- coding_agent/_lazy.py +17 -0
- coding_agent/addons/__init__.py +35 -0
- coding_agent/addons/approvals.py +224 -0
- coding_agent/addons/compaction/__init__.py +36 -0
- coding_agent/addons/compaction/addon.py +88 -0
- coding_agent/addons/compaction/compactor.py +138 -0
- coding_agent/addons/compaction/prompts.py +20 -0
- coding_agent/addons/compaction/transcript.py +118 -0
- coding_agent/addons/evaluation/__init__.py +68 -0
- coding_agent/addons/evaluation/addon.py +199 -0
- coding_agent/addons/evaluation/evaluators.py +245 -0
- coding_agent/addons/evaluation/handlers.py +66 -0
- coding_agent/addons/evaluation/policy.py +277 -0
- coding_agent/addons/evaluation/protocol.py +192 -0
- coding_agent/addons/evaluation/state.py +284 -0
- coding_agent/addons/langfuse/__init__.py +24 -0
- coding_agent/addons/langfuse/addon.py +465 -0
- coding_agent/addons/langfuse/install.py +79 -0
- coding_agent/addons/langfuse/serialize.py +158 -0
- coding_agent/addons/learning/__init__.py +33 -0
- coding_agent/addons/learning/addon.py +181 -0
- coding_agent/addons/learning/loop.py +247 -0
- coding_agent/addons/learning/prompts.py +26 -0
- coding_agent/addons/learning/sanitize.py +64 -0
- coding_agent/addons/learning/store.py +522 -0
- coding_agent/addons/manager/__init__.py +18 -0
- coding_agent/addons/manager/bridge.py +74 -0
- coding_agent/addons/manager/discovery.py +34 -0
- coding_agent/addons/manager/report.py +33 -0
- coding_agent/addons/manager/runtime.py +76 -0
- coding_agent/addons/manager/tool_changes.py +16 -0
- coding_agent/addons/persistence/__init__.py +41 -0
- coding_agent/addons/persistence/active.py +132 -0
- coding_agent/addons/persistence/artifacts.py +110 -0
- coding_agent/addons/persistence/collection.py +166 -0
- coding_agent/addons/persistence/compatibility.py +30 -0
- coding_agent/addons/persistence/images.py +133 -0
- coding_agent/addons/persistence/jsonl.py +1195 -0
- coding_agent/addons/persistence/presentation.py +104 -0
- coding_agent/addons/plugins/__init__.py +19 -0
- coding_agent/addons/plugins/install.py +37 -0
- coding_agent/addons/plugins/manager.py +140 -0
- coding_agent/addons/plugins/models.py +50 -0
- coding_agent/addons/skills/__init__.py +28 -0
- coding_agent/addons/skills/addon.py +26 -0
- coding_agent/addons/skills/args.py +78 -0
- coding_agent/addons/skills/bundled_skills/addon-authoring/SKILL.md +77 -0
- coding_agent/addons/skills/bundled_skills/context-budget/SKILL.md +12 -0
- coding_agent/addons/skills/bundled_skills/orient-first/SKILL.md +16 -0
- coding_agent/addons/skills/bundled_skills/security-boundary/SKILL.md +16 -0
- coding_agent/addons/skills/bundled_skills/skill-discipline/SKILL.md +13 -0
- coding_agent/addons/skills/bundled_skills/surgical-edit/SKILL.md +11 -0
- coding_agent/addons/skills/bundled_skills/systematic-debug/SKILL.md +18 -0
- coding_agent/addons/skills/bundled_skills/verify-before-done/SKILL.md +11 -0
- coding_agent/addons/skills/frontmatter.py +57 -0
- coding_agent/addons/skills/models.py +57 -0
- coding_agent/addons/skills/registry.py +109 -0
- coding_agent/addons/skills/tool.py +54 -0
- coding_agent/addons/voice/__init__.py +5 -0
- coding_agent/addons/voice/addon.py +106 -0
- coding_agent/addons/voice/credentials.py +42 -0
- coding_agent/addons/voice/playback.py +42 -0
- coding_agent/addons/voice/speak.py +287 -0
- coding_agent/addons/voice/stream.py +651 -0
- coding_agent/addons/voice/summary.py +126 -0
- coding_agent/agent.py +523 -0
- coding_agent/bench/__init__.py +1 -0
- coding_agent/bench/cli.py +200 -0
- coding_agent/bench/patch.py +78 -0
- coding_agent/commands/__init__.py +5 -0
- coding_agent/commands/catalog.py +179 -0
- coding_agent/commands/jev_argument.py +40 -0
- coding_agent/config/__init__.py +51 -0
- coding_agent/config/config.py +376 -0
- coding_agent/config/credentials.py +153 -0
- coding_agent/config/personalities.json +34 -0
- coding_agent/config/personalities.py +170 -0
- coding_agent/config/prompts.py +67 -0
- coding_agent/protocols/__init__.py +5 -0
- coding_agent/protocols/acp/README.md +36 -0
- coding_agent/protocols/acp/__init__.py +27 -0
- coding_agent/protocols/acp/mapping.py +164 -0
- coding_agent/protocols/acp/server.py +261 -0
- coding_agent/protocols/acp/transport.py +72 -0
- coding_agent/protocols/stdio/README.md +36 -0
- coding_agent/protocols/stdio/__init__.py +37 -0
- coding_agent/protocols/stdio/catalog.py +49 -0
- coding_agent/protocols/stdio/commands.py +218 -0
- coding_agent/protocols/stdio/server.py +181 -0
- coding_agent/protocols/stdio/transport.py +71 -0
- coding_agent/py.typed +0 -0
- coding_agent/resources/__init__.py +5 -0
- coding_agent/resources/usage.py +264 -0
- coding_agent/run/__init__.py +1 -0
- coding_agent/run/cli.py +99 -0
- coding_agent/run/detach.py +37 -0
- coding_agent/run/interrupted.py +67 -0
- coding_agent/run/log_sink.py +46 -0
- coding_agent/tools/__init__.py +73 -0
- coding_agent/tools/ask_user.py +61 -0
- coding_agent/tools/base.py +136 -0
- coding_agent/tools/bash.py +288 -0
- coding_agent/tools/bash_jobs.py +116 -0
- coding_agent/tools/generate_image.py +123 -0
- coding_agent/tools/memory.py +100 -0
- coding_agent/tools/patch.py +218 -0
- coding_agent/tools/read_file.py +167 -0
- coding_agent/tools/search.py +433 -0
- coding_agent/tools/web_search.py +151 -0
- coding_agent/tools/write_file.py +40 -0
- coding_agent/tui/EFFICIENCY_PLAN.md +25 -0
- coding_agent/tui/__init__.py +22 -0
- coding_agent/tui/__main__.py +122 -0
- coding_agent/tui/app.py +456 -0
- coding_agent/tui/chrome/__init__.py +33 -0
- coding_agent/tui/chrome/footer.py +274 -0
- coding_agent/tui/chrome/topbar.py +259 -0
- coding_agent/tui/commands/__init__.py +45 -0
- coding_agent/tui/commands/catalog.py +35 -0
- coding_agent/tui/commands/manager.py +557 -0
- coding_agent/tui/composer/__init__.py +18 -0
- coding_agent/tui/composer/input.py +219 -0
- coding_agent/tui/composer/slash_menu.py +341 -0
- coding_agent/tui/composer/surface.py +330 -0
- coding_agent/tui/composer/voice.py +267 -0
- coding_agent/tui/composer/voice_mode.py +491 -0
- coding_agent/tui/runtime/__init__.py +23 -0
- coding_agent/tui/runtime/events.py +728 -0
- coding_agent/tui/runtime/sink.py +115 -0
- coding_agent/tui/runtime/state.py +145 -0
- coding_agent/tui/runtime/subagent.py +531 -0
- coding_agent/tui/runtime/turn.py +206 -0
- coding_agent/tui/screens/__init__.py +54 -0
- coding_agent/tui/screens/ask.py +69 -0
- coding_agent/tui/screens/context.py +195 -0
- coding_agent/tui/screens/dashboard.py +91 -0
- coding_agent/tui/screens/diff.py +302 -0
- coding_agent/tui/screens/extensions.py +68 -0
- coding_agent/tui/screens/file_selector.py +167 -0
- coding_agent/tui/screens/history.py +458 -0
- coding_agent/tui/screens/langfuse.py +142 -0
- coding_agent/tui/screens/learning.py +113 -0
- coding_agent/tui/screens/modal.py +131 -0
- coding_agent/tui/screens/onboard.py +455 -0
- coding_agent/tui/screens/resume.py +163 -0
- coding_agent/tui/theme/__init__.py +49 -0
- coding_agent/tui/theme/load.py +325 -0
- coding_agent/tui/theme/style.py +95 -0
- coding_agent/tui/theme/theme.toml +1687 -0
- coding_agent/tui/tools/__init__.py +53 -0
- coding_agent/tui/tools/activity.py +184 -0
- coding_agent/tui/tools/calls.py +560 -0
- coding_agent/tui/tools/code_view.py +52 -0
- coding_agent/tui/tools/images.py +250 -0
- coding_agent/tui/tools/labels.py +135 -0
- coding_agent/tui/tools/modal.py +137 -0
- coding_agent/tui/tools/partial_args.py +19 -0
- coding_agent/tui/tools/snapshots.py +382 -0
- coding_agent/tui/transcript/__init__.py +36 -0
- coding_agent/tui/transcript/messages.py +408 -0
- coding_agent/tui/transcript/process.py +504 -0
- coding_agent/tui/transcript/surface.py +345 -0
- coding_agent/tui/transcript/thinking_markdown.py +314 -0
- symphony_code-0.0.1.dist-info/METADATA +66 -0
- symphony_code-0.0.1.dist-info/RECORD +169 -0
- symphony_code-0.0.1.dist-info/WHEEL +4 -0
- symphony_code-0.0.1.dist-info/entry_points.txt +5 -0
- 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
|