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 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()