agent-self-edit 0.2.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.
Files changed (38) hide show
  1. agent_self_edit/__init__.py +8 -0
  2. agent_self_edit/__main__.py +4 -0
  3. agent_self_edit/ab_test.py +333 -0
  4. agent_self_edit/adapters/__init__.py +7 -0
  5. agent_self_edit/adapters/base.py +27 -0
  6. agent_self_edit/adapters/file.py +63 -0
  7. agent_self_edit/adapters/stdin.py +48 -0
  8. agent_self_edit/analyzer.py +665 -0
  9. agent_self_edit/cli/__init__.py +43 -0
  10. agent_self_edit/cli/diff.py +36 -0
  11. agent_self_edit/cli/guardrails.py +45 -0
  12. agent_self_edit/cli/ingest.py +44 -0
  13. agent_self_edit/cli/init.py +59 -0
  14. agent_self_edit/cli/lineage.py +32 -0
  15. agent_self_edit/cli/propose.py +142 -0
  16. agent_self_edit/cli/rollback.py +25 -0
  17. agent_self_edit/cli/run.py +142 -0
  18. agent_self_edit/cli/status.py +76 -0
  19. agent_self_edit/cli/validate.py +81 -0
  20. agent_self_edit/config.py +242 -0
  21. agent_self_edit/diff.py +298 -0
  22. agent_self_edit/gate.py +485 -0
  23. agent_self_edit/guardrails.py +334 -0
  24. agent_self_edit/llm/__init__.py +7 -0
  25. agent_self_edit/llm/base.py +26 -0
  26. agent_self_edit/llm/mock.py +64 -0
  27. agent_self_edit/llm/openai.py +142 -0
  28. agent_self_edit/registry.py +419 -0
  29. agent_self_edit/scorers.py +375 -0
  30. agent_self_edit/tasks.py +199 -0
  31. agent_self_edit/trace.py +360 -0
  32. agent_self_edit/types.py +182 -0
  33. agent_self_edit-0.2.0.dist-info/METADATA +198 -0
  34. agent_self_edit-0.2.0.dist-info/RECORD +38 -0
  35. agent_self_edit-0.2.0.dist-info/WHEEL +5 -0
  36. agent_self_edit-0.2.0.dist-info/entry_points.txt +2 -0
  37. agent_self_edit-0.2.0.dist-info/licenses/LICENSE +21 -0
  38. agent_self_edit-0.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,8 @@
