otto-cli-agent 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.
Files changed (129) hide show
  1. agent/README.md +22 -0
  2. agent/__init__.py +0 -0
  3. agent/cli/README.md +77 -0
  4. agent/cli/__init__.py +0 -0
  5. agent/cli/art.py +371 -0
  6. agent/cli/chat.py +309 -0
  7. agent/cli/clipboard.py +106 -0
  8. agent/cli/context.py +56 -0
  9. agent/cli/doctor.py +60 -0
  10. agent/cli/errors.py +58 -0
  11. agent/cli/eval.py +130 -0
  12. agent/cli/eval_claw.py +414 -0
  13. agent/cli/eval_compaction.py +106 -0
  14. agent/cli/eval_hle.py +92 -0
  15. agent/cli/eval_memory.py +253 -0
  16. agent/cli/eval_swe.py +172 -0
  17. agent/cli/lessons.py +97 -0
  18. agent/cli/main.py +67 -0
  19. agent/cli/modals.py +570 -0
  20. agent/cli/models.py +64 -0
  21. agent/cli/output.py +54 -0
  22. agent/cli/route.py +78 -0
  23. agent/cli/sessions.py +125 -0
  24. agent/cli/setup_screen.py +562 -0
  25. agent/cli/shell.py +548 -0
  26. agent/cli/tui.py +1807 -0
  27. agent/cli/ui.py +14 -0
  28. agent/cli/usage_panel.py +159 -0
  29. agent/config/README.md +7 -0
  30. agent/config/__init__.py +0 -0
  31. agent/config/envfile.py +76 -0
  32. agent/eval/README.md +76 -0
  33. agent/eval/__init__.py +0 -0
  34. agent/eval/claw_bench.py +1031 -0
  35. agent/eval/compaction_bench.py +229 -0
  36. agent/eval/data/README.md +10 -0
  37. agent/eval/data/claw/README.md +108 -0
  38. agent/eval/data/claw/llm_judge-gemini.patch +57 -0
  39. agent/eval/data/claw/otto.yaml +35 -0
  40. agent/eval/failures.py +276 -0
  41. agent/eval/golden/README.md +33 -0
  42. agent/eval/golden/code_01.json +6 -0
  43. agent/eval/golden/code_02.json +6 -0
  44. agent/eval/golden/code_03.json +6 -0
  45. agent/eval/golden/code_04.json +6 -0
  46. agent/eval/golden/code_05.json +6 -0
  47. agent/eval/golden/code_06.json +6 -0
  48. agent/eval/golden/math_01.json +6 -0
  49. agent/eval/golden/math_02.json +6 -0
  50. agent/eval/golden/math_03.json +6 -0
  51. agent/eval/golden/math_04.json +6 -0
  52. agent/eval/golden/math_05.json +6 -0
  53. agent/eval/golden/math_06.json +6 -0
  54. agent/eval/golden/nphard_gcp_01.json +6 -0
  55. agent/eval/golden/nphard_ksp_01.json +6 -0
  56. agent/eval/golden/nphard_math_binpacking_01.json +6 -0
  57. agent/eval/golden/nphard_math_clique_01.json +6 -0
  58. agent/eval/golden/nphard_math_setcover_01.json +6 -0
  59. agent/eval/golden/nphard_math_subsetsum_01.json +6 -0
  60. agent/eval/golden/nphard_tsp_01.json +6 -0
  61. agent/eval/golden/nphard_tsp_02.json +6 -0
  62. agent/eval/hle_bench.py +273 -0
  63. agent/eval/langfuse_sync.py +172 -0
  64. agent/eval/memory_bench.py +538 -0
  65. agent/eval/runner.py +174 -0
  66. agent/eval/single_agent.py +120 -0
  67. agent/eval/swe_bench.py +604 -0
  68. agent/eval/terminal_bench.py +345 -0
  69. agent/memory/README.md +102 -0
  70. agent/memory/__init__.py +42 -0
  71. agent/memory/embeddings.py +302 -0
  72. agent/memory/hashing.py +15 -0
  73. agent/memory/lessons.py +483 -0
  74. agent/memory/queue.py +531 -0
  75. agent/memory/retrieval.py +493 -0
  76. agent/memory/session.py +60 -0
  77. agent/memory/sessions.py +436 -0
  78. agent/memory/store.py +429 -0
  79. agent/memory/tokens.py +60 -0
  80. agent/memory/wiring.py +146 -0
  81. agent/pipeline/README.md +135 -0
  82. agent/pipeline/__init__.py +0 -0
  83. agent/pipeline/browsing.py +609 -0
  84. agent/pipeline/budget.py +403 -0
  85. agent/pipeline/codemap.py +254 -0
  86. agent/pipeline/evidence.py +325 -0
  87. agent/pipeline/execution.py +67 -0
  88. agent/pipeline/modes.py +137 -0
  89. agent/pipeline/native.py +1137 -0
  90. agent/pipeline/nodes.py +3644 -0
  91. agent/pipeline/pricing.py +209 -0
  92. agent/pipeline/progress.py +139 -0
  93. agent/pipeline/rag.py +139 -0
  94. agent/pipeline/research.py +1325 -0
  95. agent/pipeline/run.py +528 -0
  96. agent/pipeline/screen.py +77 -0
  97. agent/pipeline/state.py +220 -0
  98. agent/pipeline/toolkit.py +328 -0
  99. agent/pipeline/tools.py +1990 -0
  100. agent/pipeline/tracing.py +147 -0
  101. agent/pipeline/usage.py +251 -0
  102. agent/pipeline/vision.py +84 -0
  103. agent/pipeline/walkthrough.py +735 -0
  104. agent/pipeline/workspace.py +229 -0
  105. agent/router/README.md +60 -0
  106. agent/router/__init__.py +0 -0
  107. agent/router/automap.py +114 -0
  108. agent/router/health.py +229 -0
  109. agent/router/llm_provider/README.md +38 -0
  110. agent/router/llm_provider/__init__.py +202 -0
  111. agent/router/llm_provider/anthropic_provider.py +128 -0
  112. agent/router/llm_provider/base.py +507 -0
  113. agent/router/llm_provider/custom.py +152 -0
  114. agent/router/llm_provider/gemini_provider.py +122 -0
  115. agent/router/llm_provider/inception_provider.py +687 -0
  116. agent/router/llm_provider/openai_provider.py +151 -0
  117. agent/router/llm_provider/retired.py +145 -0
  118. agent/router/llm_provider/temperature.py +371 -0
  119. agent/router/mapping.py +579 -0
  120. agent/router/outcomes.py +363 -0
  121. agent/router/overrides.py +389 -0
  122. agent/router/reload.py +28 -0
  123. agent/router/router.py +413 -0
  124. agent/router/setup.py +123 -0
  125. otto_cli_agent-0.1.0.dist-info/METADATA +115 -0
  126. otto_cli_agent-0.1.0.dist-info/RECORD +129 -0
  127. otto_cli_agent-0.1.0.dist-info/WHEEL +4 -0
  128. otto_cli_agent-0.1.0.dist-info/entry_points.txt +2 -0
  129. otto_cli_agent-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,363 @@
