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.
- agent/README.md +22 -0
- agent/__init__.py +0 -0
- agent/cli/README.md +77 -0
- agent/cli/__init__.py +0 -0
- agent/cli/art.py +371 -0
- agent/cli/chat.py +309 -0
- agent/cli/clipboard.py +106 -0
- agent/cli/context.py +56 -0
- agent/cli/doctor.py +60 -0
- agent/cli/errors.py +58 -0
- agent/cli/eval.py +130 -0
- agent/cli/eval_claw.py +414 -0
- agent/cli/eval_compaction.py +106 -0
- agent/cli/eval_hle.py +92 -0
- agent/cli/eval_memory.py +253 -0
- agent/cli/eval_swe.py +172 -0
- agent/cli/lessons.py +97 -0
- agent/cli/main.py +67 -0
- agent/cli/modals.py +570 -0
- agent/cli/models.py +64 -0
- agent/cli/output.py +54 -0
- agent/cli/route.py +78 -0
- agent/cli/sessions.py +125 -0
- agent/cli/setup_screen.py +562 -0
- agent/cli/shell.py +548 -0
- agent/cli/tui.py +1807 -0
- agent/cli/ui.py +14 -0
- agent/cli/usage_panel.py +159 -0
- agent/config/README.md +7 -0
- agent/config/__init__.py +0 -0
- agent/config/envfile.py +76 -0
- agent/eval/README.md +76 -0
- agent/eval/__init__.py +0 -0
- agent/eval/claw_bench.py +1031 -0
- agent/eval/compaction_bench.py +229 -0
- agent/eval/data/README.md +10 -0
- agent/eval/data/claw/README.md +108 -0
- agent/eval/data/claw/llm_judge-gemini.patch +57 -0
- agent/eval/data/claw/otto.yaml +35 -0
- agent/eval/failures.py +276 -0
- agent/eval/golden/README.md +33 -0
- agent/eval/golden/code_01.json +6 -0
- agent/eval/golden/code_02.json +6 -0
- agent/eval/golden/code_03.json +6 -0
- agent/eval/golden/code_04.json +6 -0
- agent/eval/golden/code_05.json +6 -0
- agent/eval/golden/code_06.json +6 -0
- agent/eval/golden/math_01.json +6 -0
- agent/eval/golden/math_02.json +6 -0
- agent/eval/golden/math_03.json +6 -0
- agent/eval/golden/math_04.json +6 -0
- agent/eval/golden/math_05.json +6 -0
- agent/eval/golden/math_06.json +6 -0
- agent/eval/golden/nphard_gcp_01.json +6 -0
- agent/eval/golden/nphard_ksp_01.json +6 -0
- agent/eval/golden/nphard_math_binpacking_01.json +6 -0
- agent/eval/golden/nphard_math_clique_01.json +6 -0
- agent/eval/golden/nphard_math_setcover_01.json +6 -0
- agent/eval/golden/nphard_math_subsetsum_01.json +6 -0
- agent/eval/golden/nphard_tsp_01.json +6 -0
- agent/eval/golden/nphard_tsp_02.json +6 -0
- agent/eval/hle_bench.py +273 -0
- agent/eval/langfuse_sync.py +172 -0
- agent/eval/memory_bench.py +538 -0
- agent/eval/runner.py +174 -0
- agent/eval/single_agent.py +120 -0
- agent/eval/swe_bench.py +604 -0
- agent/eval/terminal_bench.py +345 -0
- agent/memory/README.md +102 -0
- agent/memory/__init__.py +42 -0
- agent/memory/embeddings.py +302 -0
- agent/memory/hashing.py +15 -0
- agent/memory/lessons.py +483 -0
- agent/memory/queue.py +531 -0
- agent/memory/retrieval.py +493 -0
- agent/memory/session.py +60 -0
- agent/memory/sessions.py +436 -0
- agent/memory/store.py +429 -0
- agent/memory/tokens.py +60 -0
- agent/memory/wiring.py +146 -0
- agent/pipeline/README.md +135 -0
- agent/pipeline/__init__.py +0 -0
- agent/pipeline/browsing.py +609 -0
- agent/pipeline/budget.py +403 -0
- agent/pipeline/codemap.py +254 -0
- agent/pipeline/evidence.py +325 -0
- agent/pipeline/execution.py +67 -0
- agent/pipeline/modes.py +137 -0
- agent/pipeline/native.py +1137 -0
- agent/pipeline/nodes.py +3644 -0
- agent/pipeline/pricing.py +209 -0
- agent/pipeline/progress.py +139 -0
- agent/pipeline/rag.py +139 -0
- agent/pipeline/research.py +1325 -0
- agent/pipeline/run.py +528 -0
- agent/pipeline/screen.py +77 -0
- agent/pipeline/state.py +220 -0
- agent/pipeline/toolkit.py +328 -0
- agent/pipeline/tools.py +1990 -0
- agent/pipeline/tracing.py +147 -0
- agent/pipeline/usage.py +251 -0
- agent/pipeline/vision.py +84 -0
- agent/pipeline/walkthrough.py +735 -0
- agent/pipeline/workspace.py +229 -0
- agent/router/README.md +60 -0
- agent/router/__init__.py +0 -0
- agent/router/automap.py +114 -0
- agent/router/health.py +229 -0
- agent/router/llm_provider/README.md +38 -0
- agent/router/llm_provider/__init__.py +202 -0
- agent/router/llm_provider/anthropic_provider.py +128 -0
- agent/router/llm_provider/base.py +507 -0
- agent/router/llm_provider/custom.py +152 -0
- agent/router/llm_provider/gemini_provider.py +122 -0
- agent/router/llm_provider/inception_provider.py +687 -0
- agent/router/llm_provider/openai_provider.py +151 -0
- agent/router/llm_provider/retired.py +145 -0
- agent/router/llm_provider/temperature.py +371 -0
- agent/router/mapping.py +579 -0
- agent/router/outcomes.py +363 -0
- agent/router/overrides.py +389 -0
- agent/router/reload.py +28 -0
- agent/router/router.py +413 -0
- agent/router/setup.py +123 -0
- otto_cli_agent-0.1.0.dist-info/METADATA +115 -0
- otto_cli_agent-0.1.0.dist-info/RECORD +129 -0
- otto_cli_agent-0.1.0.dist-info/WHEEL +4 -0
- otto_cli_agent-0.1.0.dist-info/entry_points.txt +2 -0
- 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]
|