1
+ """AgentSelfEdit — An agent that rewrites its own system prompt from execution feedback."""
2
+
3
+ from importlib.metadata import version as _version
4
+
5
+ try:
6
+ __version__ = _version("agent-self-edit")
7
+ except Exception:
8
+ __version__ = "0.0.0"
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -0,0 +1,333 @@
1
+ """Task runner, A/B test engine, statistics, and cost tracking."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import random
6
+ import statistics
7
+ import time
8
+ from dataclasses import dataclass, field
9
+ from typing import Literal
10
+
11
+ from .config import ABTestConfig, Config
12
+ from .llm.base import LLMProvider, ProviderError
13
+ from .scorers import Scorer
14
+ from .tasks import Task, TaskSet
15
+ from .types import utc_now_iso # noqa: F401 (re-export convenience)
16
+
17
+ _COST_PER_1K_TOKENS = 0.0033 # approx gpt-4o-mini blend ($ per 1K tokens)
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class TaskResult:
22
+ """Result of running one task against one prompt."""
23
+
24
+ output: str
25
+ success: bool
26
+ latency_ms: float
27
+ token_count: int
28
+ error: str | None = None
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class PerTask:
33
+ """Paired per-task comparison for the A/B result."""
34
+
35
+ task_id: str
36
+ task_input: str
37
+ expected_output: str
38
+ output_a: str
39
+ score_a: float
40
+ output_b: str
41
+ score_b: float
42
+ delta: float
43
+ latency_a_ms: float
44
+ latency_b_ms: float
45
+ tokens_a: int
46
+ tokens_b: int
47
+ error_a: str | None = None
48
+ error_b: str | None = None
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class ABResult:
53
+ """Statistical result of comparing prompt B against prompt A."""
54
+
55
+ winner: Literal["a", "b", "tie", "inconclusive"]
56
+ mean_delta: float
57
+ ci_low: float
58
+ ci_high: float
59
+ p_value: float
60
+ effect_size: float
61
+ n_trials: int
62
+ per_task: list[PerTask] = field(default_factory=list)
63
+ cost_usd: float = 0.0
64
+ token_count: int = 0
65
+
66
+
67
+ @dataclass(frozen=True)
68
+ class BootstrapResult:
69
+ mean: float
70
+ ci_low: float
71
+ ci_high: float
72
+ std: float
73
+
74
+
75
+ def estimate_tokens(text: str) -> int:
76
+ """Rough token estimate: ~4 characters per token."""
77
+ return max(1, len(text) // 4)
78
+
79
+
80
+ def estimate_cost(token_count: int, price_per_1k: float = _COST_PER_1K_TOKENS) -> float:
81
+ """Estimate USD cost for ``token_count`` tokens."""
82
+ return token_count * price_per_1k / 1000.0
83
+
84
+
85
+ # ---------------------------------------------------------------------------
86
+ # Task runner (#16)
87
+ # ---------------------------------------------------------------------------
88
+
89
+
90
+ def run_task(task: Task, prompt: str, llm: LLMProvider) -> TaskResult:
91
+ """Run ``task`` against one ``prompt`` and measure latency + tokens."""
92
+ if not prompt.strip():
93
+ return TaskResult(output="", success=False, latency_ms=0.0, token_count=0,
94
+ error="empty prompt")
95
+
96
+ full_prompt = f"{prompt}\n\n---\n\nTask: {task.input}"
97
+ start = time.monotonic()
98
+ try:
99
+ output = llm.complete(prompt=full_prompt, system_prompt="", temperature=0.0)
100
+ except ProviderError as e:
101
+ return TaskResult(output="", success=False, latency_ms=0.0, token_count=0,
102
+ error=str(e))
103
+ latency_ms = (time.monotonic() - start) * 1000.0
104
+ tokens = estimate_tokens(full_prompt) + estimate_tokens(output)
105
+ return TaskResult(
106
+ output=output,
107
+ success=True,
108
+ latency_ms=latency_ms,
109
+ token_count=tokens,
110
+ )
111
+
112
+
113
+ # ---------------------------------------------------------------------------
114
+ # Statistics (#19, #20, #21)
115
+ # ---------------------------------------------------------------------------
116
+
117
+
118
+ def bootstrap_ci(
119
+ scores_a: list[float],
120
+ scores_b: list[float],
121
+ n_resamples: int = 10000,
122
+ ci_level: float = 0.95,
123
+ ) -> BootstrapResult:
124
+ """Bootstrap CI for the mean delta = mean(score_b - score_a).
125
+
126
+ Uses a deterministic seed for reproducibility.
127
+ """
128
+ n = len(scores_a)
129
+ if n == 0:
130
+ return BootstrapResult(mean=0.0, ci_low=0.0, ci_high=0.0, std=0.0)
131
+ if n < 2:
132
+ return BootstrapResult(mean=0.0, ci_low=0.0, ci_high=0.0, std=0.0)
133
+
134
+ deltas = [b - a for a, b in zip(scores_a, scores_b)]
135
+ mean_delta = sum(deltas) / n
136
+
137
+ if n_resamples <= 0:
138
+ return BootstrapResult(mean=mean_delta, ci_low=mean_delta, ci_high=mean_delta, std=0.0)
139
+
140
+ rng = random.Random(0)
141
+ means: list[float] = []
142
+ for _ in range(n_resamples):
143
+ sample = [rng.choice(deltas) for _ in range(n)]
144
+ means.append(sum(sample) / n)
145
+
146
+ means.sort()
147
+ tail = (1.0 - ci_level) / 2.0
148
+ low_idx = int(tail * n_resamples)
149
+ high_idx = int((1.0 - tail) * n_resamples) - 1
150
+ ci_low = means[low_idx]
151
+ ci_high = means[high_idx]
152
+ std = statistics.stdev(means) if len(means) > 1 else 0.0
153
+ return BootstrapResult(mean=mean_delta, ci_low=ci_low, ci_high=ci_high, std=std)
154
+
155
+
156
+ def permutation_test(
157
+ scores_a: list[float],
158
+ scores_b: list[float],
159
+ n_permutations: int = 1000,
160
+ ) -> float:
161
+ """One-tailed permutation p-value: how often a random split beats observed.
162
+
163
+ Deterministic seed for reproducibility.
164
+ """
165
+ n = len(scores_a)
166
+ if n == 0:
167
+ return 1.0
168
+ observed_diff = sum(scores_b) / n - sum(scores_a) / n
169
+ pooled = list(scores_a) + list(scores_b)
170
+ rng = random.Random(0)
171
+
172
+ count = 0
173
+ for _ in range(n_permutations):
174
+ rng.shuffle(pooled)
175
+ fake_a = pooled[:n]
176
+ fake_b = pooled[n:]
177
+ fake_diff = sum(fake_b) / n - sum(fake_a) / n
178
+ if fake_diff >= observed_diff:
179
+ count += 1
180
+ return count / n_permutations
181
+
182
+
183
+ def effect_size(scores_a: list[float], scores_b: list[float]) -> float:
184
+ """Relative improvement = (mean_b - mean_a) / mean_a.
185
+
186
+ Baseline of 0 renders ``inf`` for any positive improvement (handled by
187
+ the caller), and 0.0 for no change.
188
+ """
189
+ if not scores_a or not scores_b:
190
+ return 0.0
191
+ mean_a = sum(scores_a) / len(scores_a)
192
+ mean_b = sum(scores_b) / len(scores_b)
193
+ if mean_a == 0:
194
+ if mean_b > 0:
195
+ return float("inf")
196
+ return 0.0
197
+ return (mean_b - mean_a) / mean_a
198
+
199
+
200
+ # ---------------------------------------------------------------------------
201
+ # A/B test runner (#18)
202
+ # ---------------------------------------------------------------------------
203
+
204
+
205
+ def _resolve_ab_config(config: Config | None) -> ABTestConfig:
206
+ return config.ab_test if config is not None else ABTestConfig()
207
+
208
+
209
+ def run_ab_test(
210
+ prompt_a: str,
211
+ prompt_b: str,
212
+ task_set: TaskSet,
213
+ llm: LLMProvider,
214
+ scorer: Scorer,
215
+ config: Config | None = None,
216
+ ) -> ABResult:
217
+ """Run the paired A/B comparison of ``prompt_b`` vs ``prompt_a``."""
218
+ ab_config = _resolve_ab_config(config)
219
+ tasks = task_set.list_tasks()
220
+ results: list[PerTask] = []
221
+ total_tokens = 0
222
+ failures = 0
223
+
224
+ for task in tasks:
225
+ result_a = run_task(task, prompt_a, llm)
226
+ result_b = run_task(task, prompt_b, llm)
227
+
228
+ score_a = (
229
+ scorer.score(task.expected_output, result_a.output)[1]
230
+ if not result_a.error
231
+ else 0.0
232
+ )
233
+ score_b = (
234
+ scorer.score(task.expected_output, result_b.output)[1]
235
+ if not result_b.error
236
+ else 0.0
237
+ )
238
+
239
+ if result_a.error or result_b.error:
240
+ failures += 1
241
+
242
+ total_tokens += result_a.token_count + result_b.token_count
243
+ results.append(
244
+ PerTask(
245
+ task_id=task.id,
246
+ task_input=task.input,
247
+ expected_output=task.expected_output,
248
+ output_a=result_a.output,
249
+ score_a=score_a,
250
+ output_b=result_b.output,
251
+ score_b=score_b,
252
+ delta=score_b - score_a,
253
+ latency_a_ms=result_a.latency_ms,
254
+ latency_b_ms=result_b.latency_ms,
255
+ tokens_a=result_a.token_count,
256
+ tokens_b=result_b.token_count,
257
+ error_a=result_a.error,
258
+ error_b=result_b.error,
259
+ )
260
+ )
261
+
262
+ # Cost ceiling abort (D3 §8.1)
263
+ if estimate_cost(total_tokens) > ab_config.cost_ceiling_usd:
264
+ return _inconclusive(results, total_tokens)
265
+
266
+ if tasks and failures / len(tasks) > 0.2:
267
+ return _inconclusive(results, total_tokens)
268
+
269
+ scores_a = [r.score_a for r in results]
270
+ scores_b = [r.score_b for r in results]
271
+ mean_a = sum(scores_a) / len(scores_a) if scores_a else 0.0
272
+ mean_b = sum(scores_b) / len(scores_b) if scores_b else 0.0
273
+
274
+ if not scores_a:
275
+ return _inconclusive([], 0)
276
+
277
+ deltas = [r.delta for r in results]
278
+ if all(d == 0.0 for d in deltas):
279
+ winner: Literal["a", "b", "tie", "inconclusive"] = "tie"
280
+ ci = BootstrapResult(mean=0.0, ci_low=0.0, ci_high=0.0, std=0.0)
281
+ p_value = 1.0
282
+ else:
283
+ ci = bootstrap_ci(
284
+ scores_a, scores_b,
285
+ n_resamples=ab_config.n_resamples,
286
+ )
287
+ p_value = permutation_test(
288
+ scores_a, scores_b,
289
+ n_permutations=ab_config.n_permutations,
290
+ )
291
+ effect = effect_size(scores_a, scores_b)
292
+ alpha = 1.0 - ab_config.confidence_level
293
+ if (
294
+ ci.ci_low > 0
295
+ and p_value < alpha
296
+ and effect >= ab_config.min_effect_size
297
+ ):
298
+ winner = "b"
299
+ elif ci.ci_high < 0 and p_value < alpha:
300
+ winner = "a"
301
+ else:
302
+ winner = "inconclusive"
303
+
304
+ return ABResult(
305
+ winner=winner,
306
+ mean_delta=mean_b - mean_a,
307
+ ci_low=ci.ci_low,
308
+ ci_high=ci.ci_high,
309
+ p_value=p_value,
310
+ effect_size=effect_size(scores_a, scores_b),
311
+ n_trials=len(results),
312
+ per_task=results,
313
+ cost_usd=estimate_cost(total_tokens),
314
+ token_count=total_tokens,
315
+ )
316
+
317
+
318
+ def _inconclusive(
319
+ per_task: list[PerTask],
320
+ token_count: int,
321
+ ) -> ABResult:
322
+ return ABResult(
323
+ winner="inconclusive",
324
+ mean_delta=0.0,
325
+ ci_low=0.0,
326
+ ci_high=0.0,
327
+ p_value=1.0,
328
+ effect_size=0.0,
329
+ n_trials=len(per_task),
330
+ per_task=per_task,
331
+ cost_usd=estimate_cost(token_count),
332
+ token_count=token_count,
333
+ )
@@ -0,0 +1,7 @@
1
+ """Trace adapter imports."""
2
+
3
+ from .base import TraceAdapter
4
+ from .file import FileAdapter
5
+ from .stdin import StdinAdapter
6
+
7
+ __all__ = ["TraceAdapter", "StdinAdapter", "FileAdapter"]
@@ -0,0 +1,27 @@
1
+ """Trace adapter interface."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import threading
6
+ from abc import ABC, abstractmethod
7
+
8
+ from ..trace import TraceStore
9
+
10
+
11
+ class TraceAdapter(ABC):
12
+ """Abstract adapter that ingests traces from a specific source."""
13
+
14
+ def __init__(self, store: TraceStore) -> None:
15
+ self._store = store
16
+ self._stop_event = threading.Event()
17
+
18
+ @abstractmethod
19
+ def run(self) -> None:
20
+ """Blocking run loop; calls ``store.ingest()`` per trace."""
21
+
22
+ def stop(self) -> None:
23
+ """Signal the run loop to exit."""
24
+ self._stop_event.set()
25
+
26
+ def _stopped(self) -> bool:
27
+ return self._stop_event.is_set()
@@ -0,0 +1,63 @@
1
+ """Directory-watching trace adapter."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ import time
8
+ from pathlib import Path
9
+
10
+ from ..trace import TraceStore
11
+ from .base import TraceAdapter
12
+
13
+ logger = logging.getLogger("agent_self_edit.adapters.file")
14
+
15
+
16
+ class FileAdapter(TraceAdapter):
17
+ """Watches a directory for new ``.json`` trace files and ingests each.
18
+
19
+ Each file must be a JSON object (one trace per file). After successful
20
+ ingestion the file is moved to a ``.done`` sibling so it is not re-read.
21
+ Uses the ``.done`` rename as the sole dedup mechanism — no in-memory
22
+ filename cache, so repeated filenames are safe (ref #142).
23
+ """
24
+
25
+ def __init__(
26
+ self, store: TraceStore, watch_dir: str | Path, poll_interval: float = 1.0
27
+ ) -> None:
28
+ super().__init__(store)
29
+ self._watch_dir = Path(watch_dir)
30
+ self._watch_dir.mkdir(parents=True, exist_ok=True)
31
+ self._poll_interval = poll_interval
32
+
33
+ def run(self) -> None:
34
+ while not self._stopped():
35
+ self._process_once()
36
+ time.sleep(self._poll_interval)
37
+
38
+ def _process_once(self) -> int:
39
+ processed = 0
40
+ for path in sorted(self._watch_dir.glob("*.json")):
41
+ self._ingest_file(path)
42
+ processed += 1
43
+ return processed
44
+
45
+ def _ingest_file(self, path: Path) -> None:
46
+ try:
47
+ data = json.loads(path.read_text())
48
+ except (json.JSONDecodeError, OSError) as e:
49
+ logger.warning("Skipping malformed trace file %s: %s", path, e)
50
+ return
51
+ if not isinstance(data, dict):
52
+ logger.warning("Skipping trace file %s: expected a JSON object", path)
53
+ return
54
+ try:
55
+ self._store.ingest(data)
56
+ except ValueError as e:
57
+ logger.warning("Skipping invalid trace %s: %s", path, e)
58
+ return
59
+ try:
60
+ done = path.with_suffix(path.suffix + ".done")
61
+ path.rename(done)
62
+ except OSError as e:
63
+ logger.warning("Could not move %s to .done: %s", path, e)
@@ -0,0 +1,48 @@
1
+ """Stdin-aware trace adapter for JSON-lines input."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sys
7
+ from typing import TextIO
8
+
9
+ from ..trace import TraceStore
10
+ from .base import TraceAdapter
11
+
12
+
13
+ class StdinAdapter(TraceAdapter):
14
+ """Reads JSON-lines from stdin, one trace object per line, and ingests each."""
15
+
16
+ def __init__(
17
+ self, store: TraceStore, stream: TextIO | None = None, batch_size: int = 1000
18
+ ) -> None:
19
+ super().__init__(store)
20
+ self._stream = stream if stream is not None else sys.stdin
21
+ self._batch_size = batch_size
22
+ self._ingested = 0
23
+
24
+ def run(self) -> None:
25
+ for line in self._stream:
26
+ if self._stopped():
27
+ break
28
+ line = line.strip()
29
+ if not line:
30
+ continue
31
+ try:
32
+ trace = json.loads(line)
33
+ except json.JSONDecodeError as e:
34
+ print(f"agent-self-edit: skipping malformed line: {e}", file=sys.stderr)
35
+ continue
36
+ try:
37
+ self._store.ingest(trace)
38
+ self._ingested += 1
39
+ except ValueError as e:
40
+ print(
41
+ f"agent-self-edit: skipping invalid trace: {e}",
42
+ file=sys.stderr,
43
+ )
44
+ if self._ingested % self._batch_size == 0:
45
+ print(
46
+ f"agent-self-edit: ingested {self._ingested} traces",
47
+ file=sys.stderr,
48
+ )