trace-use 0.1.0__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.
- trace_use/__init__.py +64 -0
- trace_use/_util.py +31 -0
- trace_use/agents.py +430 -0
- trace_use/brain.py +878 -0
- trace_use/display.py +218 -0
- trace_use/forecast.py +93 -0
- trace_use/pipeline.py +614 -0
- trace_use/tools.py +138 -0
- trace_use-0.1.0.dist-info/METADATA +707 -0
- trace_use-0.1.0.dist-info/RECORD +12 -0
- trace_use-0.1.0.dist-info/WHEEL +5 -0
- trace_use-0.1.0.dist-info/top_level.txt +1 -0
trace_use/__init__.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""trace_use — forecast agent failure from execution traces.
|
|
2
|
+
|
|
3
|
+
Install:
|
|
4
|
+
pip install trace-use
|
|
5
|
+
|
|
6
|
+
Usage:
|
|
7
|
+
from trace_use import BrainAgent, build_embedder, tool_agent
|
|
8
|
+
|
|
9
|
+
brain = BrainAgent(build_embedder(), threshold=0.30)
|
|
10
|
+
agent = tool_agent(["python_exec"], model="claude-haiku-4-5-20251001")
|
|
11
|
+
agent.monitor = brain
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from .brain import BrainAgent
|
|
15
|
+
|
|
16
|
+
from .agents import (
|
|
17
|
+
haiku,
|
|
18
|
+
opus,
|
|
19
|
+
tool_agent,
|
|
20
|
+
build_embedder,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
from .pipeline import (
|
|
24
|
+
Forecaster,
|
|
25
|
+
TaskResult,
|
|
26
|
+
ComponentResult,
|
|
27
|
+
run_task,
|
|
28
|
+
decompose,
|
|
29
|
+
attempt,
|
|
30
|
+
gold_judge,
|
|
31
|
+
self_judge,
|
|
32
|
+
self_consistency,
|
|
33
|
+
tiered_judge,
|
|
34
|
+
make_retriever,
|
|
35
|
+
code_judge,
|
|
36
|
+
extract_code,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
__all__ = [
|
|
40
|
+
# Brain
|
|
41
|
+
"BrainAgent",
|
|
42
|
+
# Agents & embedder
|
|
43
|
+
"haiku",
|
|
44
|
+
"opus",
|
|
45
|
+
"tool_agent",
|
|
46
|
+
"build_embedder",
|
|
47
|
+
# Forecaster pipeline
|
|
48
|
+
"run_task",
|
|
49
|
+
"Forecaster",
|
|
50
|
+
"TaskResult",
|
|
51
|
+
"ComponentResult",
|
|
52
|
+
# Pipeline primitives
|
|
53
|
+
"decompose",
|
|
54
|
+
"attempt",
|
|
55
|
+
# Verifiers
|
|
56
|
+
"gold_judge",
|
|
57
|
+
"self_judge",
|
|
58
|
+
"self_consistency",
|
|
59
|
+
"tiered_judge",
|
|
60
|
+
"code_judge",
|
|
61
|
+
# Utilities
|
|
62
|
+
"extract_code",
|
|
63
|
+
"make_retriever",
|
|
64
|
+
]
|
trace_use/_util.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Small self-contained helpers (vendored, dependency-free)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
from typing import Iterable, Union
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _words(s: str) -> set:
|
|
9
|
+
return {w for w in re.findall(r"[a-z0-9]+", s.lower()) if len(w) > 2}
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def select_for_single(query: str, context: Union[str, Iterable[str], None], max_words: int) -> str:
|
|
13
|
+
"""Coherent, relevance-capped context: rank whole chunks by keyword overlap with the
|
|
14
|
+
query, take the top ones up to `max_words`, and join them in original order."""
|
|
15
|
+
if not context:
|
|
16
|
+
return ""
|
|
17
|
+
chunks = [context] if isinstance(context, str) else list(context)
|
|
18
|
+
qk = _words(query)
|
|
19
|
+
order = {id(c): i for i, c in enumerate(chunks)}
|
|
20
|
+
ranked = sorted(chunks, key=lambda c: len(qk & _words(c)), reverse=True)
|
|
21
|
+
chosen, used = [], 0
|
|
22
|
+
for c in ranked:
|
|
23
|
+
w = len(c.split())
|
|
24
|
+
if chosen and used + w > max_words:
|
|
25
|
+
break
|
|
26
|
+
chosen.append(c)
|
|
27
|
+
used += w
|
|
28
|
+
if used >= max_words:
|
|
29
|
+
break
|
|
30
|
+
chosen.sort(key=lambda c: order[id(c)])
|
|
31
|
+
return "\n".join(chosen)
|
trace_use/agents.py
ADDED
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
"""Self-contained agent + embedder for the evals — no external project deps.
|
|
2
|
+
|
|
3
|
+
`haiku` / `opus` are temperature-0 Anthropic agents (`prompt -> (text, tokens)`);
|
|
4
|
+
`_build_openai()` returns an OpenAI `text-embedding-3-small` embedder
|
|
5
|
+
(`texts -> L2-normalised vectors`). Clients are created lazily on first use, so importing
|
|
6
|
+
this module never needs keys — only running an eval does. Keys load from the environment
|
|
7
|
+
or a nearby `.env`.
|
|
8
|
+
|
|
9
|
+
`BackgroundMonitor` runs in a shadow thread alongside the main agent. It receives
|
|
10
|
+
partial trace chunks via `push()`, embeds them asynchronously, and sets a bail flag
|
|
11
|
+
the moment the trajectory resembles past failures — before the main agent finishes.
|
|
12
|
+
|
|
13
|
+
`streaming_agent(model)` uses the Anthropic streaming API and checks the bail flag
|
|
14
|
+
after every chunk, enabling mid-generation early exit for plain text agents.
|
|
15
|
+
|
|
16
|
+
`monitored_agent(base_agent, monitor)` attaches a BackgroundMonitor to any agent.
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import os
|
|
21
|
+
import threading
|
|
22
|
+
import time
|
|
23
|
+
|
|
24
|
+
_HAIKU_MODEL = "claude-haiku-4-5-20251001"
|
|
25
|
+
_SONNET_MODEL = "claude-sonnet-4-6"
|
|
26
|
+
_OPUS_MODEL = "claude-opus-4-8"
|
|
27
|
+
_EMBED_MODEL = "text-embedding-3-small"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _load_env() -> None:
|
|
31
|
+
try:
|
|
32
|
+
from dotenv import load_dotenv
|
|
33
|
+
for p in (".env", os.path.join("..", ".env"), os.path.join("..", "..", ".env")):
|
|
34
|
+
load_dotenv(p)
|
|
35
|
+
except Exception: # noqa: BLE001
|
|
36
|
+
pass
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
_load_env()
|
|
40
|
+
_client = None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _anthropic_call(model: str, prompt: str, max_tokens: int = 512):
|
|
44
|
+
global _client
|
|
45
|
+
if _client is None:
|
|
46
|
+
import anthropic
|
|
47
|
+
_client = anthropic.Anthropic()
|
|
48
|
+
# temperature=0 pins sampling where the model allows it; Opus 4 rejects the
|
|
49
|
+
# parameter outright (400), so omit it there. (Haiku 4.5 accepts it.)
|
|
50
|
+
supports_temp = "opus-4" not in model
|
|
51
|
+
kwargs = {"temperature": 0} if supports_temp else {}
|
|
52
|
+
last = None
|
|
53
|
+
for attempt in range(4):
|
|
54
|
+
try:
|
|
55
|
+
r = _client.messages.create(model=model, max_tokens=max_tokens,
|
|
56
|
+
messages=[{"role": "user", "content": prompt}],
|
|
57
|
+
**kwargs)
|
|
58
|
+
return r.content[0].text, r.usage.input_tokens + r.usage.output_tokens
|
|
59
|
+
except Exception as e: # noqa: BLE001
|
|
60
|
+
last = e
|
|
61
|
+
# Back off on overload (529) or rate-limit (429); give up on others.
|
|
62
|
+
code = getattr(getattr(e, 'response', None), 'status_code', None) or \
|
|
63
|
+
getattr(e, 'status_code', None)
|
|
64
|
+
if code in (429, 529):
|
|
65
|
+
time.sleep(4 ** attempt) # 1s, 4s, 16s
|
|
66
|
+
else:
|
|
67
|
+
break
|
|
68
|
+
raise last
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def haiku(prompt: str):
|
|
72
|
+
"""Fast, cheap temperature-0 agent. Returns (text, total_tokens)."""
|
|
73
|
+
return _anthropic_call(_HAIKU_MODEL, prompt)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def opus(prompt: str):
|
|
77
|
+
"""High-accuracy temperature-0 agent. Returns (text, total_tokens)."""
|
|
78
|
+
return _anthropic_call(_OPUS_MODEL, prompt, max_tokens=1024)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class BackgroundMonitor:
|
|
82
|
+
"""Shadow thread that watches the accumulating trace and fires a bail flag
|
|
83
|
+
the moment the trajectory resembles past failures.
|
|
84
|
+
|
|
85
|
+
Usage::
|
|
86
|
+
|
|
87
|
+
monitor = BackgroundMonitor(forecaster)
|
|
88
|
+
agent = monitored_agent(haiku, monitor)
|
|
89
|
+
|
|
90
|
+
with monitor: # starts the watcher thread
|
|
91
|
+
result = run_task(task, agent=agent, monitor=monitor, ...)
|
|
92
|
+
# if the monitor fires mid-run, the agent exits early and the
|
|
93
|
+
# pipeline fires a self-critique retry automatically
|
|
94
|
+
|
|
95
|
+
The monitor resets between components (via reset()) so each sub-question
|
|
96
|
+
gets a clean slate. It never blocks the main agent — the embedding call
|
|
97
|
+
happens in the background thread, not on the critical path.
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
def __init__(self, forecaster, check_interval: float = 1.0, min_chars: int = 300):
|
|
101
|
+
"""
|
|
102
|
+
Args:
|
|
103
|
+
forecaster: The shared Forecaster instance.
|
|
104
|
+
check_interval: Seconds between kNN checks (each is an embedding API call).
|
|
105
|
+
min_chars: Minimum trace length before the first check fires — avoids
|
|
106
|
+
flagging on fragments too short to embed meaningfully.
|
|
107
|
+
"""
|
|
108
|
+
self.forecaster = forecaster
|
|
109
|
+
self.check_interval = check_interval
|
|
110
|
+
self.min_chars = min_chars
|
|
111
|
+
|
|
112
|
+
self._buffer: str = ""
|
|
113
|
+
self._lock = threading.Lock()
|
|
114
|
+
self._bail_event = threading.Event()
|
|
115
|
+
self._done_event = threading.Event()
|
|
116
|
+
self._thread: threading.Thread | None = None
|
|
117
|
+
self.last_p_fail: float | None = None # last score computed, for display
|
|
118
|
+
|
|
119
|
+
# ── main-thread interface ─────────────────────────────────────────────────
|
|
120
|
+
|
|
121
|
+
def push(self, text: str) -> None:
|
|
122
|
+
"""Append a chunk of trace text. Called from the main agent thread."""
|
|
123
|
+
with self._lock:
|
|
124
|
+
self._buffer += text
|
|
125
|
+
|
|
126
|
+
@property
|
|
127
|
+
def should_bail(self) -> bool:
|
|
128
|
+
"""True when the monitor has decided this trajectory is failing."""
|
|
129
|
+
return self._bail_event.is_set()
|
|
130
|
+
|
|
131
|
+
def reset(self) -> None:
|
|
132
|
+
"""Clear buffer and bail flag before a new component attempt."""
|
|
133
|
+
with self._lock:
|
|
134
|
+
self._buffer = ""
|
|
135
|
+
self._bail_event.clear()
|
|
136
|
+
self.last_p_fail = None
|
|
137
|
+
|
|
138
|
+
# ── lifecycle ─────────────────────────────────────────────────────────────
|
|
139
|
+
|
|
140
|
+
def start(self) -> "BackgroundMonitor":
|
|
141
|
+
self._done_event.clear()
|
|
142
|
+
self._bail_event.clear()
|
|
143
|
+
with self._lock:
|
|
144
|
+
self._buffer = ""
|
|
145
|
+
self._thread = threading.Thread(target=self._loop, daemon=True, name="trace-monitor")
|
|
146
|
+
self._thread.start()
|
|
147
|
+
return self
|
|
148
|
+
|
|
149
|
+
def stop(self) -> None:
|
|
150
|
+
self._done_event.set()
|
|
151
|
+
if self._thread:
|
|
152
|
+
self._thread.join(timeout=3)
|
|
153
|
+
|
|
154
|
+
def __enter__(self) -> "BackgroundMonitor":
|
|
155
|
+
return self.start()
|
|
156
|
+
|
|
157
|
+
def __exit__(self, *_) -> None:
|
|
158
|
+
self.stop()
|
|
159
|
+
|
|
160
|
+
# ── background loop ───────────────────────────────────────────────────────
|
|
161
|
+
|
|
162
|
+
def _loop(self) -> None:
|
|
163
|
+
while not self._done_event.is_set():
|
|
164
|
+
time.sleep(self.check_interval)
|
|
165
|
+
|
|
166
|
+
with self._lock:
|
|
167
|
+
trace = self._buffer
|
|
168
|
+
|
|
169
|
+
# Need enough text to embed meaningfully, and enough stored history
|
|
170
|
+
# for the kNN to have signal.
|
|
171
|
+
if len(trace) < self.min_chars:
|
|
172
|
+
continue
|
|
173
|
+
if len(self.forecaster._vecs) < 2:
|
|
174
|
+
continue
|
|
175
|
+
|
|
176
|
+
p_fail = self.forecaster.predict_fail(trace)
|
|
177
|
+
self.last_p_fail = p_fail
|
|
178
|
+
|
|
179
|
+
if self.forecaster.should_intervene(trace):
|
|
180
|
+
self._bail_event.set()
|
|
181
|
+
return # job done — bail flag is set, stop looping
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def tool_agent(tools: list | None = None, max_turns: int = 4,
|
|
185
|
+
model: str | None = None, max_tokens: int = 4096):
|
|
186
|
+
"""ReAct tool-calling agent. Returns a callable (prompt -> (trace, tokens)).
|
|
187
|
+
|
|
188
|
+
`tools` is a subset of ['calculator', 'python_exec', 'wikipedia_search'].
|
|
189
|
+
Defaults to all three. The full trajectory — reasoning, tool calls, and
|
|
190
|
+
tool results — is returned as the trace so the Forecaster can embed it.
|
|
191
|
+
|
|
192
|
+
`model` overrides the default (_HAIKU_MODEL). Pass _SONNET_MODEL or any
|
|
193
|
+
Anthropic model ID to trade cost for capability on harder tasks.
|
|
194
|
+
"""
|
|
195
|
+
from .tools import TOOL_DEFINITIONS, dispatch
|
|
196
|
+
|
|
197
|
+
selected = tools or ["calculator", "python_exec", "wikipedia_search"]
|
|
198
|
+
tool_defs = [t for t in TOOL_DEFINITIONS if t["name"] in selected]
|
|
199
|
+
_model = model or _HAIKU_MODEL
|
|
200
|
+
|
|
201
|
+
def agent(prompt: str):
|
|
202
|
+
global _client
|
|
203
|
+
if _client is None:
|
|
204
|
+
import anthropic
|
|
205
|
+
_client = anthropic.Anthropic()
|
|
206
|
+
|
|
207
|
+
messages = [{"role": "user", "content": prompt}]
|
|
208
|
+
trace_parts: list[str] = []
|
|
209
|
+
total_tokens = 0
|
|
210
|
+
monitor: BackgroundMonitor | None = getattr(agent, 'monitor', None)
|
|
211
|
+
|
|
212
|
+
for _ in range(max_turns):
|
|
213
|
+
# 4096 so the model can emit a complete implementation inside a single
|
|
214
|
+
# tool call — at 1024 a large code block is truncated mid-JSON, the
|
|
215
|
+
# tool input arrives empty, nothing runs, and the component fails.
|
|
216
|
+
r = _client.messages.create(
|
|
217
|
+
model=_model, max_tokens=max_tokens, temperature=0,
|
|
218
|
+
tools=tool_defs if tool_defs else [],
|
|
219
|
+
messages=messages,
|
|
220
|
+
)
|
|
221
|
+
total_tokens += r.usage.input_tokens + r.usage.output_tokens
|
|
222
|
+
|
|
223
|
+
# Collect text and tool calls; push every text block to the monitor
|
|
224
|
+
# so the background thread can embed the accumulating trace.
|
|
225
|
+
tool_results = []
|
|
226
|
+
for block in r.content:
|
|
227
|
+
if block.type == "text":
|
|
228
|
+
trace_parts.append(block.text)
|
|
229
|
+
if monitor:
|
|
230
|
+
monitor.push(block.text)
|
|
231
|
+
elif block.type == "tool_use":
|
|
232
|
+
result = dispatch(block.name, block.input)
|
|
233
|
+
|
|
234
|
+
# Brain intercept: pass raw input dict (no regex) to the
|
|
235
|
+
# monitor's on_tool_call hook. The brain runs probe tests
|
|
236
|
+
# and kNN in one call and returns a modified result if it
|
|
237
|
+
# wants to intervene, or None to leave result unchanged.
|
|
238
|
+
if monitor and hasattr(monitor, "on_tool_call"):
|
|
239
|
+
_modified = monitor.on_tool_call(block.name, block.input, result)
|
|
240
|
+
if _modified is not None:
|
|
241
|
+
result = _modified
|
|
242
|
+
|
|
243
|
+
# json.dumps guarantees parseable extraction (no brace-count
|
|
244
|
+
# issues when code itself contains { } characters).
|
|
245
|
+
import json as _json
|
|
246
|
+
chunk = f"[tool:{block.name}({_json.dumps(block.input)})] → {result[:500]}"
|
|
247
|
+
trace_parts.append(chunk)
|
|
248
|
+
if monitor:
|
|
249
|
+
monitor.push(chunk)
|
|
250
|
+
tool_results.append({
|
|
251
|
+
"type": "tool_result",
|
|
252
|
+
"tool_use_id": block.id,
|
|
253
|
+
"content": result[:4000],
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
if r.stop_reason == "end_turn" or not tool_results:
|
|
257
|
+
break
|
|
258
|
+
|
|
259
|
+
# After each tool turn: pulse the monitor for immediate check.
|
|
260
|
+
# This catches failure patterns right after a tool result arrives
|
|
261
|
+
# rather than waiting for the timed background loop.
|
|
262
|
+
if monitor and hasattr(monitor, 'pulse'):
|
|
263
|
+
monitor.pulse()
|
|
264
|
+
|
|
265
|
+
# Between-turn checkpoint: if the background monitor has already
|
|
266
|
+
# decided this trajectory is failing, exit now rather than burning
|
|
267
|
+
# another LLM turn. The outer pipeline sees [EARLY_EXIT] and fires
|
|
268
|
+
# the self-critique retry.
|
|
269
|
+
if monitor and monitor.should_bail:
|
|
270
|
+
trace_parts.append(
|
|
271
|
+
f"[EARLY_EXIT: monitor P(fail)={monitor.last_p_fail:.2f} "
|
|
272
|
+
"— trajectory matches past failures]"
|
|
273
|
+
)
|
|
274
|
+
break
|
|
275
|
+
|
|
276
|
+
messages.append({"role": "assistant", "content": r.content})
|
|
277
|
+
messages.append({"role": "user", "content": tool_results})
|
|
278
|
+
|
|
279
|
+
return "\n".join(trace_parts), total_tokens
|
|
280
|
+
|
|
281
|
+
agent.monitor = None # attached by monitored_agent() or run_task()
|
|
282
|
+
return agent
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def streaming_agent(model: str | None = None):
|
|
286
|
+
"""Text agent using the Anthropic streaming API.
|
|
287
|
+
|
|
288
|
+
Behaves identically to haiku/opus in normal use. When a BackgroundMonitor
|
|
289
|
+
is attached (via monitored_agent), it checks the bail flag after every
|
|
290
|
+
streamed chunk and closes the connection immediately on detection —
|
|
291
|
+
enabling mid-generation early exit without waiting for the full response.
|
|
292
|
+
|
|
293
|
+
Example::
|
|
294
|
+
|
|
295
|
+
monitor = BackgroundMonitor(forecaster)
|
|
296
|
+
agent = monitored_agent(streaming_agent(), monitor)
|
|
297
|
+
"""
|
|
298
|
+
_model = model or _HAIKU_MODEL
|
|
299
|
+
supports_temp = "opus-4" not in _model
|
|
300
|
+
|
|
301
|
+
def agent(prompt: str):
|
|
302
|
+
global _client
|
|
303
|
+
if _client is None:
|
|
304
|
+
import anthropic
|
|
305
|
+
_client = anthropic.Anthropic()
|
|
306
|
+
|
|
307
|
+
monitor: BackgroundMonitor | None = getattr(agent, 'monitor', None)
|
|
308
|
+
kwargs = {"temperature": 0} if supports_temp else {}
|
|
309
|
+
|
|
310
|
+
accumulated = ""
|
|
311
|
+
total_tokens = 0
|
|
312
|
+
bailed = False
|
|
313
|
+
|
|
314
|
+
with _client.messages.stream(
|
|
315
|
+
model=_model,
|
|
316
|
+
max_tokens=1024,
|
|
317
|
+
messages=[{"role": "user", "content": prompt}],
|
|
318
|
+
**kwargs,
|
|
319
|
+
) as stream:
|
|
320
|
+
for chunk in stream.text_stream:
|
|
321
|
+
accumulated += chunk
|
|
322
|
+
if monitor:
|
|
323
|
+
monitor.push(chunk)
|
|
324
|
+
if monitor.should_bail:
|
|
325
|
+
bailed = True
|
|
326
|
+
break # closes the stream, stops billing for remaining tokens
|
|
327
|
+
|
|
328
|
+
if bailed:
|
|
329
|
+
accumulated += (
|
|
330
|
+
f"\n[EARLY_EXIT: monitor P(fail)={monitor.last_p_fail:.2f} "
|
|
331
|
+
"— trajectory matches past failures]"
|
|
332
|
+
)
|
|
333
|
+
else:
|
|
334
|
+
try:
|
|
335
|
+
msg = stream.get_final_message()
|
|
336
|
+
total_tokens = msg.usage.input_tokens + msg.usage.output_tokens
|
|
337
|
+
except Exception:
|
|
338
|
+
pass
|
|
339
|
+
|
|
340
|
+
return accumulated, total_tokens
|
|
341
|
+
|
|
342
|
+
agent.monitor = None
|
|
343
|
+
return agent
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def monitored_agent(base_agent, monitor: BackgroundMonitor):
|
|
347
|
+
"""Attach a BackgroundMonitor to any agent.
|
|
348
|
+
|
|
349
|
+
- For tool_agent and streaming_agent: sets agent.monitor directly.
|
|
350
|
+
- For plain haiku / opus callables: wraps them in a streaming_agent so
|
|
351
|
+
the monitor can trigger mid-generation bail.
|
|
352
|
+
|
|
353
|
+
The returned agent is the same object (or a new streaming wrapper) with
|
|
354
|
+
monitor wired in. Pass it to run_task as the agent argument::
|
|
355
|
+
|
|
356
|
+
monitor = BackgroundMonitor(forecaster)
|
|
357
|
+
agent = monitored_agent(haiku, monitor)
|
|
358
|
+
|
|
359
|
+
with monitor:
|
|
360
|
+
result = run_task(task, agent=agent, monitor=monitor, forecaster=fc)
|
|
361
|
+
"""
|
|
362
|
+
if hasattr(base_agent, 'monitor'):
|
|
363
|
+
# Already a monitor-aware agent (tool_agent or streaming_agent)
|
|
364
|
+
base_agent.monitor = monitor
|
|
365
|
+
return base_agent
|
|
366
|
+
|
|
367
|
+
# Plain callable (haiku, opus, or a CoT lambda) — wrap in streaming_agent
|
|
368
|
+
# so the monitor can bail mid-generation.
|
|
369
|
+
name = getattr(base_agent, '__name__', '')
|
|
370
|
+
model_map = {'haiku': _HAIKU_MODEL, 'opus': _OPUS_MODEL}
|
|
371
|
+
_model = model_map.get(name, _HAIKU_MODEL)
|
|
372
|
+
|
|
373
|
+
wrapped = streaming_agent(_model)
|
|
374
|
+
wrapped.monitor = monitor
|
|
375
|
+
|
|
376
|
+
# Preserve the original callable as a fallback docstring hint
|
|
377
|
+
wrapped.__wrapped__ = base_agent
|
|
378
|
+
return wrapped
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _build_local_embedder():
|
|
382
|
+
"""sentence-transformers local embedder — free, no API key, ~10ms/chunk on CPU.
|
|
383
|
+
|
|
384
|
+
Uses all-MiniLM-L6-v2 (80MB, 384-dim). Downloaded once on first call.
|
|
385
|
+
Returns L2-normalised float32 vectors, same interface as _build_openai().
|
|
386
|
+
"""
|
|
387
|
+
from sentence_transformers import SentenceTransformer
|
|
388
|
+
import numpy as np
|
|
389
|
+
_model = SentenceTransformer("all-MiniLM-L6-v2")
|
|
390
|
+
|
|
391
|
+
def embed(texts):
|
|
392
|
+
texts = [t[:8000] for t in texts]
|
|
393
|
+
vecs = _model.encode(texts, normalize_embeddings=True,
|
|
394
|
+
show_progress_bar=False, convert_to_numpy=True)
|
|
395
|
+
return np.asarray(vecs, dtype="float32")
|
|
396
|
+
return embed
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def _build_openai():
|
|
400
|
+
"""OpenAI text-embedding-3-small embedder. Requires OPENAI_API_KEY.
|
|
401
|
+
|
|
402
|
+
Returns L2-normalised float32 vectors. Use build_embedder() instead to
|
|
403
|
+
prefer the free local embedder and fall back here only if needed.
|
|
404
|
+
"""
|
|
405
|
+
from openai import OpenAI
|
|
406
|
+
import numpy as np
|
|
407
|
+
client = OpenAI()
|
|
408
|
+
|
|
409
|
+
def embed(texts):
|
|
410
|
+
texts = list(texts)
|
|
411
|
+
out = []
|
|
412
|
+
for i in range(0, len(texts), 256):
|
|
413
|
+
batch = [t[:8000] for t in texts[i:i + 256]]
|
|
414
|
+
r = client.embeddings.create(model=_EMBED_MODEL, input=batch)
|
|
415
|
+
out.extend(d.embedding for d in r.data)
|
|
416
|
+
v = np.asarray(out, dtype="float32")
|
|
417
|
+
return v / (np.linalg.norm(v, axis=1, keepdims=True) + 1e-9)
|
|
418
|
+
return embed
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def build_embedder():
|
|
422
|
+
"""Return the best available embedder.
|
|
423
|
+
|
|
424
|
+
Tries sentence-transformers (local, free, no API key) first.
|
|
425
|
+
Falls back to OpenAI text-embedding-3-small if not installed.
|
|
426
|
+
"""
|
|
427
|
+
try:
|
|
428
|
+
return _build_local_embedder()
|
|
429
|
+
except ImportError:
|
|
430
|
+
return _build_openai()
|