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
agent/cli/chat.py ADDED
@@ -0,0 +1,309 @@
1
+ """The interactive shell: `otto chat`.
2
+
3
+ Every turn goes through the pipeline (agent/pipeline/ -- the router/
4
+ planner/solver/summarizer/finder/evaluator graph that replaced the
5
+ orchestrator/worker/evaluate/subtask_consensus/synthesize swarm on
6
+ 2026-09-10, which had itself replaced the Phase 11B code hive on
7
+ 2026-09-09). There is no `agents` concept anymore -- one router dispatch,
8
+ one specialist, one evaluator, per round -- so there is nothing to size or
9
+ fix for a session; see agent/pipeline/nodes.py's module docstring for the
10
+ design discussion behind the swap.
11
+
12
+ Mid-run questions (2026-09-10, same day, nodes.py's seventh refinement):
13
+ `_run_turn` used to be a single `for update in run_pipeline_stream(...)`
14
+ loop. A run can now pause partway through (an `{"__ask__": ...}` event,
15
+ not the usual `{"__final__": ...}`) when a specialist or the evaluator
16
+ gets stuck on something only the person can answer -- `_run_turn` is a
17
+ `while` loop around that same `for` now, so it can render the question,
18
+ collect an answer with `_ask_user`, and keep going via
19
+ `resume_pipeline_stream()` for as long as the run keeps asking.
20
+
21
+ A line that moves (2026-09-12, design call: "optimize latency and steps,
22
+ and improve the tui"): the graph streams one update per NODE, and `agent`
23
+ is a node that spends every model call and every tool call inside itself.
24
+ Measured across the twenty golden items, that is 828 seconds of wall time,
25
+ 96% of it inside model requests, with nothing printed in the middle of any
26
+ of them -- the longest item ran 131 seconds against a still terminal.
27
+ `_run_turn` now binds agent/pipeline/progress.py for the length of the
28
+ turn and keeps one transient Rich status line alive underneath it, saying
29
+ what the run is doing and how long it has been doing it. Ctrl-C sets the
30
+ same seam's cancel Event rather than killing the process, so a turn started
31
+ by mistake ends within one model call instead of having to be waited out.
32
+
33
+ Bounded conversation memory (2026-09-10, same day, Phase 2 of
34
+ docs/design/tiered-memory.md): `_run_turn` used to read `s.history[:-1]` --
35
+ an unbounded, ever-growing raw list `chat()` appended this turn's own
36
+ HumanMessage onto just before calling `_run_turn`, then appended the reply
37
+ onto after. `Session` now keeps that history in a bounded
38
+ `agent.memory.queue.TieredQueue` instead (agent/cli/shell.py); `_run_turn`
39
+ builds its own `HumanMessage(text)` locally rather than reading it back off
40
+ `s.history`, asks `s.history_for_graph()` for this turn's bounded
41
+ `(history, memory_context)`, and records the finished turn with
42
+ `s.record_turn()` once it has both halves -- `chat()`'s own loop no longer
43
+ touches history at all.
44
+ """
45
+
46
+ import threading
47
+ import time
48
+ from collections import Counter
49
+ from pathlib import Path
50
+ from typing import Annotated, Optional
51
+
52
+ import typer
53
+ from langchain_core.messages import AIMessage, HumanMessage
54
+ from rich.markdown import Markdown
55
+ from rich.panel import Panel
56
+
57
+ from agent.cli.output import save_final
58
+ from agent.cli.shell import (
59
+ Session, build_prompt_session, describe_workspace, dispatch, render_transcript,
60
+ render_update, resolve_workspace,
61
+ )
62
+ from agent.cli.ui import err, out
63
+ from agent.pipeline.progress import Cancelled, Progress, bind_progress
64
+ from agent.pipeline.run import resume_pipeline_stream, run_pipeline_stream
65
+ from agent.pipeline.state import AgentState
66
+
67
+
68
+ def _ask_user(prompt_session, question: str, choices: list[str]) -> str:
69
+ """Block on the person's answer to a mid-run `{"__ask__": ...}` event --
70
+ print the question, list any choices as a picked-by-number menu
71
+ (typing the number OR just typing free text both work; empty/Ctrl-C
72
+ falls back to open text next time), and always accept free text too
73
+ (the "multi choice + text bar" UX design call, nodes.py's module
74
+ docstring, seventh refinement -- the REPL's own version of it: no text
75
+ bar widget here, but the same two ways to answer).
76
+ """
77
+ out.print(Panel(Markdown(question), title="[warn]otto is asking[/]", border_style="warn"))
78
+ if choices:
79
+ for i, choice in enumerate(choices, start=1):
80
+ err.print(f"[muted]{i}.[/] {choice}")
81
+ err.print("[muted]pick a number, or just type your own answer[/]")
82
+ while True:
83
+ try:
84
+ reply = prompt_session.prompt("your answer> ").strip()
85
+ except (EOFError, KeyboardInterrupt):
86
+ return ""
87
+ if not reply:
88
+ continue
89
+ if choices and reply.isdigit() and 1 <= int(reply) <= len(choices):
90
+ return choices[int(reply) - 1]
91
+ return reply
92
+
93
+
94
+ class _Line:
95
+ """One transient status line under a turn in flight.
96
+
97
+ Rich's `Console.status` already draws a spinner that clears itself, so
98
+ this is only the bookkeeping: what the run last reported, and a clock
99
+ that keeps moving through a ten-second model call that reports nothing
100
+ while it runs.
101
+
102
+ Every method here runs on the graph's own thread, which for the REPL is
103
+ the main thread -- the same one Rich is drawing from -- so there is
104
+ nothing to marshal and no lock to take.
105
+ """
106
+
107
+ def __init__(self, status) -> None:
108
+ self._status = status
109
+ self._started = time.monotonic()
110
+ self.phase = "reading your message"
111
+ self.model = ""
112
+ self.tool = ""
113
+ self.calls = 0
114
+
115
+ def __call__(self, update: Progress) -> None:
116
+ self.calls = update.calls or self.calls
117
+ if update.kind == "call_start":
118
+ self.model, self.tool = update.text, ""
119
+ self.phase = self.phase or "thinking"
120
+ elif update.kind == "phase":
121
+ self.phase, self.tool = update.text, ""
122
+ elif update.kind == "tool":
123
+ target = (update.detail or {}).get("target", "")
124
+ self.tool = f"{update.text} {target}".strip()
125
+ else:
126
+ # A streamed partial. Nothing to draw for it here -- the REPL
127
+ # prints the settled answer as one Markdown panel, and redrawing
128
+ # a growing block above a live prompt is what Rich's own docs
129
+ # warn against. Still worth the clock tick.
130
+ pass
131
+ self.draw()
132
+
133
+ def clock(self) -> str:
134
+ elapsed = int(time.monotonic() - self._started)
135
+ return f"{elapsed // 60}:{elapsed % 60:02d}"
136
+
137
+ def draw(self) -> None:
138
+ bits = [self.phase or "thinking"]
139
+ if self.tool:
140
+ bits.append(f"[chosen]{self.tool}[/]")
141
+ if self.model:
142
+ bits.append(f"[muted]{self.model}[/]")
143
+ if self.calls:
144
+ bits.append(f"[muted]{self.calls} calls[/]")
145
+ bits.append(f"[muted]{self.clock()}[/]")
146
+ bits.append("[muted]ctrl-c to stop[/]")
147
+ self._status.update(" · ".join(bits))
148
+
149
+
150
+ def _run_turn(s: Session, text: str, prompt_session) -> None:
151
+ """One turn, under a live status line and a cancel key.
152
+
153
+ Ctrl-C inside a turn ends the turn, not the session. The REPL runs the
154
+ graph on the main thread, so an interrupt lands inside whatever model
155
+ call is in flight and there is nothing to cooperate with -- the cancel
156
+ Event is still set on the way out, because a run can be several frames
157
+ deep and the next `_call` must not spend again while the stack unwinds.
158
+ Ctrl-C at the prompt still exits, which is where a person means it.
159
+ """
160
+ cancel = threading.Event()
161
+ stopped = False
162
+ with err.status("", spinner="dots") as status:
163
+ line = _Line(status)
164
+ line.draw()
165
+ # Nothing reports anything during a ten-second model call, so the
166
+ # clock has to move on its own or the line reads as a hung process.
167
+ ticking = threading.Event()
168
+ clock = threading.Thread(target=_keep_time, args=(line, ticking), daemon=True)
169
+ clock.start()
170
+ try:
171
+ with bind_progress(line, cancel=cancel):
172
+ _drive_turn(s, text, prompt_session)
173
+ except (Cancelled, KeyboardInterrupt):
174
+ cancel.set()
175
+ stopped = True
176
+ finally:
177
+ ticking.set()
178
+ clock.join(timeout=2.0)
179
+ if stopped:
180
+ err.print("[warn]stopped[/]")
181
+ err.print(f"[muted]{line.calls} model calls · {line.clock()}[/]")
182
+
183
+
184
+ def _keep_time(line: "_Line", done: threading.Event) -> None:
185
+ """Redraw the status line once a second until the turn ends.
186
+
187
+ A thread rather than a signal or an async task: the REPL has no event
188
+ loop of its own, and Rich's Live is already safe to update from another
189
+ thread.
190
+ """
191
+ while not done.wait(1.0):
192
+ line.draw()
193
+
194
+
195
+ def _drive_turn(s: Session, text: str, prompt_session) -> None:
196
+ tally: Counter = Counter()
197
+ final: AgentState | None = None
198
+ human_message = HumanMessage(text)
199
+ # Bounded, not the raw ever-growing list -- module docstring. `history`
200
+ # is however many recent turns still fit verbatim; `memory_context` is
201
+ # whatever's older than that, already compacted (agent/memory/queue.py).
202
+ history, memory_context = s.history_for_graph()
203
+
204
+ stream = run_pipeline_stream(
205
+ text, session_id=s.session_id, history=history, memory_context=memory_context,
206
+ workspace=s.workspace_arg(),
207
+ )
208
+ while stream is not None:
209
+ next_stream = None
210
+ for update in stream:
211
+ if "__ask__" in update:
212
+ ask = update["__ask__"]
213
+ answer = _ask_user(prompt_session, ask["question"], ask["choices"])
214
+ out.print(f"[bold]you[/] {answer}")
215
+ # Breaking out leaves this generator suspended at its yield,
216
+ # INSIDE bind_budget/bind_store/bind_workspace, so their
217
+ # contextvar tokens are reset whenever the GC happens to get
218
+ # to it -- from whatever context is running then, which is not
219
+ # the one that set them. close() unwinds it here, now, on this
220
+ # thread, which is the only place the resets are valid.
221
+ stream.close()
222
+ next_stream = resume_pipeline_stream(
223
+ answer, thread_id=ask["thread_id"], session_id=s.session_id,
224
+ workspace=s.workspace_arg(),
225
+ )
226
+ break
227
+ if "__final__" in update:
228
+ final = update["__final__"]
229
+ s.trace_id = update.get("__trace_id__")
230
+ continue
231
+
232
+ node, delta = next(iter(update.items()))
233
+ render_update(node, delta, tally)
234
+ stream = next_stream
235
+
236
+ if final is not None:
237
+ raw_output = (final.get("final_output") or "").strip()
238
+ code = raw_output or "*(no output produced)*"
239
+ out.print(Panel(Markdown(code), title="[spec]final[/]", border_style="ok"))
240
+ s.record_turn(human_message, AIMessage(code) if raw_output else None)
241
+ if raw_output:
242
+ # On disk, not just on screen -- selecting a Rich panel's text
243
+ # out of a live terminal mangles box-drawing borders and wrapped
244
+ # lines (13.4's bug hunt). A plain file sidesteps that.
245
+ path = save_final(s.session_id, s.turn, raw_output, None)
246
+ err.print(f"[muted]saved to {path}[/]")
247
+
248
+
249
+ WORKSPACE_HELP = (
250
+ "Directory otto's file tools may read and write. Defaults to the current "
251
+ "directory. See agent/pipeline/workspace.py for what this grants."
252
+ )
253
+ NO_WORKSPACE_HELP = "Give otto no file access at all. Wins over --workspace."
254
+ RESUME_HELP = (
255
+ "Pick a saved session back up: its id, a unique prefix of it, or 'last'. "
256
+ "`otto sessions` lists them. Its workspace comes back with it unless "
257
+ "--workspace/--no-workspace say otherwise."
258
+ )
259
+
260
+
261
+ def open_session(ctx_obj, workspace: Optional[Path], no_workspace: bool, resume: Optional[str]) -> Session:
262
+ """The Session `otto chat` and `otto tui` both start from. Shared so
263
+ `--resume` means the same thing at both prompts: the saved session, with
264
+ its saved workspace, unless a workspace flag was given explicitly --
265
+ which is the one case `resolve_workspace`'s cwd default must NOT win,
266
+ since a person resuming work on a repository from their home directory
267
+ did not mean to move it there. Raises LookupError for an unknown ref.
268
+ """
269
+ s = Session(ctx=ctx_obj, workspace=resolve_workspace(workspace, no_workspace))
270
+ if resume is not None:
271
+ s.load(resume)
272
+ if workspace is not None or no_workspace:
273
+ s.workspace = resolve_workspace(workspace, no_workspace)
274
+ return s
275
+
276
+
277
+ def chat(
278
+ ctx: typer.Context,
279
+ workspace: Annotated[Optional[Path], typer.Option("--workspace", "-w", help=WORKSPACE_HELP)] = None,
280
+ no_workspace: Annotated[bool, typer.Option("--no-workspace", help=NO_WORKSPACE_HELP)] = False,
281
+ resume: Annotated[Optional[str], typer.Option("--resume", "-r", help=RESUME_HELP)] = None,
282
+ ) -> None:
283
+ """Talk to the pipeline."""
284
+ try:
285
+ s = open_session(ctx.obj, workspace, no_workspace, resume)
286
+ except LookupError as exc:
287
+ err.print(f"[bad]{exc}[/]")
288
+ raise typer.Exit(1)
289
+ err.print("[muted]otto:pipeline[/]")
290
+ err.print(f"[muted]{describe_workspace(s.workspace)}[/]")
291
+ err.print("[muted]/help for commands[/]")
292
+ if resume is not None:
293
+ err.print(f"[muted]resumed {s.session_id[:8]} · {s.title or '(untitled)'} · {s.turn} turn(s)[/]")
294
+ render_transcript(s)
295
+
296
+ prompt_session = build_prompt_session()
297
+
298
+ while True:
299
+ try:
300
+ text = prompt_session.prompt("you> ")
301
+ except (EOFError, KeyboardInterrupt):
302
+ out.print("\n[muted]bye[/]")
303
+ break
304
+ if not text.strip():
305
+ continue
306
+ if dispatch(s, text):
307
+ continue
308
+
309
+ _run_turn(s, text, prompt_session)
agent/cli/clipboard.py ADDED
@@ -0,0 +1,106 @@
1
+ """Copying text out of the TUI (2026-09-13, design call: "one can copy
2
+ anything from the UI except borders").
3
+
4
+ Textual already lets a person drag-select across widgets and press ctrl+c;
5
+ what it hands back and where it puts it are the two halves this module owns.
6
+
7
+ * `clean()` -- the selection as a person means it. CSS borders are drawn by
8
+ the compositor and never appear in selected text, but a Rich table's own
9
+ rules and column bars do. Lines made only of box-drawing are dropped and
10
+ edge bars are trimmed, so a selection over the doctor table comes out as
11
+ the words in it.
12
+ * `copy()` -- two routes at once. OSC 52 (what `App.copy_to_clipboard`
13
+ writes) works in most terminals and not in macOS Terminal.app; a native
14
+ command (`pbcopy`, `wl-copy`, `xclip`, `xsel`) works wherever one is
15
+ installed. Both are tried, and the return value says which landed, so the
16
+ toast can be honest about it.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import re
21
+ import shutil
22
+ import subprocess
23
+
24
+ __all__ = ["clean", "copy", "native_command", "plain_text_of"]
25
+
26
+
27
+ def plain_text_of(content, width: int) -> str:
28
+ """What a Static is showing, as the plain text a person would read off
29
+ the screen -- rendered at the widget's width so line breaks match what a
30
+ selection's coordinates point at. Markup strings and Text are taken as
31
+ they are; a Rich renderable (Markdown, Table, Group) is rendered."""
32
+ import io
33
+
34
+ from rich.console import Console
35
+ from rich.text import Text
36
+
37
+ if isinstance(content, str):
38
+ return Text.from_markup(content).plain
39
+ if isinstance(content, Text):
40
+ return content.plain
41
+ buf = io.StringIO()
42
+ Console(file=buf, width=max(8, width), force_terminal=False, no_color=True,
43
+ highlight=False, legacy_windows=False).print(content, end="")
44
+ return buf.getvalue()
45
+
46
+ _BOX = "─━│┃┄┅┆┇┈┉┊┋┌┍┎┏┐┑┒┓└┕┖┗┘┙┚┛├┝┞┟┠┡┢┣┤┥┦┧┨┩┪┫┬┭┮┯┰┱┲┳┴┵┶┷┸┹┺┻┼┽┾┿╀╁╂╃╄╅╆╇╈╉╊╋╌╍╎╏═║╒╓╔╕╖╗╘╙╚╛╜╝╞╟╠╡╢╣╤╥╦╧╨╩╪╫╬╭╮╯╰╱╲╳╴╵╶╷╸╹╺╻╼╽╾╿"
47
+ _ONLY_BOX = re.compile(rf"^[\s{re.escape(_BOX)}]*$")
48
+ _EDGE_BARS = re.compile(rf"^[{re.escape(_BOX)}]\s?|\s?[{re.escape(_BOX)}]$")
49
+ _INNER_BAR = re.compile(r"\s[│┃║]\s")
50
+
51
+
52
+ def clean(text: str) -> str:
53
+ """Selected text without any box-drawing: rule-only lines go, edge bars
54
+ go, and a column bar between cells becomes two spaces."""
55
+ kept: list[str] = []
56
+ for line in (text or "").splitlines():
57
+ if _ONLY_BOX.match(line):
58
+ continue
59
+ line = _EDGE_BARS.sub("", line)
60
+ line = _INNER_BAR.sub(" ", line)
61
+ kept.append(line.rstrip())
62
+ while kept and not kept[0].strip():
63
+ kept.pop(0)
64
+ while kept and not kept[-1].strip():
65
+ kept.pop()
66
+ return "\n".join(kept)
67
+
68
+
69
+ _NATIVE: tuple[tuple[str, tuple[str, ...]], ...] = (
70
+ ("pbcopy", ()),
71
+ ("wl-copy", ()),
72
+ ("xclip", ("-selection", "clipboard")),
73
+ ("xsel", ("--clipboard", "--input")),
74
+ )
75
+
76
+
77
+ def native_command() -> list[str] | None:
78
+ """The first clipboard command installed here, or None."""
79
+ for name, args in _NATIVE:
80
+ path = shutil.which(name)
81
+ if path:
82
+ return [path, *args]
83
+ return None
84
+
85
+
86
+ def copy(app, text: str) -> str:
87
+ """Put `text` on the clipboard by every route available. Returns a short
88
+ description of what worked, for the toast."""
89
+ routes: list[str] = []
90
+ try:
91
+ app.copy_to_clipboard(text)
92
+ routes.append("OSC 52")
93
+ except Exception:
94
+ pass
95
+ command = native_command()
96
+ if command:
97
+ try:
98
+ subprocess.run(command, input=text.encode("utf-8"), check=True, timeout=3,
99
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
100
+ routes.append(command[0].rsplit("/", 1)[-1])
101
+ except (OSError, subprocess.SubprocessError):
102
+ pass
103
+ if not routes:
104
+ return "no clipboard route worked"
105
+ note = "" if len(routes) > 1 or routes != ["OSC 52"] else " (macOS Terminal.app ignores this; iTerm, Ghostty, kitty, WezTerm honour it)"
106
+ return "via " + " + ".join(routes) + note
agent/cli/context.py ADDED
@@ -0,0 +1,56 @@
1
+ import atexit
2
+ import os
3
+ from importlib.metadata import PackageNotFoundError, version
4
+ from typing import Any
5
+
6
+ from agent.router.router import Router
7
+
8
+
9
+ def _release() -> str:
10
+ """Installed version, or 'dev' outside an installed environment."""
11
+ try:
12
+ return version("otto-cli-agent")
13
+ except PackageNotFoundError:
14
+ return "dev"
15
+
16
+
17
+ class AppContext:
18
+ def __init__(self, *, strict: bool = False) -> None:
19
+ self.strict = strict
20
+ self._router: Router | None = None
21
+ self._handler: Any | None = None
22
+
23
+ @property
24
+ def router(self) -> Router:
25
+ if self._router is None:
26
+ self._router = Router(strict=self.strict)
27
+ return self._router
28
+
29
+ @property
30
+ def handler(self):
31
+ if self._handler is None:
32
+ from langfuse import get_client
33
+ from langfuse.langchain import CallbackHandler
34
+
35
+ # Groups traces by build in the Langfuse UI. setdefault, so a
36
+ # deploy or CI run can override with a commit SHA:
37
+ # LANGFUSE_RELEASE=$GITHUB_SHA otto chat
38
+ os.environ.setdefault("LANGFUSE_RELEASE", _release())
39
+
40
+ self._handler = CallbackHandler()
41
+ atexit.register(get_client().flush)
42
+ return self._handler
43
+
44
+ @property
45
+ def client(self):
46
+ """The Langfuse client itself, for the things a callback cannot do.
47
+
48
+ Scores are the main one: the handler reports what happened, but a
49
+ score is a judgement about it, so it is always an explicit call.
50
+ Reading `handler` first is deliberate -- that is what sets
51
+ LANGFUSE_RELEASE and registers the flush at exit.
52
+ """
53
+ self.handler
54
+ from langfuse import get_client
55
+
56
+ return get_client()
agent/cli/doctor.py ADDED
@@ -0,0 +1,60 @@
1
+ import typer
2
+ from rich.table import Table
3
+ from rich.panel import Panel
4
+ from rich import box
5
+
6
+ from agent.cli.ui import err, out
7
+ from agent.router.llm_provider import health_report
8
+ from agent.router.llm_provider.base import HealthReport, ProviderStatus
9
+ from agent.router.router import Router
10
+
11
+ STYLE = {ProviderStatus.OK: "ok", ProviderStatus.NO_KEY: "muted",
12
+ ProviderStatus.AUTH_FAILED: "bad",
13
+ ProviderStatus.UNREACHABLE: "warn", ProviderStatus.ERROR: "bad"}
14
+
15
+
16
+ def health_table(reports: list[HealthReport]) -> Table:
17
+ t = Table(box=box.SIMPLE, header_style="muted")
18
+ t.add_column("provider")
19
+ t.add_column("status")
20
+ t.add_column("models", justify="right")
21
+ t.add_column("detail", style="muted", overflow="fold")
22
+ for r in reports:
23
+ t.add_row(
24
+ r.provider,
25
+ f"[{STYLE[r.status]}]{r.status.value}[/]",
26
+ str(r.model_count or ""),
27
+ r.detail
28
+ )
29
+ return t
30
+
31
+
32
+ def router_view(router: Router) -> Panel:
33
+ t = Table(box=None, show_header=False, pad_edge=False)
34
+ t.add_column(style="muted")
35
+ t.add_column()
36
+ state = "[ok]configured[/]" if router.ready() else "[bad]missing -- set INCEPTION_API_KEY (otto tui -> Setup)[/]"
37
+ t.add_row("required", f"{router.REQUIRED} {state}")
38
+ # Every configured provider is usable now -- there is no single "secondary"
39
+ # seat any more (agent/router/router.py's own docstring for why).
40
+ optional = tuple(p for p in router.usable() if p != router.REQUIRED)
41
+ t.add_row("also configured", ", ".join(optional) if optional else "[muted]none[/]")
42
+ return Panel(t, box=box.ROUNDED, border_style="muted", expand=False)
43
+
44
+
45
+ def doctor(ctx: typer.Context) -> None:
46
+ """Check every provider with a real call, and show the routing policy."""
47
+
48
+ with err.status("contacting providers"):
49
+ reports = health_report()
50
+
51
+ out.print(health_table(reports))
52
+
53
+ # The table above is the diagnosis; the panel below is the conclusion.
54
+ # A Router constructs without Inception now (agent/router/router.py), so
55
+ # the missing-required case is a row in that panel and a non-zero exit,
56
+ # not an exception that replaces the finding the table built toward.
57
+ router = ctx.obj.router
58
+ out.print(router_view(router))
59
+ if not router.ready():
60
+ raise typer.Exit(2)
agent/cli/errors.py ADDED
@@ -0,0 +1,58 @@
1
+ """Turn provider-layer exceptions into CLI errors.
2
+
3
+ The router raises `AuthError` from `Router.require_ready()` (called by
4
+ `resolve()` and the pipeline's entry points) and `NoViableRoute` /
5
+ `RoutingDegraded` from `resolve()`. Those are the right
6
+ exceptions for a library, but a CLI must never answer a user with a traceback:
7
+ it buries the diagnosis and trains people to ignore stack traces.
8
+
9
+ `friendly` is applied once per command at registration, so a new command can
10
+ never forget it.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import functools
16
+ from collections.abc import Callable
17
+ from typing import Any, TypeVar
18
+
19
+ import typer
20
+
21
+ from agent.cli.ui import err
22
+ from agent.router.llm_provider.base import AuthError, ProviderError
23
+ from agent.router.router import NoViableRoute, RoutingDegraded
24
+
25
+ F = TypeVar("F", bound=Callable[..., Any])
26
+
27
+ #: Exit codes, so a shell script can tell these apart.
28
+ EXIT_NO_KEY = 2
29
+ EXIT_NO_ROUTE = 3
30
+ EXIT_DEGRADED = 4
31
+ EXIT_PROVIDER = 5
32
+
33
+
34
+ def friendly(command: F) -> F:
35
+ """Render expected failures as CLI errors instead of tracebacks."""
36
+
37
+ @functools.wraps(command) # keeps the signature Typer introspects
38
+ def wrapper(*args: Any, **kwargs: Any) -> Any:
39
+ try:
40
+ return command(*args, **kwargs)
41
+ except AuthError as exc:
42
+ err.print(f"[bad]{exc}[/]")
43
+ err.print("[muted]run `otto doctor` to see which providers are configured[/]")
44
+ raise typer.Exit(EXIT_NO_KEY) from None
45
+ except NoViableRoute as exc:
46
+ err.print(f"[bad]{exc}[/]")
47
+ raise typer.Exit(EXIT_NO_ROUTE) from None
48
+ except RoutingDegraded as exc:
49
+ err.print(f"[warn]{exc}[/]")
50
+ err.print("[muted]drop --strict to allow the fallback[/]")
51
+ raise typer.Exit(EXIT_DEGRADED) from None
52
+ except ProviderError as exc:
53
+ # Anything else the provider layer defines: still ours, still not
54
+ # a traceback the user should have to read.
55
+ err.print(f"[bad]{type(exc).__name__}: {exc}[/]")
56
+ raise typer.Exit(EXIT_PROVIDER) from None
57
+
58
+ return wrapper # type: ignore[return-value]