1
+ """What each seat's model actually achieved, recorded so the chain can be
2
+ ordered by evidence instead of by the order somebody typed it in.
3
+
4
+ Milestone 4's last piece. Logging verified per-(task, model) outcomes and
5
+ routing on them is worth about +15.3% relative on its own, and it is the one
6
+ form of adaptation that costs no model calls: the evidence is a by-product of
7
+ runs that were happening anyway.
8
+
9
+ WHAT IS RECORDED, AND WHY SO LITTLE. A run's approval cannot be attributed to
10
+ one model when the agent changed modes mid-run -- three models touched the
11
+ answer and only one verdict came back. So a multi-mode run records NOTHING.
12
+ That throws away real data, and it is still the right call: a log that
13
+ mis-attributes is worse than a smaller honest one, because the whole purpose
14
+ of the log is to be trusted enough to change routing.
15
+
16
+ WHAT IT CANNOT LEARN. The evaluator's own seat. Every run uses it, and nothing
17
+ in a run says whether the judge was RIGHT -- crediting the judge with the
18
+ verdict it issued would be a system marking its own homework. Those seats stay
19
+ where mapping.py pins them until something outside a run says otherwise.
20
+
21
+ THE ORDERING IS CONSERVATIVE ON PURPOSE. A candidate only moves when it has
22
+ `MIN_SAMPLES` runs behind it, and it is ordered on approval rate with cost as
23
+ the tiebreak. Below that threshold the declared order in mapping.py stands
24
+ exactly as written, so a fresh install, a test, and a route nobody has
25
+ exercised all behave the way they read.
26
+ """
27
+ from __future__ import annotations
28
+
29
+ import logging
30
+ import random
31
+ import sqlite3
32
+ from contextlib import contextmanager
33
+ from contextvars import ContextVar
34
+ from dataclasses import dataclass
35
+ from pathlib import Path
36
+
37
+ log = logging.getLogger(__name__)
38
+
39
+ #: Where the log lives. Beside the memory stores rather than in the repo: it is
40
+ #: a record of what this installation has observed, not a fact about the code.
41
+ DB_DIR = Path.home() / ".otto"
42
+
43
+ #: How many runs a (task, model) pair needs before its number is allowed to
44
+ #: move anything. Small samples on a benchmark whose own scores swing 0.36
45
+ #: between identical runs are noise, and acting on noise is how a router
46
+ #: convinces itself a coin is weighted.
47
+ MIN_SAMPLES = 12
48
+
49
+ #: How often the chain is handed to the candidate we know LEAST about instead
50
+ #: of the one the evidence prefers.
51
+ #:
52
+ #: Without this the ordering freezes permanently, and the first version of
53
+ #: this file had exactly that bug. Once a candidate is demoted it stops being
54
+ #: resolved, so it stops accruing runs, so its record stays frozen at the
55
+ #: twelve samples that demoted it -- measured: 200 further runs left the
56
+ #: loser on 12. Twelve runs then decide a seat forever, and a model that
57
+ #: later improves (a newer pin, a provider that fixed something, or simply
58
+ #: twelve unlucky draws) never gets a second chance.
59
+ #:
60
+ #: One in ten, and only while the log is writable: exploring without
61
+ #: recording what happened costs a worse answer and learns nothing, which is
62
+ #: also what keeps a held-out measurement reproducible.
63
+ EXPLORATION_RATE = 0.10
64
+
65
+ #: How much better one candidate's approval rate has to be before it overtakes
66
+ #: a candidate declared above it. A margin, not a strict comparison, because at
67
+ #: MIN_SAMPLES a difference of one run is a difference of 8 points.
68
+ MIN_MARGIN = 0.10
69
+
70
+ _SCHEMA = """
71
+ CREATE TABLE IF NOT EXISTS seat_outcomes (
72
+ task TEXT NOT NULL,
73
+ model_id TEXT NOT NULL,
74
+ runs INTEGER NOT NULL DEFAULT 0,
75
+ approved INTEGER NOT NULL DEFAULT 0,
76
+ calls INTEGER NOT NULL DEFAULT 0,
77
+ PRIMARY KEY (task, model_id)
78
+ );
79
+ """
80
+
81
+
82
+ @dataclass(frozen=True)
83
+ class SeatRecord:
84
+ task: str
85
+ model_id: str
86
+ runs: int
87
+ approved: int
88
+ calls: int
89
+
90
+ @property
91
+ def approval_rate(self) -> float:
92
+ return self.approved / self.runs if self.runs else 0.0
93
+
94
+ @property
95
+ def calls_per_run(self) -> float:
96
+ return self.calls / self.runs if self.runs else 0.0
97
+
98
+ @property
99
+ def trusted(self) -> bool:
100
+ return self.runs >= MIN_SAMPLES
101
+
102
+
103
+ _UNSET = object()
104
+ _LOG: ContextVar = ContextVar("otto_seat_outcomes", default=_UNSET)
105
+
106
+
107
+ def log_path() -> Path:
108
+ return DB_DIR / "outcomes.db"
109
+
110
+
111
+ @contextmanager
112
+ def bind_log(path: Path | str | None):
113
+ """Point the log somewhere else, or turn it off with None.
114
+
115
+ Off, not "use the default" -- same reason agent/memory/lessons.py draws
116
+ that distinction. A measurement that is supposed to hold routing fixed has
117
+ to be able to say so, or the thing being measured changes underneath it.
118
+ """
119
+ token = _LOG.set(None if path is None else Path(path))
120
+ try:
121
+ yield
122
+ finally:
123
+ _LOG.reset(token)
124
+
125
+
126
+ #: Whether a run may WRITE what it observed. Separate from whether it may
127
+ #: read, because a held-out measurement needs exactly that combination: route
128
+ #: on everything the development runs observed, contribute nothing back. Same
129
+ #: split, and the same reason, as agent/memory/lessons.py's.
130
+ _WRITES: ContextVar[bool] = ContextVar("otto_seat_writes", default=True)
131
+
132
+
133
+ @contextmanager
134
+ def read_only():
135
+ """Routing may read the log, nothing may be added to it."""
136
+ token = _WRITES.set(False)
137
+ try:
138
+ yield
139
+ finally:
140
+ _WRITES.reset(token)
141
+
142
+
143
+ def _connect() -> sqlite3.Connection | None:
144
+ bound = _LOG.get()
145
+ if bound is None:
146
+ return None
147
+ path = log_path() if bound is _UNSET else bound
148
+ try:
149
+ path.parent.mkdir(parents=True, exist_ok=True)
150
+ conn = sqlite3.connect(str(path), check_same_thread=False)
151
+ conn.executescript(_SCHEMA)
152
+ return conn
153
+ except sqlite3.Error as exc:
154
+ # Routing must never stop because a log could not be opened.
155
+ log.warning("seat outcome log unavailable: %s", exc)
156
+ return None
157
+
158
+
159
+ def record(task: str, model_id: str, *, approved: bool, calls: int = 0) -> None:
160
+ """One finished, single-mode run against one seat."""
161
+ if not _WRITES.get():
162
+ return
163
+ conn = _connect()
164
+ if conn is None or not model_id:
165
+ return
166
+ try:
167
+ conn.execute(
168
+ "INSERT INTO seat_outcomes (task, model_id, runs, approved, calls) "
169
+ "VALUES (?, ?, 1, ?, ?) "
170
+ "ON CONFLICT(task, model_id) DO UPDATE SET "
171
+ " runs = runs + 1, approved = approved + ?, calls = calls + ?",
172
+ (str(task), model_id, int(approved), int(calls),
173
+ int(approved), int(calls)),
174
+ )
175
+ conn.commit()
176
+ except sqlite3.Error as exc:
177
+ log.warning("could not record seat outcome: %s", exc)
178
+ finally:
179
+ conn.close()
180
+
181
+
182
+ def records(task: str | None = None) -> list[SeatRecord]:
183
+ """Everything observed, best approval rate first within each task."""
184
+ conn = _connect()
185
+ if conn is None:
186
+ return []
187
+ try:
188
+ sql = "SELECT task, model_id, runs, approved, calls FROM seat_outcomes"
189
+ args: tuple = ()
190
+ if task is not None:
191
+ sql += " WHERE task = ?"
192
+ args = (str(task),)
193
+ rows = conn.execute(sql, args).fetchall()
194
+ except sqlite3.Error:
195
+ return []
196
+ finally:
197
+ conn.close()
198
+ found = [SeatRecord(*row) for row in rows]
199
+ found.sort(key=lambda r: (r.task, -r.approval_rate, r.calls_per_run))
200
+ return found
201
+
202
+
203
+ def preference(task: str) -> dict[str, SeatRecord]:
204
+ """Trusted records for `task`, by model id. Untrusted ones are left out
205
+ entirely rather than returned with a caveat -- a caller that has to
206
+ remember to check `trusted` is a caller that will forget."""
207
+ return {r.model_id: r for r in records(task) if r.trusted}
208
+
209
+
210
+ def spec_id(candidate) -> str:
211
+ """The model id a candidate resolves to, when that is knowable without
212
+ asking a provider. A capability query is not a model yet, so it gets "" --
213
+ and `reorder` then leaves it exactly where it was declared, because the
214
+ log is keyed on a model.
215
+
216
+ Here rather than on Router because it is a fact about this log's key, and
217
+ the router is one caller of it.
218
+ """
219
+ spec = getattr(candidate, "spec", None)
220
+ if not spec:
221
+ return ""
222
+ _, _, model_id = spec.partition(":")
223
+ return model_id
224
+
225
+
226
+ def reorder(task: str, candidates, model_id_of=spec_id) -> list:
227
+ """`candidates`, best-evidenced first, declared order preserved otherwise.
228
+
229
+ `model_id_of(candidate)` returns the model id a candidate would resolve to,
230
+ or "" when that cannot be known without asking a provider -- an open query,
231
+ for instance.
232
+
233
+ ONLY TWO MEASURED CANDIDATES EVER TRADE PLACES. A candidate nobody has
234
+ evidence about keeps its declared position, even against one with a good
235
+ record, because "we measured A at 90%" is not evidence that B is worse than
236
+ A -- B has no number at all. The declared order in mapping.py encodes a
237
+ person's judgment, and unseating it needs evidence on both sides.
238
+
239
+ Between two measured candidates the challenger has to be ahead by
240
+ `MIN_MARGIN` to overtake. Inside that margin the incumbent keeps its place
241
+ unless it is also spending more calls per run to get the same result, which
242
+ is the one case where a tie is not really a tie.
243
+
244
+ Adjacent swaps rather than a sort key: a chain is two or three candidates
245
+ long, and "B overtakes A" is a comparison between two records, not a score
246
+ each can be given on its own.
247
+ """
248
+ known = preference(task)
249
+ if not known:
250
+ return list(candidates)
251
+
252
+ ordered = list(candidates)
253
+ if explorer := _explore(ordered, known, model_id_of):
254
+ ordered.remove(explorer)
255
+ return [explorer, *ordered]
256
+
257
+ for _ in range(len(ordered)):
258
+ settled = True
259
+ for i in range(len(ordered) - 1):
260
+ incumbent = known.get(model_id_of(ordered[i]) or "")
261
+ challenger = known.get(model_id_of(ordered[i + 1]) or "")
262
+ if _overtakes(challenger, incumbent):
263
+ ordered[i], ordered[i + 1] = ordered[i + 1], ordered[i]
264
+ settled = False
265
+ if settled:
266
+ break
267
+ return ordered
268
+
269
+
270
+ def _explore(candidates, known, model_id_of):
271
+ """Occasionally, the trusted candidate with the fewest runs behind it.
272
+
273
+ Exploitation alone is a ratchet: the winner keeps winning because only
274
+ the winner is ever asked. This is the one in ten that keeps every
275
+ measured candidate's record alive. It returns None the rest of the time,
276
+ and always when the log cannot be written to -- an exploration nobody
277
+ records is a worse answer bought for nothing.
278
+ """
279
+ if not _WRITES.get() or random.random() >= EXPLORATION_RATE:
280
+ return None
281
+ measured = [(known[mid], c) for c in candidates
282
+ if (mid := model_id_of(c) or "") in known]
283
+ if len(measured) < 2:
284
+ return None # nothing to explore between
285
+ return min(measured, key=lambda pair: pair[0].runs)[1]
286
+
287
+
288
+ def _overtakes(challenger: SeatRecord | None, incumbent: SeatRecord | None) -> bool:
289
+ """Whether the candidate declared SECOND should be tried first."""
290
+ if challenger is None or incumbent is None:
291
+ return False
292
+ # `>=`, not `>`. The docstring says the challenger has to be ahead BY
293
+ # MIN_MARGIN, and at exactly that margin a strict `>` sent it to the tie
294
+ # branch below instead -- so "ahead by ten points" did not overtake while
295
+ # "ahead by ten points and a hair" did. Nothing in the numbers justifies
296
+ # that line falling between them.
297
+ if challenger.approval_rate >= incumbent.approval_rate + MIN_MARGIN:
298
+ return True
299
+ if abs(challenger.approval_rate - incumbent.approval_rate) < MIN_MARGIN:
300
+ # Same result, measurably cheaper. A fifth fewer calls per run, so
301
+ # that ordinary run-to-run variation does not shuffle the chain.
302
+ return challenger.calls_per_run < incumbent.calls_per_run * 0.8
303
+ return False
304
+
305
+
306
+ # --------------------------------------------------------------------------
307
+ # Moving the log between machines
308
+ # --------------------------------------------------------------------------
309
+
310
+ def export_records(path: Path) -> int:
311
+ """Every row as JSON. Returns how many."""
312
+ import json
313
+
314
+ rows = [{"task": r.task, "model_id": r.model_id, "runs": r.runs,
315
+ "approved": r.approved, "calls": r.calls} for r in records()]
316
+ path = Path(path).expanduser()
317
+ path.parent.mkdir(parents=True, exist_ok=True)
318
+ path.write_text(json.dumps(rows, indent=2) + "\n")
319
+ return len(rows)
320
+
321
+
322
+ def import_records(path: Path) -> int:
323
+ """Merge rows from `path` ADDITIVELY -- runs, approvals and calls are
324
+ summed onto whatever this machine already observed, which is the only
325
+ semantics consistent with `record()`. Honours `read_only()`. Returns how
326
+ many rows were merged."""
327
+ import json
328
+
329
+ if not _WRITES.get():
330
+ return 0
331
+ path = Path(path).expanduser()
332
+ try:
333
+ rows = json.loads(path.read_text())
334
+ except (OSError, ValueError) as exc:
335
+ raise ValueError(f"cannot read {path}: {exc}") from exc
336
+ if not isinstance(rows, list):
337
+ raise ValueError(f"{path} does not hold a list of seat records")
338
+ conn = _connect()
339
+ if conn is None:
340
+ return 0
341
+ merged = 0
342
+ try:
343
+ for row in rows:
344
+ if not isinstance(row, dict) or not row.get("task") or not row.get("model_id"):
345
+ continue
346
+ runs, approved, calls = (int(row.get(k) or 0) for k in ("runs", "approved", "calls"))
347
+ if runs <= 0:
348
+ continue
349
+ conn.execute(
350
+ "INSERT INTO seat_outcomes (task, model_id, runs, approved, calls) "
351
+ "VALUES (?, ?, ?, ?, ?) "
352
+ "ON CONFLICT(task, model_id) DO UPDATE SET "
353
+ " runs = runs + ?, approved = approved + ?, calls = calls + ?",
354
+ (str(row["task"]), str(row["model_id"]), runs, approved, calls,
355
+ runs, approved, calls),
356
+ )
357
+ merged += 1
358
+ conn.commit()
359
+ except sqlite3.Error as exc:
360
+ log.warning("could not import seat outcomes: %s", exc)
361
+ finally:
362
+ conn.close()
363
+ return merged