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/shell.py
ADDED
|
@@ -0,0 +1,548 @@
|
|
|
1
|
+
"""The interactive shell: session state, slash commands, completion, and the
|
|
2
|
+
per-turn renderer.
|
|
3
|
+
|
|
4
|
+
Reconciled against the router/planner/solver/summarizer/finder/evaluator
|
|
5
|
+
graph (agent/pipeline/, replaced the orchestrator/worker/evaluate/
|
|
6
|
+
subtask_consensus/synthesize swarm pipeline on 2026-09-10, which had itself
|
|
7
|
+
replaced the Phase 11B code hive on 2026-09-09): the interactive default is
|
|
8
|
+
no longer a single resolved (RoutingDecision, chat model) pair, nor a
|
|
9
|
+
swarm of N workers -- it is run_pipeline_stream(), a sequence of
|
|
10
|
+
AgentState graph updates from a single router-dispatched specialist per
|
|
11
|
+
round. Session carries no `d`/`llm`/`config`/`agents` for that reason:
|
|
12
|
+
there is nothing left to size or fix, and `/task` from the original 9.5
|
|
13
|
+
spec is gone with it -- there is no longer a single task route for a turn
|
|
14
|
+
to switch, since each node already routes its own calls (router on
|
|
15
|
+
Task.CHAT_FAST, planner on Task.PLAN, solver on Task.REASON, ...)
|
|
16
|
+
independently of anything a REPL command could select.
|
|
17
|
+
|
|
18
|
+
Conversation memory (2026-09-10, same day, Phase 2 of docs/design/tiered-
|
|
19
|
+
memory-design.md): `Session` used to carry conversation history as a plain
|
|
20
|
+
`list[BaseMessage]`, growing every turn with nothing capping it -- that gap
|
|
21
|
+
was flagged, not fixed, when agent/pipeline/run.py's `history` parameter
|
|
22
|
+
was first added. It now keeps a `history_queue` (an `agent.memory.queue.
|
|
23
|
+
TieredQueue`, "history" kind) instead: `history_for_graph()` is what a
|
|
24
|
+
caller hands to `run_pipeline_stream()`/`run_pipeline()`, `record_turn()`
|
|
25
|
+
is what a caller writes a finished turn back with -- see agent/memory/
|
|
26
|
+
wiring.py for the actual reconstruction/formatting logic, kept out of this
|
|
27
|
+
file on purpose (this file's own job stays "session state, slash commands,
|
|
28
|
+
completion, the per-turn renderer", not memory-engine internals).
|
|
29
|
+
|
|
30
|
+
The workspace is session state too (2026-09-12, design call: "we need
|
|
31
|
+
filesystem management so we can use it to write code and work on already
|
|
32
|
+
implemented codebases"). It is one directory for the whole session rather than
|
|
33
|
+
a per-turn argument, because that is what it means to a person: you open otto
|
|
34
|
+
on a repository and every turn after that is about that repository. `/workspace`
|
|
35
|
+
below is how to look at it or point it somewhere else mid-session, and
|
|
36
|
+
`Session.reset()` deliberately KEEPS it -- a new session is a new conversation,
|
|
37
|
+
not a different project. See agent/pipeline/workspace.py for what binding one
|
|
38
|
+
actually grants and what it does not.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
from __future__ import annotations
|
|
42
|
+
|
|
43
|
+
import uuid
|
|
44
|
+
from collections import Counter
|
|
45
|
+
from dataclasses import dataclass, field
|
|
46
|
+
from pathlib import Path
|
|
47
|
+
from typing import Callable
|
|
48
|
+
|
|
49
|
+
from langchain_core.messages import BaseMessage, HumanMessage
|
|
50
|
+
from prompt_toolkit import PromptSession
|
|
51
|
+
from prompt_toolkit.completion import Completer, Completion
|
|
52
|
+
from prompt_toolkit.history import FileHistory
|
|
53
|
+
from rich.markdown import Markdown
|
|
54
|
+
from rich.panel import Panel
|
|
55
|
+
from rich.table import Table
|
|
56
|
+
|
|
57
|
+
from agent.cli.art import decorate_board_line
|
|
58
|
+
from agent.cli.context import AppContext
|
|
59
|
+
from agent.cli.doctor import health_table, router_view
|
|
60
|
+
from agent.cli.models import models_table
|
|
61
|
+
from agent.cli.route import _chain, _summary
|
|
62
|
+
from agent.cli.sessions import sessions_table
|
|
63
|
+
from agent.cli.ui import err, out
|
|
64
|
+
from agent.memory import sessions as session_index
|
|
65
|
+
from agent.memory import wiring as memory_wiring
|
|
66
|
+
from agent.memory.queue import TieredQueue
|
|
67
|
+
from agent.router.llm_provider import all_models
|
|
68
|
+
from agent.router.mapping import Task
|
|
69
|
+
from agent.router.router import NoViableRoute
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass
|
|
73
|
+
class Session:
|
|
74
|
+
ctx: AppContext
|
|
75
|
+
session_id: str = field(default_factory=lambda: uuid.uuid4().hex)
|
|
76
|
+
trace_id: str | None = None
|
|
77
|
+
#: The directory every file tool in this session may touch, or None for
|
|
78
|
+
#: no file access at all (module docstring; agent/pipeline/workspace.py
|
|
79
|
+
#: for the boundary). Handed to run_pipeline_stream() on every turn --
|
|
80
|
+
#: NOT bound here, because the contextvar has to be set on whichever
|
|
81
|
+
#: thread actually consumes the stream, which for the TUI is not this one.
|
|
82
|
+
workspace: Path | None = None
|
|
83
|
+
#: Counts turns that actually produced output, for output.py's filenames
|
|
84
|
+
#: -- not every dispatched line (slash commands don't count). Advanced by
|
|
85
|
+
#: `record_turn` (2026-09-13), which is also what writes it to the
|
|
86
|
+
#: session index, so the two cannot disagree about how far a session got.
|
|
87
|
+
turn: int = 0
|
|
88
|
+
#: What the session index calls this session (agent/memory/sessions.py):
|
|
89
|
+
#: the first message, cut short, until `/rename`. Empty until the first
|
|
90
|
+
#: turn is recorded -- an unnamed session is one nothing has happened in.
|
|
91
|
+
title: str = ""
|
|
92
|
+
#: This session's own bounded conversation memory (module docstring) --
|
|
93
|
+
#: `init=False`/set in __post_init__ rather than a `field(default_factory=...)`
|
|
94
|
+
#: because building one needs `session_id`, which isn't available yet
|
|
95
|
+
#: when dataclass field defaults are evaluated (session_id is itself
|
|
96
|
+
#: just becoming set at that point, by ITS OWN default_factory, with no
|
|
97
|
+
#: guaranteed ordering against a sibling field's factory).
|
|
98
|
+
history_queue: TieredQueue = field(init=False, repr=False)
|
|
99
|
+
|
|
100
|
+
def __post_init__(self) -> None:
|
|
101
|
+
self.history_queue = memory_wiring.new_history_queue(self.session_id)
|
|
102
|
+
|
|
103
|
+
def history_for_graph(self) -> tuple[list[BaseMessage], str]:
|
|
104
|
+
"""(bounded prior messages, compacted-memory context) for this
|
|
105
|
+
session's NEXT turn -- agent/pipeline/run.py's `history`/
|
|
106
|
+
`memory_context` parameters. See agent/memory/wiring.py's
|
|
107
|
+
history_for_graph() for what each half actually contains."""
|
|
108
|
+
return memory_wiring.history_for_graph(self.history_queue)
|
|
109
|
+
|
|
110
|
+
def workspace_arg(self) -> str | None:
|
|
111
|
+
"""This session's workspace as run.py wants it: a plain string, or
|
|
112
|
+
None. One accessor rather than `str(s.workspace) if s.workspace else
|
|
113
|
+
None` repeated at four call sites, each of which could get the
|
|
114
|
+
empty-vs-None distinction wrong on its own."""
|
|
115
|
+
return str(self.workspace) if self.workspace is not None else None
|
|
116
|
+
|
|
117
|
+
def record_turn(self, human: BaseMessage, ai: BaseMessage | None) -> None:
|
|
118
|
+
"""Write one finished turn into this session's memory -- `ai` is
|
|
119
|
+
None for a turn that produced no final output (nothing worth
|
|
120
|
+
remembering as "otto said"), same as chat.py/tui.py already treat
|
|
121
|
+
an empty `raw_output` as nothing to save to disk.
|
|
122
|
+
|
|
123
|
+
Also counts the turn (when it produced output) and registers the
|
|
124
|
+
session in the index (agent/memory/sessions.py) -- creating its row
|
|
125
|
+
on the first turn, titled from what the person said, and bumping
|
|
126
|
+
its activity after. Here rather than in the two front ends because
|
|
127
|
+
both must do exactly this and neither may forget: a session the
|
|
128
|
+
index does not know is one `/resume` cannot find.
|
|
129
|
+
"""
|
|
130
|
+
memory_wiring.record_turn(self.history_queue, human, ai)
|
|
131
|
+
if ai is not None:
|
|
132
|
+
self.turn += 1
|
|
133
|
+
info = session_index.touch(
|
|
134
|
+
self.session_id,
|
|
135
|
+
title=self.title or session_index.title_from(str(human.content)),
|
|
136
|
+
workspace=self.workspace, turns=self.turn,
|
|
137
|
+
)
|
|
138
|
+
self.title = info.title
|
|
139
|
+
|
|
140
|
+
def load(self, ref: str) -> session_index.SessionInfo:
|
|
141
|
+
"""Adopt a saved session in place -- the counterpart of `reset()`,
|
|
142
|
+
which mints a new one. `ref` is what agent/memory/sessions.py's
|
|
143
|
+
`resolve` accepts: "last", an id, or a unique prefix; LookupError
|
|
144
|
+
for anything else, with the reason in the message.
|
|
145
|
+
|
|
146
|
+
The saved workspace comes back with it when the directory still
|
|
147
|
+
exists: a session about a repository is still about that repository.
|
|
148
|
+
A caller with an explicit `--workspace` sets it after this returns.
|
|
149
|
+
Memory is the session's own file, read back rather than started
|
|
150
|
+
over (agent/memory/wiring.py's `restore=True`).
|
|
151
|
+
"""
|
|
152
|
+
info = session_index.resolve(ref)
|
|
153
|
+
self.close()
|
|
154
|
+
self.session_id = info.id
|
|
155
|
+
self.trace_id = None
|
|
156
|
+
self.turn = info.turns
|
|
157
|
+
self.title = info.title
|
|
158
|
+
self.history_queue = memory_wiring.new_history_queue(info.id, restore=True)
|
|
159
|
+
if info.workspace and Path(info.workspace).is_dir():
|
|
160
|
+
self.workspace = Path(info.workspace)
|
|
161
|
+
return info
|
|
162
|
+
|
|
163
|
+
def rename(self, title: str) -> None:
|
|
164
|
+
self.title = session_index.rename(self.session_id, title, workspace=self.workspace).title
|
|
165
|
+
|
|
166
|
+
def transcript(self) -> tuple[str, list[BaseMessage]]:
|
|
167
|
+
"""What a resumed session has to show for itself: everything older
|
|
168
|
+
than the recent turns as the compacted text the model will see, and
|
|
169
|
+
the recent turns as typed messages -- exactly what the next turn's
|
|
170
|
+
prompt is built from, so what is on screen is what otto knows."""
|
|
171
|
+
return self.history_queue.earlier_view(), memory_wiring.recent_messages(self.history_queue)
|
|
172
|
+
|
|
173
|
+
def close(self) -> None:
|
|
174
|
+
"""Release this session's memory file. `reset()`/`load()` call it on
|
|
175
|
+
the store they replace, and anything about to delete the session
|
|
176
|
+
must call it first: Windows will not delete a file a connection
|
|
177
|
+
still holds open (CI's windows-latest job found this), and POSIX
|
|
178
|
+
would -- silently, leaving the connection writing into an unlinked
|
|
179
|
+
inode. Safe to call twice."""
|
|
180
|
+
self.history_queue.store.close()
|
|
181
|
+
|
|
182
|
+
def reset(self) -> None:
|
|
183
|
+
"""New session, new memory -- `/new` (chat.py)/"new session"
|
|
184
|
+
(tui.py)'s existing "clear history, start fresh" contract, now
|
|
185
|
+
covering `history_queue` too (a fresh TieredQueue backed by a
|
|
186
|
+
fresh SQLite file under this new session_id, not the old queue
|
|
187
|
+
cleared in place -- the old session's compacted history stays on
|
|
188
|
+
disk, keyed by its own now-abandoned session_id, exactly as
|
|
189
|
+
harmless and exactly as inaccessible as it always was once a
|
|
190
|
+
session ended)."""
|
|
191
|
+
self.close()
|
|
192
|
+
self.session_id = uuid.uuid4().hex
|
|
193
|
+
self.trace_id = None
|
|
194
|
+
self.turn = 0
|
|
195
|
+
self.title = ""
|
|
196
|
+
self.history_queue = memory_wiring.new_history_queue(self.session_id)
|
|
197
|
+
# `workspace` is deliberately NOT reset: "clear history, start fresh"
|
|
198
|
+
# is about the conversation, and a person who opened otto on a repo and
|
|
199
|
+
# then cleared the chat is still working on that repo. Changing it is
|
|
200
|
+
# `/workspace <path>`, which is explicit.
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
# --------------------------------------------------------------------------
|
|
204
|
+
# Slash commands
|
|
205
|
+
# --------------------------------------------------------------------------
|
|
206
|
+
|
|
207
|
+
@dataclass(frozen=True)
|
|
208
|
+
class Slash:
|
|
209
|
+
name: str
|
|
210
|
+
help: str
|
|
211
|
+
run: Callable[[Session, str], None]
|
|
212
|
+
options: Callable[[], list[str]] = lambda: []
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _route_cmd(session: Session, arg: str) -> None:
|
|
216
|
+
if not arg:
|
|
217
|
+
err.print("[warn]usage: /route <task>[/]")
|
|
218
|
+
return
|
|
219
|
+
try:
|
|
220
|
+
task = Task(arg)
|
|
221
|
+
except ValueError:
|
|
222
|
+
err.print(f"[warn]unknown task {arg!r}; one of {[t.value for t in Task]}[/]")
|
|
223
|
+
return
|
|
224
|
+
try:
|
|
225
|
+
d = session.ctx.router.resolve(task)
|
|
226
|
+
except NoViableRoute as exc:
|
|
227
|
+
out.print(_chain(task, exc.skipped, None))
|
|
228
|
+
err.print("[bad]no viable model[/]")
|
|
229
|
+
return
|
|
230
|
+
out.print(_chain(task, d.skipped, d))
|
|
231
|
+
out.print(_summary(d))
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _models_cmd(session: Session, arg: str) -> None:
|
|
235
|
+
with err.status("fetching catalogues…"):
|
|
236
|
+
found = all_models(None)
|
|
237
|
+
found.sort(key=lambda m: (m.provider, m.id))
|
|
238
|
+
out.print(models_table(found) if found else "[muted]no models matched[/]")
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _doctor_cmd(session: Session, arg: str) -> None:
|
|
242
|
+
from agent.router.llm_provider import health_report
|
|
243
|
+
|
|
244
|
+
with err.status("contacting providers"):
|
|
245
|
+
reports = health_report()
|
|
246
|
+
out.print(health_table(reports))
|
|
247
|
+
out.print(router_view(session.ctx.router))
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _score_common(session: Session, value: float, comment: str | None) -> None:
|
|
251
|
+
if session.trace_id is None:
|
|
252
|
+
err.print("[warn]nothing to rate yet[/]")
|
|
253
|
+
return
|
|
254
|
+
session.ctx.client.create_score(
|
|
255
|
+
name="user_feedback",
|
|
256
|
+
value=value,
|
|
257
|
+
data_type="NUMERIC",
|
|
258
|
+
trace_id=session.trace_id,
|
|
259
|
+
comment=comment,
|
|
260
|
+
)
|
|
261
|
+
session.ctx.client.flush()
|
|
262
|
+
err.print(f"[muted]scored {value:g}[/]")
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _good_cmd(session: Session, arg: str) -> None:
|
|
266
|
+
_score_common(session, 1.0, arg.strip() or None)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _bad_cmd(session: Session, arg: str) -> None:
|
|
270
|
+
_score_common(session, 0.0, arg.strip() or None)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _score_cmd(session: Session, arg: str) -> None:
|
|
274
|
+
value_text, _, comment_text = arg.partition(" ")
|
|
275
|
+
try:
|
|
276
|
+
value = float(value_text)
|
|
277
|
+
except ValueError:
|
|
278
|
+
err.print(r"[warn]usage: /score <0-1> \[comment][/]")
|
|
279
|
+
return
|
|
280
|
+
_score_common(session, value, comment_text.strip() or None)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _new_cmd(session: Session, arg: str) -> None:
|
|
284
|
+
session.reset()
|
|
285
|
+
err.print("[muted]new session[/]")
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def render_transcript(session: Session, sink: Callable[[object], None] | None = None) -> None:
|
|
289
|
+
"""Print a resumed session's history the way it was first shown: the
|
|
290
|
+
compacted part as one dim panel, then each recent turn as the same
|
|
291
|
+
"you" line and final panel chat.py posts live. Shared shape with the
|
|
292
|
+
TUI's own replay, which posts the same pieces into its transcript.
|
|
293
|
+
`sink` defaults to the REPL console at call time, not definition time,
|
|
294
|
+
so a test that swaps the console sees what this prints."""
|
|
295
|
+
sink = sink or out.print
|
|
296
|
+
earlier, messages = session.transcript()
|
|
297
|
+
if earlier:
|
|
298
|
+
sink(Panel(earlier, title="[muted]earlier, compacted[/]", border_style="muted"))
|
|
299
|
+
for message in messages:
|
|
300
|
+
text = str(message.content)
|
|
301
|
+
if isinstance(message, HumanMessage):
|
|
302
|
+
sink(f"[bold]you[/] {text}")
|
|
303
|
+
else:
|
|
304
|
+
sink(Panel(Markdown(text), title="[spec]final[/]", border_style="ok"))
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _sessions_cmd(session: Session, arg: str) -> None:
|
|
308
|
+
rows = session_index.list_sessions(limit=20)
|
|
309
|
+
if not rows:
|
|
310
|
+
out.print("[muted]no saved sessions yet -- a session is saved once a turn finishes[/]")
|
|
311
|
+
return
|
|
312
|
+
out.print(sessions_table(rows, current=session.session_id))
|
|
313
|
+
out.print("[muted]/resume <id> picks one up; /resume last is the newest[/]")
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _resume_cmd(session: Session, arg: str) -> None:
|
|
317
|
+
if not arg:
|
|
318
|
+
err.print("[warn]usage: /resume <id or prefix | last>[/]")
|
|
319
|
+
return
|
|
320
|
+
try:
|
|
321
|
+
info = session.load(arg)
|
|
322
|
+
except LookupError as exc:
|
|
323
|
+
err.print(f"[warn]{exc}[/]")
|
|
324
|
+
return
|
|
325
|
+
err.print(f"[muted]resumed {info.short_id} · {info.label} · {info.turns} turn(s)[/]")
|
|
326
|
+
err.print(f"[muted]{describe_workspace(session.workspace)}[/]")
|
|
327
|
+
render_transcript(session)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _rename_cmd(session: Session, arg: str) -> None:
|
|
331
|
+
if not arg:
|
|
332
|
+
err.print("[warn]usage: /rename <title>[/]")
|
|
333
|
+
return
|
|
334
|
+
session.rename(arg)
|
|
335
|
+
err.print(f"[muted]session is now called {session.title!r}[/]")
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def resolve_workspace(chosen: Path | None, disabled: bool) -> Path | None:
|
|
339
|
+
"""What `--workspace PATH` / `--no-workspace` / neither actually means.
|
|
340
|
+
|
|
341
|
+
One resolver shared by `otto chat` and `otto tui` so the two cannot drift,
|
|
342
|
+
and the single place the "current directory by default" policy is written
|
|
343
|
+
down (2026-09-12 design call, asked explicitly: a session should work on
|
|
344
|
+
the repo you launched it in, the way every other developer tool does).
|
|
345
|
+
|
|
346
|
+
`--no-workspace` wins over `--workspace`, deliberately: the two together
|
|
347
|
+
are a contradiction, and resolving it towards LESS access is the only
|
|
348
|
+
direction that cannot surprise someone.
|
|
349
|
+
"""
|
|
350
|
+
if disabled:
|
|
351
|
+
return None
|
|
352
|
+
if chosen is not None:
|
|
353
|
+
return chosen.expanduser().resolve()
|
|
354
|
+
return Path.cwd()
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def describe_workspace(workspace: Path | None) -> str:
|
|
358
|
+
"""One line saying what file access this session has. Shared with the TUI
|
|
359
|
+
(agent/cli/tui.py) so both front ends say the same thing about the same
|
|
360
|
+
state -- the whole point of Session living in this module."""
|
|
361
|
+
if workspace is None:
|
|
362
|
+
return "no workspace: file tools are off (open one with --workspace, or /workspace <path>)"
|
|
363
|
+
return f"workspace: {workspace}"
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def set_workspace(session: Session, path: str) -> str | None:
|
|
367
|
+
"""Point `session` at `path`, or return why it cannot be. Returns None on
|
|
368
|
+
success so a caller can treat a string as "show this and stop".
|
|
369
|
+
|
|
370
|
+
Rejects a path that is not an existing directory rather than creating it,
|
|
371
|
+
unlike `bind_workspace`, which creates because a harness genuinely wants a
|
|
372
|
+
fresh scratch dir. A person typing a path at a prompt has almost certainly
|
|
373
|
+
typo'd it, and silently creating `~/projcts` is worse than saying so.
|
|
374
|
+
"""
|
|
375
|
+
candidate = Path(path).expanduser()
|
|
376
|
+
try:
|
|
377
|
+
resolved = candidate.resolve()
|
|
378
|
+
except OSError as exc:
|
|
379
|
+
return f"{path!r} is not a usable path: {exc}"
|
|
380
|
+
if not resolved.exists():
|
|
381
|
+
return f"{resolved} does not exist"
|
|
382
|
+
if not resolved.is_dir():
|
|
383
|
+
return f"{resolved} is not a directory"
|
|
384
|
+
session.workspace = resolved
|
|
385
|
+
return None
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def _workspace_cmd(session: Session, arg: str) -> None:
|
|
389
|
+
target = arg.strip()
|
|
390
|
+
if not target:
|
|
391
|
+
out.print(f"[muted]{describe_workspace(session.workspace)}[/]")
|
|
392
|
+
return
|
|
393
|
+
if target in {"off", "none"}:
|
|
394
|
+
session.workspace = None
|
|
395
|
+
err.print("[muted]workspace closed: file tools are off[/]")
|
|
396
|
+
return
|
|
397
|
+
problem = set_workspace(session, target)
|
|
398
|
+
if problem:
|
|
399
|
+
err.print(f"[warn]{problem}[/]")
|
|
400
|
+
return
|
|
401
|
+
err.print(f"[muted]workspace: {session.workspace}[/]")
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def _help_cmd(session: Session, arg: str) -> None:
|
|
405
|
+
t = Table(box=None, show_header=False, pad_edge=False)
|
|
406
|
+
t.add_column(style="chosen")
|
|
407
|
+
t.add_column(style="muted")
|
|
408
|
+
for name, slash in sorted(COMMANDS.items()):
|
|
409
|
+
t.add_row(name, slash.help)
|
|
410
|
+
out.print(t)
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
COMMANDS: dict[str, Slash] = {
|
|
414
|
+
"/route": Slash("/route", "show how a task resolves", _route_cmd, lambda: [t.value for t in Task]),
|
|
415
|
+
"/models": Slash("/models", "list every configured model", _models_cmd),
|
|
416
|
+
"/doctor": Slash("/doctor", "check every provider", _doctor_cmd),
|
|
417
|
+
"/good": Slash("/good", "score the last answer 1.0", _good_cmd),
|
|
418
|
+
"/bad": Slash("/bad", "score the last answer 0.0", _bad_cmd),
|
|
419
|
+
"/score": Slash("/score", r"score the last answer <0-1> \[comment]", _score_cmd),
|
|
420
|
+
"/new": Slash("/new", "clear history, start a fresh session", _new_cmd),
|
|
421
|
+
"/sessions": Slash("/sessions", "list saved sessions, newest first", _sessions_cmd),
|
|
422
|
+
"/resume": Slash(
|
|
423
|
+
"/resume", "pick a saved session back up: <id or prefix>, or last", _resume_cmd,
|
|
424
|
+
lambda: ["last", *(r.short_id for r in session_index.list_sessions(limit=10))],
|
|
425
|
+
),
|
|
426
|
+
"/rename": Slash("/rename", "give this session a title", _rename_cmd),
|
|
427
|
+
"/workspace": Slash(
|
|
428
|
+
"/workspace", "show the working directory, or <path> to change it (off to close)",
|
|
429
|
+
_workspace_cmd, lambda: ["off"],
|
|
430
|
+
),
|
|
431
|
+
"/help": Slash("/help", "list these commands", _help_cmd),
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def dispatch(session: Session, text: str) -> bool:
|
|
436
|
+
"""Handle a slash command. Returns True if `text` was one (handled or
|
|
437
|
+
not -- an unknown slash still returns True so it never reaches the
|
|
438
|
+
model)."""
|
|
439
|
+
if not text.startswith("/"):
|
|
440
|
+
return False
|
|
441
|
+
head, _, arg = text.partition(" ")
|
|
442
|
+
slash = COMMANDS.get(head)
|
|
443
|
+
if slash is None:
|
|
444
|
+
err.print(f"[warn]unknown command {head!r}[/]")
|
|
445
|
+
_help_cmd(session, "")
|
|
446
|
+
return True
|
|
447
|
+
slash.run(session, arg.strip())
|
|
448
|
+
return True
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
# --------------------------------------------------------------------------
|
|
452
|
+
# Completion
|
|
453
|
+
# --------------------------------------------------------------------------
|
|
454
|
+
|
|
455
|
+
class SlashCompleter(Completer):
|
|
456
|
+
def __init__(self, commands: dict[str, Slash]) -> None:
|
|
457
|
+
self._commands = commands
|
|
458
|
+
|
|
459
|
+
def get_completions(self, document, complete_event):
|
|
460
|
+
text = document.text_before_cursor
|
|
461
|
+
if not text.startswith("/"):
|
|
462
|
+
return
|
|
463
|
+
head, sep, arg = text.partition(" ")
|
|
464
|
+
if not sep:
|
|
465
|
+
for name in self._commands:
|
|
466
|
+
if name.startswith(head):
|
|
467
|
+
yield Completion(name, start_position=-len(head))
|
|
468
|
+
return
|
|
469
|
+
slash = self._commands.get(head)
|
|
470
|
+
if slash is None:
|
|
471
|
+
return
|
|
472
|
+
for option in slash.options():
|
|
473
|
+
if option.startswith(arg):
|
|
474
|
+
yield Completion(option, start_position=-len(arg))
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def build_prompt_session() -> PromptSession:
|
|
478
|
+
return PromptSession(
|
|
479
|
+
completer=SlashCompleter(COMMANDS),
|
|
480
|
+
history=FileHistory(str(Path.home() / ".otto_history")),
|
|
481
|
+
)
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
# --------------------------------------------------------------------------
|
|
485
|
+
# Rendering a turn -- plain lines/panels, no animation (there is no fan-out
|
|
486
|
+
# left to animate: exactly one specialist runs per round).
|
|
487
|
+
# --------------------------------------------------------------------------
|
|
488
|
+
|
|
489
|
+
#: The one working node. It was four -- planner, solver, summarizer, finder --
|
|
490
|
+
#: until they collapsed into a single agent loop that switches mode instead of
|
|
491
|
+
#: switching node (agent/pipeline/nodes.py). Still a literal rather than an
|
|
492
|
+
#: import, so this display module does not pull in the whole pipeline and its
|
|
493
|
+
#: module-level Router() just to know one string.
|
|
494
|
+
#:
|
|
495
|
+
#: Where its lines come from changed too, and that matters more than the name.
|
|
496
|
+
#: LangGraph's "updates" stream emits once per node RETURN, so with one
|
|
497
|
+
#: long-running loop this branch used to fire once, at the very end, after
|
|
498
|
+
#: minutes of silence. The loop now writes each tool call and mode swap to the
|
|
499
|
+
#: "custom" stream as it happens (nodes.py's `_emit`), in this same node-shaped
|
|
500
|
+
#: form -- so these lines arrive live and the branch below needs no changes.
|
|
501
|
+
_ROLE_NODES = ("agent", "research")
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def render_update(node: str, delta: dict, tally: Counter, sink: Callable[[object], None] = out.print) -> None:
|
|
505
|
+
"""Render one graph update by calling `sink(renderable_or_string)` once
|
|
506
|
+
(zero times for a node with nothing to show). Handles both shapes that
|
|
507
|
+
can appear on a stream: Phase 8's Hive (spawn/clone/consensus, dead code
|
|
508
|
+
today -- nothing calls agent/graph/run.py's run() from either front end
|
|
509
|
+
-- kept here only because a future caller wiring it back up would want
|
|
510
|
+
the same renderer) and the pipeline's AgentState (router/planner/
|
|
511
|
+
solver/summarizer/finder/evaluator) -- the REPL only ever drives the
|
|
512
|
+
latter.
|
|
513
|
+
|
|
514
|
+
`sink` defaults to the REPL's `out.print`; the TUI (tui.py) passes its
|
|
515
|
+
transcript widget's `.write` instead, so the same branch logic drives two
|
|
516
|
+
different front ends over one Rich renderable per call."""
|
|
517
|
+
|
|
518
|
+
if node == "spawn":
|
|
519
|
+
for line in delta.get("board", []):
|
|
520
|
+
sink(f"[muted]{line}[/]")
|
|
521
|
+
return
|
|
522
|
+
if node == "clone":
|
|
523
|
+
vote = delta["votes"][0]
|
|
524
|
+
tally[vote["answer"]] += 1
|
|
525
|
+
sink(Panel(
|
|
526
|
+
Markdown(f"**{vote['answer']}**\n\n{vote['rationale']}"),
|
|
527
|
+
title=f"[spec]clone · seed {vote['seed']}[/]", border_style="spec",
|
|
528
|
+
))
|
|
529
|
+
return
|
|
530
|
+
if node == "consensus":
|
|
531
|
+
for line in delta.get("board", []):
|
|
532
|
+
sink(f"[muted]{line}[/]")
|
|
533
|
+
tally.clear()
|
|
534
|
+
return
|
|
535
|
+
|
|
536
|
+
if node in _ROLE_NODES:
|
|
537
|
+
for line in delta.get("board", []):
|
|
538
|
+
sink(f"[muted]{decorate_board_line(line)}[/]")
|
|
539
|
+
text = (delta.get("output") or "").strip()
|
|
540
|
+
if text:
|
|
541
|
+
if len(text) > 400:
|
|
542
|
+
text = text[:400] + "\n…"
|
|
543
|
+
sink(Panel(Markdown(text), title=f"[spec]{node}[/]", border_style="spec"))
|
|
544
|
+
return
|
|
545
|
+
if node == "evaluator":
|
|
546
|
+
for line in delta.get("board", []):
|
|
547
|
+
sink(f"[muted]{decorate_board_line(line)}[/]")
|
|
548
|
+
return
|