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/pipeline/run.py ADDED
@@ -0,0 +1,528 @@
1
+ """Prewarm, invoke/stream, score, return -- same shape as the retired
2
+ agent/graph/code_run.py and the swarm pipeline's own run.py it replaces,
3
+ retargeted at the router/planner/solver/summarizer/finder/evaluator graph
4
+ in agent.pipeline.nodes.
5
+
6
+ No `agents` parameter anywhere in this file (2026-09-10): the new graph has
7
+ nothing to size -- one router dispatch, one specialist, one evaluator, per
8
+ round -- so there is no swarm-width knob left to validate, thread through,
9
+ or score. Callers that used to pass `agents=` (agent/eval/, agent/cli/chat.py,
10
+ agent/cli/tui.py) all lose that parameter in the same change.
11
+
12
+ Conversation memory (2026-09-10, same day, design call: live-tested with
13
+ "Hi" / "Solve N Queens with brute force" / "improve above solution" -- the
14
+ third turn had no idea what "above solution" meant and looped). Before
15
+ this, `_initial()` only ever seeded `messages` with the CURRENT turn's
16
+ text -- every turn started the graph from a blank slate, even though
17
+ chat.py's/tui.py's own `Session.history` was already tracking the whole
18
+ conversation client-side (used for nothing but /new resets and the visual
19
+ transcript). `history` (new, optional, keyword-only on `_initial()`,
20
+ `run_pipeline()` and `run_pipeline_stream()`) is that same list, handed
21
+ straight to the graph: `messages` becomes `[*history, HumanMessage(text)]`
22
+ instead of just `[HumanMessage(text)]`. `text` stays the required
23
+ positional argument and `history` defaults to `()` so every existing
24
+ caller that only ever runs one turn per session (agent/eval/'s golden
25
+ runner, debug_pipeline.py) needs zero changes and sees zero behavior
26
+ difference. agent/pipeline/nodes.py's prompt-builders read the rest of
27
+ `state["messages"]` (everything before the last one) as prior
28
+ conversation -- see its module docstring for that half.
29
+
30
+ Deliberately NOT built here: any kind of summarization, truncation, or
31
+ cross-SESSION persistence. `history` is exactly the in-memory list the
32
+ REPL/TUI already had; a long-running chat's prompt grows every turn with
33
+ nothing capping it. That's the same "project profile"/"personal lessons"
34
+ territory an earlier long-term-memory design specced out for a *different*
35
+ purpose (learning across runs, not remembering within one) against the
36
+ now-replaced code hive -- worth revisiting there, not smuggled into this
37
+ fix, which only closes the "does the graph even see what I said two turns
38
+ ago" gap.
39
+
40
+ Bounding that conversation memory (2026-09-10, same day, Phase 2 of
41
+ docs/design/tiered-memory.md -- the paragraph above flagged
42
+ "deliberately NOT built here" as its own explicit follow-up): `history` no
43
+ longer needs to be the whole raw, unbounded list. `_initial()` gained a
44
+ `memory_context` parameter, seeded from whatever `agent/memory/wiring.py`'s
45
+ `history_for_graph()` produces out of a session's TieredQueue -- the
46
+ recent, still-verbatim tail becomes `history` exactly as before (so
47
+ `messages`/`_conversation_so_far()` need no changes at all), and anything
48
+ older that's been compacted away becomes `memory_context`, landing in
49
+ `state["context"]`'s existing "CONTEXT GATHERED SO FAR:" display instead.
50
+ Both `run_pipeline()` and `run_pipeline_stream()` also now open this
51
+ session's MemoryStore (agent/memory/store.py's `~/.otto/memory/
52
+ <session_id>.db`) and bind it for the duration of the graph call
53
+ (agent/memory/session.py's `bind_store()`) -- how agent/pipeline/tools.py's
54
+ new `recall_memory` tool finds out which session's history to search,
55
+ since a plain TOOL_DISPATCH function has no other way to see `session_id`.
56
+
57
+ Pausing for a person mid-run (2026-09-10, same day, agent/pipeline/
58
+ nodes.py's seventh refinement -- has its own module docstring section
59
+ with the full design discussion): app.compile() already carries a
60
+ checkpointer (InMemorySaver, nodes.py) from before this, for an unrelated
61
+ reason (giving every turn its own disposable thread id -- _graph_thread_id
62
+ below). That's also exactly what LangGraph's own interrupt()/
63
+ Command(resume=...) mechanism needs, so nodes.py's new `ask_user` node
64
+ reuses it rather than inventing a second one. When a run hits that node,
65
+ `app.stream()` yields LangGraph's own `{"__interrupt__": (Interrupt(...),)
66
+ }` update instead of continuing -- run_pipeline_stream() (below) turns
67
+ that into a `{"__ask__": {"question", "choices", "thread_id"}}` event for
68
+ callers and then RETURNS (the generator just ends there, mid-turn; there
69
+ is no `__final__` yet). `resume_pipeline_stream()` (below, new) is how a
70
+ caller with an answer continues that SAME checkpoint thread -- not a new
71
+ turn, `Command(resume=answer)` picks the graph up exactly where ask_user()
72
+ paused it, plan/context/active_step/everything intact. It can itself hit
73
+ another `__ask__` (a chained question) or finish with `__final__`, same as
74
+ run_pipeline_stream() -- agent/cli/chat.py's and agent/cli/tui.py's own
75
+ run-turn loops handle both the same way, looping until `__final__` shows
76
+ up. Each half gets its own Langfuse observation rather than one span held
77
+ open across however long a person takes to answer -- tagged the same way
78
+ (session_id, "pipeline" in the tags) so both halves of one externally
79
+ visible turn are still easy to find together.
80
+
81
+ A `workspace` for every entry point (2026-09-12, design call: "we need
82
+ filesystem management so we can use it to write code and work on already
83
+ implemented codebases"). Binding a workspace was already possible and only
84
+ agent/eval/'s harnesses did it, each wrapping its OWN `with
85
+ bind_workspace(...)` around the call below. That worked for them and left
86
+ `otto chat`/`otto tui` with no way to reach it, because a contextvar set on
87
+ the CLI's thread is not visible inside a generator consumed on a Textual
88
+ worker thread. So the binding moves in here, next to `bind_budget` and
89
+ `bind_store`, which exist for exactly the same reason: a run-scoped fact the
90
+ graph cannot be handed as an argument has to be bound where the graph actually
91
+ runs. `workspace=None` is the old behaviour and stays the default, so the
92
+ harnesses' own outer `bind_workspace` still wins for their calls (nesting
93
+ unwinds correctly -- workspace.py's contract) and no single-turn caller
94
+ changes.
95
+ """
96
+ import logging
97
+ import uuid
98
+ from collections.abc import Sequence
99
+ from contextlib import nullcontext
100
+
101
+ from langchain_core.messages import BaseMessage, HumanMessage
102
+
103
+ from langgraph.types import Command
104
+
105
+ from agent.memory.session import bind_store
106
+ from agent.pipeline.budget import bind_budget, current_budget, default_budget
107
+ from agent.memory.store import MemoryStore
108
+ from agent.pipeline.nodes import _RECURSION_SAFETY_NET, ROUTER, app
109
+ from agent.pipeline.state import AgentState
110
+ from agent.pipeline.tracing import callback_handler, closing_in_foreign_context, observe_run
111
+ from agent.pipeline.usage import UsageLedger, bind_usage, current_usage
112
+ from agent.pipeline.workspace import bind_workspace
113
+
114
+ logger = logging.getLogger(__name__)
115
+
116
+
117
+ def _initial(text: str, *, history: Sequence[BaseMessage] = (), memory_context: str = "") -> dict:
118
+ return {
119
+ "messages": [*history, HumanMessage(text)],
120
+ "board": [],
121
+ "actions": [],
122
+ "node": None,
123
+ "feedback": "",
124
+ "output": None,
125
+ "context": memory_context,
126
+ "node_error": None,
127
+ "pending_question": None,
128
+ "pending_choices": None,
129
+ "asking_role": None,
130
+ "user_answer": None,
131
+ "asked_qa": [],
132
+ # Per TURN, not per session: a new request gets its own budget of
133
+ # questions (agent/pipeline/state.py's `asks`).
134
+ "asks": 0,
135
+ # The agent loop's own conversation, carried across node returns
136
+ # (agent/pipeline/state.py). None means "not started" -- the loop seeds
137
+ # it on its first entry and hands back the version it finished with.
138
+ "transcript": None,
139
+ "mode": None,
140
+ "mode_log": [],
141
+ "model_calls": 0,
142
+ "rejections": 0,
143
+ "checklist": None,
144
+ "final_output": None,
145
+ "route": None,
146
+ "document_path": None,
147
+ }
148
+
149
+
150
+ def _config(graph_thread_id: str, handler) -> dict:
151
+ # The overseer (router) is re-invoked after every node with no cap on
152
+ # how many times it may retry (nodes.py, 2026-09-10 design call) -- the
153
+ # only backstop left is LangGraph's own recursion_limit, sized by
154
+ # nodes.py's _RECURSION_SAFETY_NET as pure infra insurance against a
155
+ # genuinely runaway loop, not a business rule a real request should hit.
156
+ return {
157
+ "recursion_limit": _RECURSION_SAFETY_NET,
158
+ "configurable": {"thread_id": graph_thread_id},
159
+ "callbacks": [handler] if handler is not None else [],
160
+ }
161
+
162
+
163
+ def _salvage(final: dict | None, exc: Exception | None = None) -> dict:
164
+ """Make sure a run hands back the best answer it actually reached.
165
+
166
+ Three ways a run used to return nothing despite having done the work:
167
+
168
+ * an unhandled exception anywhere unwound the whole graph. Seen live on
169
+ Claw-Eval task C01 -- the agent had computed and verified a mortgage
170
+ comparison, then put prose where a file path goes, `Path.exists()` raised
171
+ OSError(ENAMETOOLONG), and 1096 seconds of correct work was thrown away.
172
+ The grader saw a conversation with no assistant messages at all.
173
+ * `app.invoke` returns NORMALLY on an interrupt, with `__interrupt__`
174
+ spliced into the state and `final_output` still None. Every caller reads
175
+ `final_output` and records an empty answer, and none of them checks.
176
+ * the evaluator never approved, so `output` held a real candidate that
177
+ `final_output` never received.
178
+
179
+ All three have the same fix and it belongs in one place: `output` is the
180
+ agent's own best attempt, and reporting it -- labelled as unverified -- beats
181
+ reporting nothing.
182
+ """
183
+ state = dict(final or {})
184
+ if exc is not None:
185
+ state.setdefault("board", [])
186
+ state["board"] = [*state.get("board", []), f"the run failed: {type(exc).__name__}: {exc}"]
187
+ if not (state.get("final_output") or "").strip():
188
+ candidate = (state.get("output") or "").strip()
189
+ if not candidate and state.get("pending_question"):
190
+ # The run stopped to ask something and nothing here can answer --
191
+ # `run_pipeline` has no resume path, that is the streaming API.
192
+ # The question IS the answer in that case, and saying it is both
193
+ # honest and useful: a caller that wanted a decision learns which
194
+ # decision is missing, instead of receiving nothing.
195
+ #
196
+ # Seen live on Claw-Eval T026, where the mutation gate correctly
197
+ # stopped the agent guessing between three contacts named Zhang.
198
+ # It asked, exactly as the grader requires, and the task recorded
199
+ # no assistant output whatsoever.
200
+ question = state["pending_question"]
201
+ choices = state.get("pending_choices") or []
202
+ candidate = question if not choices else (
203
+ f"{question}\n\n" + "\n".join(f"- {c}" for c in choices)
204
+ )
205
+ if candidate:
206
+ state["final_output"] = candidate
207
+ return state
208
+
209
+
210
+ def _paused(final: dict | None) -> bool:
211
+ """Whether `app.invoke` came back on an interrupt rather than a finish.
212
+
213
+ It does not raise and it does not say so anywhere a caller looks -- the
214
+ only signals are the `__interrupt__` key it splices in and the
215
+ `pending_question` the paused node never got to clear.
216
+ """
217
+ return bool(final) and ("__interrupt__" in final or final.get("pending_question"))
218
+
219
+
220
+ def _score(run_span, final: AgentState) -> None:
221
+ """The facts worth filtering a Langfuse trace by, scored on it.
222
+
223
+ `model_calls` replaces the old `dispatch_rounds`: with one agent loop there
224
+ are no overseer rounds left to count, and model requests are the number that
225
+ maps to both latency and spend -- measured on Claw-Eval, tool execution was
226
+ 0.1 to 0.4 seconds of runs lasting 119 to 946, so everything else was this.
227
+
228
+ `mode_swaps` is here so that "did the model park on one model, or thrash
229
+ between them?" is a query across a batch rather than an opinion. Nothing in
230
+ the loop can settle that with a rule; only the numbers can.
231
+ """
232
+ run_span.update(output=final.get("final_output"))
233
+ run_span.score_trace(
234
+ name="model_calls", value=final.get("model_calls") or 0, data_type="NUMERIC",
235
+ )
236
+ swaps = [line for line in (final.get("mode_log") or []) if "->" in line]
237
+ run_span.score_trace(name="mode_swaps", value=len(swaps), data_type="NUMERIC")
238
+ if final.get("mode"):
239
+ run_span.score_trace(name="final_mode", value=final["mode"], data_type="CATEGORICAL")
240
+ if final.get("node"):
241
+ run_span.score_trace(name="last_node", value=final["node"], data_type="CATEGORICAL")
242
+ if final.get("route"):
243
+ run_span.score_trace(name="route", value=final["route"], data_type="CATEGORICAL")
244
+
245
+
246
+ def _as_ask_event(update: dict, graph_thread_id: str) -> dict | None:
247
+ """If `update` is LangGraph's own interrupt shape (`{"__interrupt__":
248
+ (Interrupt(value=..., id=...),)}`, yielded by app.stream() the moment
249
+ a node calls interrupt() -- nodes.py's ask_user, seventh refinement),
250
+ return the `{"__ask__": ...}` event run_pipeline_stream()/
251
+ resume_pipeline_stream() actually yield to callers. None otherwise --
252
+ every other update passes through unchanged.
253
+ """
254
+ payload = update.get("__interrupt__")
255
+ if not payload:
256
+ return None
257
+ value = payload[0].value or {}
258
+ return {
259
+ "__ask__": {
260
+ "question": value.get("question", ""),
261
+ "choices": value.get("choices") or [],
262
+ "thread_id": graph_thread_id,
263
+ }
264
+ }
265
+
266
+
267
+ #: What app.stream() is asked for. "updates" is what it always yielded -- one
268
+ #: dict per node RETURN. That was enough when a node returned every few seconds;
269
+ #: with one long-running agent loop it means nothing reaches the screen until
270
+ #: the whole loop finishes, so `otto chat` would sit silent for minutes and then
271
+ #: print one panel. "custom" is the loop's own per-iteration events
272
+ #: (langgraph.config.get_stream_writer), emitted in the same node-shaped form so
273
+ #: agent/cli/chat.py and agent/cli/tui.py need no changes at all.
274
+ _STREAM_MODES = ["updates", "custom"]
275
+
276
+
277
+ def _stream_events(app_stream, graph_thread_id: str):
278
+ """Unwrap app.stream()'s `(mode, payload)` tuples into the flat updates
279
+ callers have always seen, turning an interrupt into an `__ask__` event.
280
+
281
+ Asking for more than one stream mode changes the yield shape from a bare
282
+ payload to a tuple, so this is the one place that knows about it. Custom
283
+ payloads pass straight through: the loop already emits them node-shaped.
284
+ Yields `(event, is_ask)`.
285
+ """
286
+ for mode, payload in app_stream:
287
+ if mode == "custom":
288
+ yield payload, False
289
+ continue
290
+ ask = _as_ask_event(payload, graph_thread_id)
291
+ yield (ask, True) if ask is not None else (payload, False)
292
+
293
+
294
+ def _workspace_binding(workspace: str | None):
295
+ """`bind_workspace(workspace)`, or nothing at all when the caller named none.
296
+
297
+ Deliberately NOT `bind_workspace(None)`. That is an explicit "this run has
298
+ no workspace", and it sets the contextvar, which CLOBBERS an outer binding
299
+ -- exactly what agent/eval/'s harnesses depend on, each wrapping its own
300
+ `with bind_workspace(scratch)` around the call below and passing no
301
+ `workspace=` argument at all. Adding the parameter without this would have
302
+ silently taken file access away from both benchmark harnesses while every
303
+ test they have kept passing, because none of them asserts on the binding.
304
+ A test does now (tests/test_workspace_session.py), and it caught this.
305
+ """
306
+ return bind_workspace(workspace) if workspace is not None else nullcontext()
307
+
308
+
309
+ def _graph_thread_id(session_id: str) -> str:
310
+ """A fresh LangGraph checkpoint thread for THIS turn only.
311
+
312
+ Same reasoning as the retired code_run.py's/swarm pipeline's identical
313
+ helper: every reducer-annotated list in AgentState (board) accumulates
314
+ on every write, including the first one -- reusing one checkpoint
315
+ thread across turns would let turn 1's leftovers bleed into turn 2's
316
+ state. A disposable thread id per turn makes _initial()'s empty lists
317
+ actually the starting state, every time.
318
+ """
319
+ return f"{session_id}:{uuid.uuid4().hex[:8]}"
320
+
321
+
322
+ def run_pipeline(
323
+ text: str, *, session_id: str, history: Sequence[BaseMessage] = (),
324
+ memory_context: str = "", workspace: str | None = None,
325
+ usage: UsageLedger | None = None,
326
+ ) -> AgentState:
327
+ """Prewarm, invoke, score, return the finished AgentState.
328
+
329
+ One call in, one finished result out -- for scripted/benchmark callers
330
+ (agent/eval/'s golden-dataset runner). The interactive shell wants to
331
+ watch it happen instead -- that's run_pipeline_stream(), below, not a
332
+ different mode of this function.
333
+
334
+ `history`/`memory_context` are the conversation BEFORE `text` -- module
335
+ docstring's "Bounding that conversation memory". Both empty by default,
336
+ so every existing single-turn caller is unaffected.
337
+ """
338
+ # Fails here, at the entry point, rather than inside a graph node: a
339
+ # Router can now be constructed without Inception (so the TUI can open
340
+ # on a keyless machine and offer setup), but nothing may RUN without it.
341
+ ROUTER.require_ready()
342
+ failures = ROUTER.prewarm()
343
+ if failures:
344
+ logger.warning("prewarm: %s", failures)
345
+
346
+ initial = _initial(text, history=history, memory_context=memory_context)
347
+ handler = callback_handler()
348
+ config = _config(_graph_thread_id(session_id), handler)
349
+ store = MemoryStore.for_session(session_id)
350
+ # A run with nobody watching still needs a ceiling. Both benchmark harnesses
351
+ # bind their own Budget; an `otto chat` turn bound NOTHING, so
352
+ # OTTO_MAX_MODEL_CALLS was a dead env var and an interactive turn could
353
+ # spend without limit. `current_budget() or default_budget()` keeps a
354
+ # harness's own budget when there is one.
355
+ budget = current_budget() or default_budget()
356
+ ledger = usage or current_usage() or UsageLedger()
357
+
358
+ with bind_budget(budget), bind_usage(ledger), bind_store(store), _workspace_binding(workspace):
359
+ with observe_run(session_id=session_id, name="otto:pipeline", input=text, tags=["pipeline"]) as run_span:
360
+ try:
361
+ final = _salvage(app.invoke(initial, config))
362
+ except Exception as exc:
363
+ # One salvage point, deliberately, rather than a catch
364
+ # inside the loop: a real bug should still surface loudly
365
+ # here and in the logs, but it must not cost the caller the
366
+ # work that was already done. See _salvage.
367
+ logger.exception("pipeline run failed; salvaging what it reached")
368
+ final = _salvage(app.get_state(config).values, exc)
369
+ if _paused(final):
370
+ # run_pipeline has no resume path -- resume is the streaming
371
+ # API. A caller here would otherwise record an empty answer
372
+ # and never learn that a question was asked.
373
+ logger.warning(
374
+ "run_pipeline: the graph paused to ask %r and cannot be "
375
+ "resumed from here; answering with what it had",
376
+ final.get("pending_question"),
377
+ )
378
+ _score(run_span, final)
379
+
380
+ return final
381
+
382
+
383
+ def _run_pipeline_stream(
384
+ text: str, *, session_id: str, history: Sequence[BaseMessage] = (),
385
+ memory_context: str = "", workspace: str | None = None,
386
+ usage: UsageLedger | None = None,
387
+ ):
388
+ """Same prewarm, tracing and scoring as run_pipeline(), but yields each
389
+ graph update as it happens (`stream_mode="updates"`) instead of
390
+ invoking and returning. What the interactive shell/TUI watch live.
391
+
392
+ `history`/`memory_context` are the conversation BEFORE `text` -- module
393
+ docstring's "Bounding that conversation memory". Both empty by default,
394
+ so every existing single-turn caller is unaffected.
395
+
396
+ The last item yielded is usually `{"__final__": AgentState,
397
+ "__trace_id__": str | None}` -- a plain dict, not a Command/node delta,
398
+ matching the retired run_pipeline_stream()'s/run_code_stream()'s exact
399
+ shape. The one exception (module docstring, "Pausing for a person
400
+ mid-run"): a run that hits nodes.py's ask_user node instead yields
401
+ `{"__ask__": {"question", "choices", "thread_id"}}` and ENDS there,
402
+ mid-turn -- no `__final__` this call. `resume_pipeline_stream()`,
403
+ below, is how a caller with an answer continues that same run -- it
404
+ re-binds this same session's MemoryStore itself, so recall_memory stays
405
+ usable across a resume too.
406
+ """
407
+ # Fails here, at the entry point, rather than inside a graph node: a
408
+ # Router can now be constructed without Inception (so the TUI can open
409
+ # on a keyless machine and offer setup), but nothing may RUN without it.
410
+ ROUTER.require_ready()
411
+ failures = ROUTER.prewarm()
412
+ if failures:
413
+ logger.warning("prewarm: %s", failures)
414
+
415
+ initial = _initial(text, history=history, memory_context=memory_context)
416
+ handler = callback_handler()
417
+ graph_thread_id = _graph_thread_id(session_id)
418
+ config = _config(graph_thread_id, handler)
419
+ store = MemoryStore.for_session(session_id)
420
+ # A run with nobody watching still needs a ceiling. Both benchmark harnesses
421
+ # bind their own Budget; an `otto chat` turn bound NOTHING, so
422
+ # OTTO_MAX_MODEL_CALLS was a dead env var and an interactive turn could
423
+ # spend without limit. `current_budget() or default_budget()` keeps a
424
+ # harness's own budget when there is one.
425
+ budget = current_budget() or default_budget()
426
+
427
+ # The caller's ledger when it has one (the TUI keeps one per SESSION,
428
+ # so its panel is cumulative across turns and resumes), otherwise a
429
+ # throwaway -- agent/pipeline/usage.py.
430
+ ledger = usage or current_usage() or UsageLedger()
431
+ with bind_budget(budget), bind_usage(ledger), bind_store(store), _workspace_binding(workspace):
432
+ with observe_run(session_id=session_id, name="otto:pipeline", input=text, tags=["pipeline"]) as run_span:
433
+ stream = app.stream(initial, config, stream_mode=_STREAM_MODES)
434
+ for event, is_ask in _stream_events(stream, graph_thread_id):
435
+ if is_ask:
436
+ run_span.update(output="(paused -- awaiting your answer)")
437
+ yield event
438
+ return
439
+ yield event
440
+ final = app.get_state(config).values
441
+ _score(run_span, final)
442
+ trace_id = run_span.trace_id
443
+
444
+ yield {"__final__": final, "__trace_id__": trace_id}
445
+
446
+
447
+ def _resume_pipeline_stream(
448
+ answer, *, thread_id: str, session_id: str, workspace: str | None = None,
449
+ usage: UsageLedger | None = None,
450
+ ):
451
+ """Continue a run that paused on an `{"__ask__": ...}` event (either
452
+ run_pipeline_stream()'s or a previous resume_pipeline_stream()'s) --
453
+ module docstring, "Pausing for a person mid-run". `thread_id` is that
454
+ event's own `"thread_id"` -- the SAME LangGraph checkpoint thread the
455
+ original run_pipeline_stream() call used, resumed via
456
+ `Command(resume=answer)` exactly where nodes.py's ask_user node
457
+ paused it (plan/context/active_step/everything else untouched), not
458
+ restarted as a fresh turn.
459
+
460
+ Same yield shape as run_pipeline_stream(): usually ends in
461
+ `{"__final__": ...}`, but can itself end in another `{"__ask__": ...}`
462
+ if the re-invoked specialist gets stuck again -- callers loop on this
463
+ the same way they loop on run_pipeline_stream() (agent/cli/chat.py,
464
+ agent/cli/tui.py). Re-binds `session_id`'s own MemoryStore for this
465
+ continuation too (module docstring, "Bounding that conversation
466
+ memory") -- recall_memory stays usable after a resume, not just on a
467
+ run's first `run_pipeline_stream()` call.
468
+ """
469
+ handler = callback_handler()
470
+ config = _config(thread_id, handler)
471
+ store = MemoryStore.for_session(session_id)
472
+ # A run with nobody watching still needs a ceiling. Both benchmark harnesses
473
+ # bind their own Budget; an `otto chat` turn bound NOTHING, so
474
+ # OTTO_MAX_MODEL_CALLS was a dead env var and an interactive turn could
475
+ # spend without limit. `current_budget() or default_budget()` keeps a
476
+ # harness's own budget when there is one.
477
+ budget = current_budget() or default_budget()
478
+
479
+ # The caller's ledger when it has one (the TUI keeps one per SESSION,
480
+ # so its panel is cumulative across turns and resumes), otherwise a
481
+ # throwaway -- agent/pipeline/usage.py.
482
+ ledger = usage or current_usage() or UsageLedger()
483
+ with bind_budget(budget), bind_usage(ledger), bind_store(store), _workspace_binding(workspace):
484
+ with observe_run(session_id=session_id, name="otto:pipeline:resume", input=str(answer), tags=["pipeline", "resumed"]) as run_span:
485
+ stream = app.stream(Command(resume=answer), config, stream_mode=_STREAM_MODES)
486
+ for event, is_ask in _stream_events(stream, thread_id):
487
+ if is_ask:
488
+ run_span.update(output="(paused -- awaiting your answer)")
489
+ yield event
490
+ return
491
+ yield event
492
+ final = app.get_state(config).values
493
+ _score(run_span, final)
494
+ trace_id = run_span.trace_id
495
+
496
+ yield {"__final__": final, "__trace_id__": trace_id}
497
+
498
+
499
+ def run_pipeline_stream(*args, **kwargs):
500
+ """`_run_pipeline_stream`, closed quietly when the front end has gone.
501
+
502
+ A streaming run holds contextvars and a tracing context across its
503
+ yields. Quit the TUI mid-turn and the generator is finalised by whatever
504
+ thread the garbage collector is on, where those tokens cannot be reset
505
+ (agent/pipeline/tracing.py). That is the only ValueError swallowed here;
506
+ every other one still surfaces.
507
+ """
508
+ try:
509
+ yield from _run_pipeline_stream(*args, **kwargs)
510
+ except ValueError as exc:
511
+ if not closing_in_foreign_context(exc):
512
+ raise
513
+
514
+
515
+ def resume_pipeline_stream(*args, **kwargs):
516
+ """`_resume_pipeline_stream`, with the same quiet close as
517
+ `run_pipeline_stream`."""
518
+ try:
519
+ yield from _resume_pipeline_stream(*args, **kwargs)
520
+ except ValueError as exc:
521
+ if not closing_in_foreign_context(exc):
522
+ raise
523
+
524
+
525
+ # `inspect.getsource` follows `__wrapped__`, so a reader (and
526
+ # tests/test_budget.py) sees the body that binds the budget, not the wrapper.
527
+ run_pipeline_stream.__wrapped__ = _run_pipeline_stream # type: ignore[attr-defined]
528
+ resume_pipeline_stream.__wrapped__ = _resume_pipeline_stream # type: ignore[attr-defined]
@@ -0,0 +1,77 @@
1
+ """Looking at a screen and clicking on it, in the container Otto is working in.
2
+
3
+ Same seam as the browser, and for a stronger reason. A screen-control tool
4
+ pointed at a real machine can click anything its owner happens to have open;
5
+ pointed at a container it can click the things somebody deliberately put in the
6
+ container. That difference is why this drives `containers/otto-desktop` through
7
+ `agent/pipeline/execution.py`'s command runner rather than a host, and why Otto
8
+ ships no screen-control dependency of its own.
9
+
10
+ WHAT COMES BACK IS WORDS, NOT PIXELS. A capture is routed through
11
+ agent/pipeline/vision.py exactly as `view_image` is, so the conversation
12
+ receives a description. That keeps the text-only invariant vision.py argues for
13
+ at length, and it is why the reading tool takes a QUESTION -- "which window is
14
+ in front?", "where is the Save button?" -- rather than handing back an image. A
15
+ narrow question answered twice is close to being able to look; a generic
16
+ caption is worth little.
17
+
18
+ THIS IS THE FALLBACK. Agents that route each subtask to code or GUI and prefer
19
+ CODE reach 60.76% on OSWorld in 10.15 steps against ~15 for GUI-only, a 32%
20
+ reduction, and a hybrid action space is worth +22% relative; GUI-only chains are
21
+ described as brittle and prone to cascading failure. The desktop image is built
22
+ on the one Otto already drives precisely so the shell is still there -- a
23
+ desktop with no shell on it would force the worse path.
24
+
25
+ HONEST ABOUT GROUNDING. Knowing WHERE to click is the hard part and none of
26
+ this solves it: the agent reads a description and names coordinates, which is
27
+ what every GUI agent does and why the measured scores are what they are --
28
+ grounding is one of the two failure modes named in the benchmark that defined
29
+ the task. Expect look, act, look again.
30
+ """
31
+ from __future__ import annotations
32
+
33
+ #: Ceiling on a capture handed to the vision model. A 1280x800 PNG is well
34
+ #: under this; the cap is here so a giant virtual screen fails loudly rather
35
+ #: than silently costing a fortune in image tokens.
36
+ MAX_CAPTURE_BYTES = 8 * 1024 * 1024
37
+
38
+ #: What the agent may ask the screen to do. Two operations, because a small
39
+ #: vocabulary a model uses correctly beats a faithful reproduction of a mouse.
40
+ ACT_OPS = ("click", "type")
41
+
42
+ #: Grab the root window as a PNG on stdout, base64'd so it survives a shell.
43
+ #: ImageMagick's `import` is already on the desktop image.
44
+ #: `-w0` because base64 wraps at 76 columns by default and strict decoding
45
+ #: rejects the newlines -- which is exactly how this failed the first time.
46
+ CAPTURE = (
47
+ 'import -display "$DISPLAY" -window root png:- 2>/dev/null | base64 -w0'
48
+ )
49
+
50
+
51
+ def click_command(x: int, y: int) -> str:
52
+ """Move the pointer and click, via xdotool."""
53
+ return f'xdotool mousemove {int(x)} {int(y)} click 1'
54
+
55
+
56
+ def type_command(text: str) -> str:
57
+ """Type wherever focus is. `--clearmodifiers` because a stuck modifier from
58
+ an earlier action turns ordinary text into a stream of shortcuts."""
59
+ quoted = text.replace("'", "'\\''")
60
+ return f"xdotool type --clearmodifiers --delay 12 '{quoted}'"
61
+
62
+
63
+ def parse_act(body: str) -> tuple[str, str] | str:
64
+ """`(operation, argument)` from a CODE: body, or why it is not one."""
65
+ op, _, argument = body.strip().partition(" ")
66
+ op = op.strip().lower()
67
+ if op not in ACT_OPS:
68
+ return f"say `click X Y` or `type <text>` -- {op!r} is neither"
69
+ return op, argument.strip()
70
+
71
+
72
+ def parse_point(argument: str) -> tuple[int, int] | str:
73
+ """A click target, or why it is not one."""
74
+ parts = argument.replace(",", " ").split()
75
+ if len(parts) != 2 or not all(p.lstrip("-").isdigit() for p in parts):
76
+ return "click needs two numbers: `click X Y`"
77
+ return int(parts[0]), int(parts[1])