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/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]
|
agent/pipeline/screen.py
ADDED
|
@@ -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])
|