omega-coding-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.
- omega_agent/__init__.py +19 -0
- omega_agent/agent_events.py +166 -0
- omega_agent/cancellation.py +52 -0
- omega_agent/events.py +157 -0
- omega_agent/harness.py +497 -0
- omega_agent/hooks.py +112 -0
- omega_agent/loop.py +190 -0
- omega_agent/provider.py +56 -0
- omega_agent/session/__init__.py +34 -0
- omega_agent/session/entries.py +48 -0
- omega_agent/session/jsonl.py +99 -0
- omega_agent/session/store.py +310 -0
- omega_agent/session/tree.py +101 -0
- omega_agent/tool_runner.py +116 -0
- omega_agent/tools.py +97 -0
- omega_agent/types.py +222 -0
- omega_ai/__init__.py +14 -0
- omega_ai/anthropic.py +709 -0
- omega_ai/fake.py +184 -0
- omega_ai/openai.py +580 -0
- omega_ai/openai_codex.py +720 -0
- omega_ai/provider.py +22 -0
- omega_ai/retry.py +146 -0
- omega_coding/__init__.py +18 -0
- omega_coding/approval.py +374 -0
- omega_coding/auth.py +384 -0
- omega_coding/builtin_tools.py +812 -0
- omega_coding/cli.py +1001 -0
- omega_coding/clipboard.py +224 -0
- omega_coding/commands.py +904 -0
- omega_coding/compact.py +377 -0
- omega_coding/context.py +187 -0
- omega_coding/cost.py +123 -0
- omega_coding/env.py +86 -0
- omega_coding/evals.py +137 -0
- omega_coding/eventlog.py +160 -0
- omega_coding/file_lock.py +57 -0
- omega_coding/headless.py +125 -0
- omega_coding/history.py +42 -0
- omega_coding/models.py +560 -0
- omega_coding/oauth.py +519 -0
- omega_coding/paths.py +159 -0
- omega_coding/redact.py +194 -0
- omega_coding/status.py +316 -0
- omega_coding/subagent.py +185 -0
- omega_coding/system_prompt.py +125 -0
- omega_coding/truncate.py +175 -0
- omega_coding/tui/__init__.py +13 -0
- omega_coding/tui/adapter.py +130 -0
- omega_coding/tui/app.py +1111 -0
- omega_coding/tui/approval.py +146 -0
- omega_coding/tui/banner.py +272 -0
- omega_coding/tui/config.py +81 -0
- omega_coding/tui/login.py +161 -0
- omega_coding/tui/state.py +299 -0
- omega_coding/tui/themes/__init__.py +267 -0
- omega_coding/tui/themes/high-contrast.json +33 -0
- omega_coding/tui/themes/oxblood-dark.json +33 -0
- omega_coding/tui/themes/oxblood-light.json +33 -0
- omega_coding/tui/themes/slate.json +33 -0
- omega_coding/tui/widgets.py +766 -0
- omega_coding/version.py +42 -0
- omega_coding_agent-0.1.0.dist-info/METADATA +447 -0
- omega_coding_agent-0.1.0.dist-info/RECORD +67 -0
- omega_coding_agent-0.1.0.dist-info/WHEEL +4 -0
- omega_coding_agent-0.1.0.dist-info/entry_points.txt +2 -0
- omega_coding_agent-0.1.0.dist-info/licenses/LICENSE +21 -0
omega_agent/harness.py
ADDED
|
@@ -0,0 +1,497 @@
|
|
|
1
|
+
"""The harness — the object that owns the conversation.
|
|
2
|
+
|
|
3
|
+
Tier 1 had nothing here, and `cli.py` held a bare `list`. That is fine while the
|
|
4
|
+
transcript has exactly one reader. It stops being fine the moment something else
|
|
5
|
+
needs it, and in Tier 2 three things do:
|
|
6
|
+
|
|
7
|
+
* **persistence** must write entries as they appear
|
|
8
|
+
* **orphan repair** must scan the transcript after an interrupt
|
|
9
|
+
* **the steering queue** must add to it between turns
|
|
10
|
+
|
|
11
|
+
`python/TIER-1.md` named this directly: orphan repair "needs a harness that owns
|
|
12
|
+
`messages`". So one object owns it, and the loop stays a function over a list.
|
|
13
|
+
|
|
14
|
+
**Nothing flows downward from a UI.** A listener subscribes and is called; it
|
|
15
|
+
cannot reach in and drive the loop. When you type while the agent works, the text
|
|
16
|
+
is *queued* on the harness and the loop picks it up when it is ready. That
|
|
17
|
+
strictness is why Tau's whole agent-to-UI bridge is 99 lines, and why the same
|
|
18
|
+
agent could later run on a server with the UI somewhere else.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from collections.abc import AsyncIterator, Callable, Sequence
|
|
24
|
+
from dataclasses import replace
|
|
25
|
+
|
|
26
|
+
from omega_agent.agent_events import (
|
|
27
|
+
AgentEvent,
|
|
28
|
+
MessageEndEvent,
|
|
29
|
+
MessageStartEvent,
|
|
30
|
+
MessageUpdateEvent,
|
|
31
|
+
ToolExecutionEndEvent,
|
|
32
|
+
)
|
|
33
|
+
from omega_agent.cancellation import CancelSignal
|
|
34
|
+
from omega_agent.hooks import AgentHooks
|
|
35
|
+
from omega_agent.loop import DEFAULT_MAX_TURNS, run_agent_loop
|
|
36
|
+
from omega_agent.provider import ModelProvider
|
|
37
|
+
from omega_agent.session import SessionStore
|
|
38
|
+
from omega_agent.session.tree import leaves
|
|
39
|
+
from omega_agent.tools import Tool
|
|
40
|
+
from omega_agent.types import AgentMessage, AssistantMessage, ToolResultMessage, UserMessage
|
|
41
|
+
|
|
42
|
+
#: A subscriber. Synchronous and returning nothing, deliberately: a listener that
|
|
43
|
+
#: could block or fail would be able to stall the loop it is only watching.
|
|
44
|
+
AgentListener = Callable[[AgentEvent], None]
|
|
45
|
+
|
|
46
|
+
#: What a synthesized tool result says.
|
|
47
|
+
#:
|
|
48
|
+
#: Honest on purpose. We know that no result was recorded; we do *not* know
|
|
49
|
+
#: whether the tool ran. A note claiming "nothing happened" would be a lie the
|
|
50
|
+
#: model then reasons from — it might skip re-checking a file that was in fact
|
|
51
|
+
#: half-written. "Unknown, verify" is both true and actionable.
|
|
52
|
+
INTERRUPTED_NOTE = (
|
|
53
|
+
"Interrupted: this tool call was cancelled before a result was recorded. "
|
|
54
|
+
"Its effect is unknown - verify the current state before relying on it."
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class Harness:
|
|
59
|
+
"""Holds the transcript and the run configuration; drives the loop.
|
|
60
|
+
|
|
61
|
+
One harness is one conversation. Call `run()` repeatedly and the transcript
|
|
62
|
+
accumulates, which is what makes a REPL a conversation rather than a series
|
|
63
|
+
of unrelated questions.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
def __init__(
|
|
67
|
+
self,
|
|
68
|
+
*,
|
|
69
|
+
provider: ModelProvider,
|
|
70
|
+
model: str,
|
|
71
|
+
system: str,
|
|
72
|
+
tools: Sequence[Tool],
|
|
73
|
+
hooks: AgentHooks | None = None,
|
|
74
|
+
max_turns: int = DEFAULT_MAX_TURNS,
|
|
75
|
+
signal: CancelSignal | None = None,
|
|
76
|
+
store: SessionStore | None = None,
|
|
77
|
+
) -> None:
|
|
78
|
+
self.provider = provider
|
|
79
|
+
self.model = model
|
|
80
|
+
self.system = system
|
|
81
|
+
self.tools = list(tools)
|
|
82
|
+
self.max_turns = max_turns
|
|
83
|
+
|
|
84
|
+
#: Queues the loop drains between turns. The harness owns them because
|
|
85
|
+
#: it is the only thing that outlives a single run, and it supplies the
|
|
86
|
+
#: two hooks that read them — unless the caller already did, in which
|
|
87
|
+
#: case theirs wins and these stay unused.
|
|
88
|
+
self._steering: list[AgentMessage] = []
|
|
89
|
+
self._follow_ups: list[AgentMessage] = []
|
|
90
|
+
|
|
91
|
+
base = hooks if hooks is not None else AgentHooks()
|
|
92
|
+
self.hooks = replace(
|
|
93
|
+
base,
|
|
94
|
+
get_steering_messages=base.get_steering_messages or self._drain_steering,
|
|
95
|
+
get_follow_up_messages=base.get_follow_up_messages or self._drain_follow_ups,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
#: Concrete, not the Protocol: the harness *owns* cancellation, so it
|
|
99
|
+
#: needs to be able to set and clear it, not merely ask. Everything
|
|
100
|
+
#: below only ever asks.
|
|
101
|
+
self.signal = signal if signal is not None else CancelSignal()
|
|
102
|
+
|
|
103
|
+
#: The transcript. The harness owns it; the loop appends to it.
|
|
104
|
+
self.messages: list[AgentMessage] = []
|
|
105
|
+
self._listeners: list[AgentListener] = []
|
|
106
|
+
|
|
107
|
+
#: Persistence is optional. Without a store the harness behaves exactly
|
|
108
|
+
#: as it did in Step 1 and touches no files.
|
|
109
|
+
self.store = store
|
|
110
|
+
self.session_id: str | None = None
|
|
111
|
+
|
|
112
|
+
#: How much of `messages` is already on disk. The loop appends directly
|
|
113
|
+
#: to the list, and so does `repair_orphans`, so tracking a high-water
|
|
114
|
+
#: mark catches every writer without any of them knowing about storage.
|
|
115
|
+
self._persisted = 0
|
|
116
|
+
|
|
117
|
+
#: The same trick, one step earlier. Counts how much of `messages` has
|
|
118
|
+
#: been through `before_record`. Separate from `_persisted` because
|
|
119
|
+
#: recording must happen even with no store — `--no-save` still sends the
|
|
120
|
+
#: transcript to a provider, so a key in it is still a key leaked.
|
|
121
|
+
self._recorded = 0
|
|
122
|
+
|
|
123
|
+
def add_listener(self, listener: AgentListener) -> None:
|
|
124
|
+
"""Subscribe to the agent events. Boundary D, and it only goes one way."""
|
|
125
|
+
self._listeners.append(listener)
|
|
126
|
+
|
|
127
|
+
def cancel(self) -> None:
|
|
128
|
+
"""Ask the current turn to stop.
|
|
129
|
+
|
|
130
|
+
Safe to call from a signal handler: it sets one boolean and returns. The
|
|
131
|
+
turn ends at the next check — in the provider stream, in a tool, or
|
|
132
|
+
before the next request — and ends *properly*, with a valid transcript.
|
|
133
|
+
"""
|
|
134
|
+
self.signal.cancel()
|
|
135
|
+
|
|
136
|
+
# ------------------------------------------------------------------ repair
|
|
137
|
+
|
|
138
|
+
def repair_orphans(self) -> int:
|
|
139
|
+
"""Answer every tool call that has no result. Returns how many it fixed.
|
|
140
|
+
|
|
141
|
+
**The fix for the nastiest of the nine beginner failures.** An assistant
|
|
142
|
+
message carrying a tool call with no matching result is rejected by
|
|
143
|
+
providers — not once, but on every future request. The conversation is
|
|
144
|
+
invalid forever. Interrupt at the wrong moment, persist the transcript,
|
|
145
|
+
and you have written a file that can never be resumed.
|
|
146
|
+
|
|
147
|
+
Results are inserted **beside the call that lacks one**, not appended to
|
|
148
|
+
the end. Order is a provider requirement: a result belongs to the
|
|
149
|
+
assistant turn that asked for it, so in a multi-turn transcript appending
|
|
150
|
+
would answer the wrong message.
|
|
151
|
+
"""
|
|
152
|
+
repaired: list[AgentMessage] = []
|
|
153
|
+
fixed = 0
|
|
154
|
+
index = 0
|
|
155
|
+
|
|
156
|
+
while index < len(self.messages):
|
|
157
|
+
message = self.messages[index]
|
|
158
|
+
repaired.append(message)
|
|
159
|
+
index += 1
|
|
160
|
+
|
|
161
|
+
if not isinstance(message, AssistantMessage) or not message.tool_calls:
|
|
162
|
+
continue
|
|
163
|
+
|
|
164
|
+
# Consume the results that already follow this assistant message.
|
|
165
|
+
answered: set[str] = set()
|
|
166
|
+
while index < len(self.messages):
|
|
167
|
+
following = self.messages[index]
|
|
168
|
+
if not isinstance(following, ToolResultMessage):
|
|
169
|
+
break
|
|
170
|
+
answered.add(following.tool_call_id)
|
|
171
|
+
repaired.append(following)
|
|
172
|
+
index += 1
|
|
173
|
+
|
|
174
|
+
for call in message.tool_calls:
|
|
175
|
+
if call.id in answered:
|
|
176
|
+
continue
|
|
177
|
+
repaired.append(
|
|
178
|
+
ToolResultMessage(
|
|
179
|
+
tool_call_id=call.id,
|
|
180
|
+
tool_name=call.name,
|
|
181
|
+
content=INTERRUPTED_NOTE, # type: ignore[arg-type]
|
|
182
|
+
is_error=True,
|
|
183
|
+
)
|
|
184
|
+
)
|
|
185
|
+
fixed += 1
|
|
186
|
+
|
|
187
|
+
# Rewrite in place: callers may be holding this list.
|
|
188
|
+
self.messages[:] = repaired
|
|
189
|
+
return fixed
|
|
190
|
+
|
|
191
|
+
# ------------------------------------------------------------------ queues
|
|
192
|
+
|
|
193
|
+
def queue_steering(self, text: str) -> None:
|
|
194
|
+
"""Add guidance to be picked up between turns, mid-run.
|
|
195
|
+
|
|
196
|
+
"Actually, use pytest not unittest" while it is already working. The
|
|
197
|
+
loop reads this after tool results and before the next request, so the
|
|
198
|
+
correction lands without restarting the task.
|
|
199
|
+
"""
|
|
200
|
+
self._steering.append(UserMessage(content=text))
|
|
201
|
+
|
|
202
|
+
def unqueue_steering(self) -> str | None:
|
|
203
|
+
"""Take back the most recently queued steering message, if it is still here.
|
|
204
|
+
|
|
205
|
+
**The queue was write-only**, which made "I typed that in a hurry" a
|
|
206
|
+
thing you could not undo: the message was already committed to the next
|
|
207
|
+
request and the only way out was cancelling the whole turn. This is the
|
|
208
|
+
smallest thing that fixes it — the last message, and only while it is
|
|
209
|
+
still waiting.
|
|
210
|
+
|
|
211
|
+
Returns None once the loop has drained the queue, because by then the
|
|
212
|
+
correction is part of the conversation and taking it back would mean
|
|
213
|
+
editing history rather than editing a draft.
|
|
214
|
+
"""
|
|
215
|
+
if not self._steering:
|
|
216
|
+
return None
|
|
217
|
+
message = self._steering.pop()
|
|
218
|
+
content = message.content
|
|
219
|
+
if isinstance(content, str):
|
|
220
|
+
return content
|
|
221
|
+
return "".join(getattr(block, "text", "") for block in content)
|
|
222
|
+
|
|
223
|
+
def queue_follow_up(self, text: str) -> None:
|
|
224
|
+
"""Queue the next task, to start when the current one finishes."""
|
|
225
|
+
self._follow_ups.append(UserMessage(content=text))
|
|
226
|
+
|
|
227
|
+
async def _drain_steering(self) -> list[AgentMessage]:
|
|
228
|
+
drained, self._steering = self._steering, []
|
|
229
|
+
return drained
|
|
230
|
+
|
|
231
|
+
async def _drain_follow_ups(self) -> list[AgentMessage]:
|
|
232
|
+
drained, self._follow_ups = self._follow_ups, []
|
|
233
|
+
return drained
|
|
234
|
+
|
|
235
|
+
# ---------------------------------------------------------------- sessions
|
|
236
|
+
|
|
237
|
+
def resume(self, session_id: str) -> int:
|
|
238
|
+
"""Load a stored transcript into this harness. Returns how many messages.
|
|
239
|
+
|
|
240
|
+
**Repairs orphans immediately**, and that is the point where Step 2 and
|
|
241
|
+
Step 5 meet. An interrupted session is exactly the one you want to
|
|
242
|
+
resume, and exactly the one carrying an unanswered tool call — which
|
|
243
|
+
providers reject on every future request. Loading it without repairing it
|
|
244
|
+
would hand back a conversation that can never be continued.
|
|
245
|
+
"""
|
|
246
|
+
if self.store is None:
|
|
247
|
+
raise ValueError("Cannot resume without a session store.")
|
|
248
|
+
|
|
249
|
+
self.session_id = session_id
|
|
250
|
+
self.messages[:] = self.store.load(session_id)
|
|
251
|
+
# The next append must hang where that load ended, and `load` ends at the
|
|
252
|
+
# newest leaf. This store may still point wherever a rewind left it with
|
|
253
|
+
# nothing asked after, which saved the next answer to another branch than
|
|
254
|
+
# the one on screen. Same rule as `load`, so the two cannot drift apart.
|
|
255
|
+
ends = leaves(self.store.entries(session_id))
|
|
256
|
+
self.store.branch_from(session_id, ends[-1].id if ends else None)
|
|
257
|
+
self._persisted = len(self.messages)
|
|
258
|
+
|
|
259
|
+
# Deliberately 0, not len(). What is on disk was redacted on the way in
|
|
260
|
+
# *if* this harness wrote it — a session written before `before_record`
|
|
261
|
+
# existed was not, and resuming is the one chance to clean it.
|
|
262
|
+
self._recorded = 0
|
|
263
|
+
|
|
264
|
+
# Anything the repair adds is beyond the high-water mark, so it is
|
|
265
|
+
# written on the next flush and the session is only repaired once.
|
|
266
|
+
self.repair_orphans()
|
|
267
|
+
return len(self.messages)
|
|
268
|
+
|
|
269
|
+
def start_new_session(self) -> str | None:
|
|
270
|
+
"""Forget the conversation and begin a new one. Returns the id left behind.
|
|
271
|
+
|
|
272
|
+
The mirror of `resume`, touching the same three pieces of state: the
|
|
273
|
+
messages, the high-water mark, and the session id.
|
|
274
|
+
|
|
275
|
+
**Nothing is deleted.** The previous transcript stays on disk, keeps its
|
|
276
|
+
place in `--sessions`, and can still be reopened with `--resume`. Erasing
|
|
277
|
+
it would make an append-only log a lie — and a `/clear` that destroys work
|
|
278
|
+
is a `/clear` people are afraid to type.
|
|
279
|
+
|
|
280
|
+
Setting `session_id` to None is the entire mechanism for starting the next
|
|
281
|
+
file: `run` already creates a session when it finds none, so this needs no
|
|
282
|
+
new path through the store.
|
|
283
|
+
"""
|
|
284
|
+
previous = self.session_id
|
|
285
|
+
self.messages.clear()
|
|
286
|
+
self._persisted = 0
|
|
287
|
+
self._recorded = 0
|
|
288
|
+
self.session_id = None
|
|
289
|
+
return previous
|
|
290
|
+
|
|
291
|
+
def rewind(self, questions: int = 1) -> int:
|
|
292
|
+
"""Drop back to before the last `questions` user messages. Returns the
|
|
293
|
+
number of messages removed.
|
|
294
|
+
|
|
295
|
+
**The user-facing half of branching.** Reading a tree is useless without
|
|
296
|
+
a way to make one, and this is the way: "that went wrong, go back and ask
|
|
297
|
+
differently".
|
|
298
|
+
|
|
299
|
+
Counted in *questions*, not messages, because that is the unit a person
|
|
300
|
+
thinks in. One question can produce a dozen messages — an assistant turn,
|
|
301
|
+
four tool calls, four results, a final answer — and "go back 12 messages"
|
|
302
|
+
is not something anyone knows the answer to.
|
|
303
|
+
|
|
304
|
+
**Nothing is deleted.** The abandoned messages stay in the session file
|
|
305
|
+
exactly where they are; the next append simply hangs off an older parent,
|
|
306
|
+
so the old branch remains loadable with `load(branch=...)`. That is the
|
|
307
|
+
whole reason the file is append-only, and the reason `parent_id` was
|
|
308
|
+
written on every entry a tier before anything read it.
|
|
309
|
+
|
|
310
|
+
**With a session file, the cut is made on the file, not on this list.**
|
|
311
|
+
This used to cut the list and use the same position in `store.entries()`
|
|
312
|
+
as the new parent, which holds only while the two are the same length and
|
|
313
|
+
order. Two ordinary things break that. `replace_transcript` shrinks the
|
|
314
|
+
list and writes nothing, and every rewind leaves an abandoned branch in a
|
|
315
|
+
file that `entries()` returns whole. Both were measured: after `/compact`
|
|
316
|
+
the next answer hung off an early question and the file lost four turns,
|
|
317
|
+
and a second rewind put the first one's abandoned branch back. Now the
|
|
318
|
+
store says which conversation the next append continues (`path`), the cut
|
|
319
|
+
is made there, and the list is reloaded from what is kept.
|
|
320
|
+
|
|
321
|
+
So after a compaction the list comes back whole. The compacted list was a
|
|
322
|
+
working copy, and a rewind is a statement about the conversation. Nor can
|
|
323
|
+
the compaction note be taken for a question, since it was never written.
|
|
324
|
+
|
|
325
|
+
**Without a file (`--no-save`) the list is all there is**, so it is cut
|
|
326
|
+
directly. There the note is the nearest point a rewind can reach, because
|
|
327
|
+
the turns it stands for exist nowhere else.
|
|
328
|
+
"""
|
|
329
|
+
if self.store is not None and self.session_id is not None:
|
|
330
|
+
path = self.store.path(self.session_id)
|
|
331
|
+
starts = [i for i, entry in enumerate(path) if isinstance(entry.message, UserMessage)]
|
|
332
|
+
if not starts:
|
|
333
|
+
return 0
|
|
334
|
+
cut = starts[-questions] if questions <= len(starts) else starts[0]
|
|
335
|
+
self.store.branch_from(self.session_id, path[cut - 1].id if cut else None)
|
|
336
|
+
self.messages[:] = [entry.message for entry in path[:cut]]
|
|
337
|
+
self._persisted = len(self.messages)
|
|
338
|
+
# 0, as in `resume`: these came back from the file, and a file written
|
|
339
|
+
# before `before_record` existed was never masked.
|
|
340
|
+
self._recorded = 0
|
|
341
|
+
return len(path) - cut
|
|
342
|
+
|
|
343
|
+
starts = [i for i, m in enumerate(self.messages) if isinstance(m, UserMessage)]
|
|
344
|
+
if not starts:
|
|
345
|
+
return 0
|
|
346
|
+
cut = starts[-questions] if questions <= len(starts) else starts[0]
|
|
347
|
+
removed = len(self.messages) - cut
|
|
348
|
+
del self.messages[cut:]
|
|
349
|
+
self._persisted = len(self.messages)
|
|
350
|
+
self._recorded = len(self.messages)
|
|
351
|
+
return removed
|
|
352
|
+
|
|
353
|
+
def replace_transcript(self, messages: Sequence[AgentMessage]) -> int:
|
|
354
|
+
"""Swap the working transcript for a smaller one. Returns how many remain.
|
|
355
|
+
|
|
356
|
+
The third method touching the same state as `resume` and
|
|
357
|
+
`start_new_session`, and the one with the subtlest failure mode.
|
|
358
|
+
|
|
359
|
+
**`_persisted` must move too.** It is a high-water mark, not a count of
|
|
360
|
+
what is on disk in general: `_flush` writes `messages[_persisted:]`.
|
|
361
|
+
Shrink `messages` from 20 to 4 and leave the mark at 20, and the next
|
|
362
|
+
sixteen real messages are silently never written — a data-loss bug that
|
|
363
|
+
no test of compaction itself would catch. Setting it to the new length
|
|
364
|
+
means "everything currently held is accounted for".
|
|
365
|
+
|
|
366
|
+
**Nothing is deleted from disk.** The session file is append-only and
|
|
367
|
+
still holds every original entry; later messages append after them, so
|
|
368
|
+
`--resume` returns the *full* history rather than the compacted view. The
|
|
369
|
+
file is the record, this list is the working set, and they are allowed to
|
|
370
|
+
differ — the same two-views split `history.py` describes, one level out.
|
|
371
|
+
"""
|
|
372
|
+
self.messages[:] = list(messages)
|
|
373
|
+
self._persisted = len(self.messages)
|
|
374
|
+
self._recorded = len(self.messages)
|
|
375
|
+
return len(self.messages)
|
|
376
|
+
|
|
377
|
+
async def _clean_event(self, event: AgentEvent) -> AgentEvent:
|
|
378
|
+
"""Mask the message an event carries, if it carries one.
|
|
379
|
+
|
|
380
|
+
Reordering `_record` alone was not enough. An event holds its **own
|
|
381
|
+
copy** of the message, so replacing the entry in `messages` leaves the
|
|
382
|
+
event pointing at the original. Five of the ten event types carry one.
|
|
383
|
+
|
|
384
|
+
Returns a copy, like every other hook here: the loop may still be holding
|
|
385
|
+
the event it built, and editing that underneath it would be the same
|
|
386
|
+
mistake in a new place.
|
|
387
|
+
"""
|
|
388
|
+
hook = self.hooks.before_record
|
|
389
|
+
if hook is None:
|
|
390
|
+
return event
|
|
391
|
+
|
|
392
|
+
if isinstance(event, MessageStartEvent | MessageUpdateEvent | MessageEndEvent):
|
|
393
|
+
return event.model_copy(update={"message": await hook(event.message)})
|
|
394
|
+
if isinstance(event, ToolExecutionEndEvent):
|
|
395
|
+
return event.model_copy(update={"result": await hook(event.result)})
|
|
396
|
+
return event
|
|
397
|
+
|
|
398
|
+
async def _record(self) -> None:
|
|
399
|
+
"""Offer every new message to `before_record`, then persist.
|
|
400
|
+
|
|
401
|
+
**The seam redaction actually wanted.** `after_tool_call` fires when a
|
|
402
|
+
*tool* returns a value, which is one of several ways into the transcript;
|
|
403
|
+
this fires on the transcript itself, so it covers all of them — the
|
|
404
|
+
model's own answer, the user's prompt, a synthesized interrupt note, and
|
|
405
|
+
anything a later tier adds.
|
|
406
|
+
|
|
407
|
+
Driven by a high-water mark for the same reason persistence is: the loop
|
|
408
|
+
appends straight to `messages` and so does `repair_orphans`, so counting
|
|
409
|
+
how far we have got catches every writer without any of them knowing this
|
|
410
|
+
exists.
|
|
411
|
+
|
|
412
|
+
Replaces rather than mutates. A hook returning a new message means the
|
|
413
|
+
caller's object is never edited underneath anything holding a reference,
|
|
414
|
+
which is the same guarantee `transform_context` gives one layer up.
|
|
415
|
+
"""
|
|
416
|
+
await self.clean_pending()
|
|
417
|
+
self._flush()
|
|
418
|
+
|
|
419
|
+
async def clean_pending(self) -> None:
|
|
420
|
+
"""Offer `before_record` every message it has not seen, and write nothing.
|
|
421
|
+
|
|
422
|
+
`_record` is this, then a write. **It is public for whatever shows the
|
|
423
|
+
transcript between turns.** `resume` and `rewind` load messages from the
|
|
424
|
+
file and leave masking to the next turn. That is enough for the model,
|
|
425
|
+
which sees nothing until then, but not for a screen that draws the
|
|
426
|
+
session the moment it loads. A file written before `before_record`
|
|
427
|
+
existed holds keys in the clear, and the terminal UI drew them.
|
|
428
|
+
|
|
429
|
+
Writes nothing on purpose. Opening a session to read it must not append
|
|
430
|
+
to it, and the file is append-only, so a masked copy could not replace
|
|
431
|
+
the original line anyway.
|
|
432
|
+
"""
|
|
433
|
+
if self.hooks.before_record is not None:
|
|
434
|
+
while self._recorded < len(self.messages):
|
|
435
|
+
index = self._recorded
|
|
436
|
+
self.messages[index] = await self.hooks.before_record(self.messages[index])
|
|
437
|
+
self._recorded += 1
|
|
438
|
+
else:
|
|
439
|
+
self._recorded = len(self.messages)
|
|
440
|
+
|
|
441
|
+
def _flush(self) -> None:
|
|
442
|
+
"""Write whatever is not on disk yet.
|
|
443
|
+
|
|
444
|
+
Called after every event rather than at the end of a run: the process
|
|
445
|
+
dying mid-task is the event persistence exists for, so a transcript that
|
|
446
|
+
is only saved on success saves nothing worth having.
|
|
447
|
+
"""
|
|
448
|
+
if self.store is None or self.session_id is None:
|
|
449
|
+
return
|
|
450
|
+
while self._persisted < len(self.messages):
|
|
451
|
+
self.store.append(self.session_id, self.messages[self._persisted])
|
|
452
|
+
self._persisted += 1
|
|
453
|
+
|
|
454
|
+
# --------------------------------------------------------------------- run
|
|
455
|
+
|
|
456
|
+
async def run(self, prompt: str) -> AsyncIterator[AgentEvent]:
|
|
457
|
+
"""One user prompt, run to completion.
|
|
458
|
+
|
|
459
|
+
Events are both handed to every listener and yielded to the caller, so a
|
|
460
|
+
UI can subscribe while a test simply iterates.
|
|
461
|
+
"""
|
|
462
|
+
# Clear a stale cancellation before anything else, or the previous
|
|
463
|
+
# Ctrl-C cancels this turn too.
|
|
464
|
+
self.signal.reset()
|
|
465
|
+
|
|
466
|
+
# Heal an interrupted transcript on the way in. The user should not have
|
|
467
|
+
# to know this failure mode exists — the request that would have failed
|
|
468
|
+
# permanently simply succeeds.
|
|
469
|
+
self.repair_orphans()
|
|
470
|
+
|
|
471
|
+
if self.store is not None and self.session_id is None:
|
|
472
|
+
self.session_id = self.store.create_session(model=self.model)
|
|
473
|
+
|
|
474
|
+
self.messages.append(UserMessage(content=prompt))
|
|
475
|
+
await self._record()
|
|
476
|
+
|
|
477
|
+
async for event in run_agent_loop(
|
|
478
|
+
provider=self.provider,
|
|
479
|
+
model=self.model,
|
|
480
|
+
system=self.system,
|
|
481
|
+
messages=self.messages,
|
|
482
|
+
tools=self.tools,
|
|
483
|
+
hooks=self.hooks,
|
|
484
|
+
max_turns=self.max_turns,
|
|
485
|
+
signal=self.signal,
|
|
486
|
+
):
|
|
487
|
+
# **Record first, then notify.** The other order was a real leak:
|
|
488
|
+
# listeners saw the original message while the transcript was being
|
|
489
|
+
# masked behind them, so a logging subscriber would have written the
|
|
490
|
+
# secret to disk by a route `before_record` never covered.
|
|
491
|
+
await self._record()
|
|
492
|
+
cleaned = await self._clean_event(event)
|
|
493
|
+
for listener in self._listeners:
|
|
494
|
+
listener(cleaned)
|
|
495
|
+
yield cleaned
|
|
496
|
+
|
|
497
|
+
await self._record()
|
omega_agent/hooks.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""The seams — how the coding app installs policy into the loop.
|
|
2
|
+
|
|
3
|
+
This file is why `loop.py` does not have to grow. It is the boundary that felt
|
|
4
|
+
backwards in `docs/03-architecture/04-boundaries-and-layout.md` §3, Boundary B:
|
|
5
|
+
Layer 3 hands Layer 2 a bundle of functions once, at startup, and the loop calls
|
|
6
|
+
them later at the right moments.
|
|
7
|
+
|
|
8
|
+
> **The loop asks, never decides.**
|
|
9
|
+
|
|
10
|
+
Every one of these is a *decision* — may I run this? what should be sent? — and
|
|
11
|
+
decisions belong to the caller. The mechanism (ask model, run tools, repeat) is
|
|
12
|
+
all that stays in the loop. Pi's 880-line compaction subsystem plugs into
|
|
13
|
+
exactly one of these callbacks, and its loop contains zero lines of compaction.
|
|
14
|
+
|
|
15
|
+
Pi has nine hooks and Tau six. Tier 2 fills these:
|
|
16
|
+
|
|
17
|
+
| Hook | When | What fills it |
|
|
18
|
+
|---|---|---|
|
|
19
|
+
| `before_tool_call` | before running a tool | approvals — Step 4 |
|
|
20
|
+
| `after_tool_call` | after a tool returns | secret redaction — Step 4 |
|
|
21
|
+
| `convert_to_llm` | before every request | drop UI-only messages |
|
|
22
|
+
| `transform_context` | before every request | **compaction, at Tier 3** |
|
|
23
|
+
| `get_steering_messages` | between turns | text typed while it worked |
|
|
24
|
+
| `get_follow_up_messages` | between turns | the next queued task |
|
|
25
|
+
|
|
26
|
+
**`get_api_key` is deliberately not here.** Pi makes it a loop hook; omega makes
|
|
27
|
+
it a resolver passed to the provider instead, because the loop has no business
|
|
28
|
+
knowing that credentials exist. Same capability — a token can still refresh
|
|
29
|
+
mid-session — one layer lower, where auth belongs.
|
|
30
|
+
|
|
31
|
+
The two remaining Pi hooks, `should_stop_after_turn` and `prepare_next_turn`,
|
|
32
|
+
are not needed yet. Adding them is adding a field to this dataclass.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
from collections.abc import Awaitable, Callable
|
|
38
|
+
from dataclasses import dataclass
|
|
39
|
+
|
|
40
|
+
from omega_agent.tools import ToolResult
|
|
41
|
+
from omega_agent.types import AgentMessage, ToolCall
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True, slots=True)
|
|
45
|
+
class ToolCallDecision:
|
|
46
|
+
"""The gate's answer. A denial must carry a reason.
|
|
47
|
+
|
|
48
|
+
The reason is not decoration: it becomes the tool result the model reads, so
|
|
49
|
+
"denied by policy: writes outside the project are refused" lets the model
|
|
50
|
+
adapt, where a bare refusal makes it retry the same thing.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
allowed: bool
|
|
54
|
+
reason: str | None = None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
#: The permissive answer, so a caller that only blocks some calls can return a
|
|
58
|
+
#: shared constant for the rest.
|
|
59
|
+
ALLOW = ToolCallDecision(allowed=True)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
#: Consulted before a tool runs. Denying it still produces a tool result — an
|
|
63
|
+
#: unanswered tool call is a permanent API error, so refusal cannot be silence.
|
|
64
|
+
BeforeToolCall = Callable[[ToolCall], Awaitable[ToolCallDecision]]
|
|
65
|
+
|
|
66
|
+
#: Consulted after a tool returns, and may rewrite the result. Truncation and
|
|
67
|
+
#: redaction live here: the model reads the returned value, not the original.
|
|
68
|
+
AfterToolCall = Callable[[ToolCall, ToolResult], Awaitable[ToolResult]]
|
|
69
|
+
|
|
70
|
+
#: Rewrites the conversation on its way to the provider. Takes what the harness
|
|
71
|
+
#: holds, returns what gets sent — **the harness's own transcript is untouched.**
|
|
72
|
+
#: That is the "two views of history" split, and it is what stops a failed turn
|
|
73
|
+
#: from poisoning later requests.
|
|
74
|
+
ContextTransform = Callable[[list[AgentMessage]], Awaitable[list[AgentMessage]]]
|
|
75
|
+
|
|
76
|
+
#: Supplies messages between turns. Returning an empty list means "nothing to add".
|
|
77
|
+
MessageSource = Callable[[], Awaitable[list[AgentMessage]]]
|
|
78
|
+
|
|
79
|
+
#: Consulted once for each message as it is **recorded** — whatever produced it,
|
|
80
|
+
#: and before it reaches disk or the next request. Returns the message to keep.
|
|
81
|
+
#:
|
|
82
|
+
#: The distinction from `after_tool_call` is the whole reason this exists.
|
|
83
|
+
#: That one fires when a *tool* hands back a value, which covers tool output and
|
|
84
|
+
#: nothing else: a credential in the model's own answer, or in the user's own
|
|
85
|
+
#: prompt, went to the transcript unmasked and back to the provider on every
|
|
86
|
+
#: later turn. Two earlier bypasses were patched individually before it became
|
|
87
|
+
#: clear the attachment point was wrong rather than incomplete.
|
|
88
|
+
#:
|
|
89
|
+
#: Consulted by the harness, not the loop — the harness owns the transcript, so
|
|
90
|
+
#: it is the one place every writer passes through. `loop.py` gains nothing.
|
|
91
|
+
RecordMessage = Callable[[AgentMessage], Awaitable[AgentMessage]]
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@dataclass(frozen=True, slots=True)
|
|
95
|
+
class AgentHooks:
|
|
96
|
+
"""The bundle. Every field optional; the empty bundle changes nothing.
|
|
97
|
+
|
|
98
|
+
Frozen, because the loop reads these while running and a hook set that could
|
|
99
|
+
change mid-turn would make behaviour depend on timing.
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
before_tool_call: BeforeToolCall | None = None
|
|
103
|
+
after_tool_call: AfterToolCall | None = None
|
|
104
|
+
# convert_to_llm runs first, then transform_context: normalise, then compact.
|
|
105
|
+
# Order recorded here because nothing in the type system enforces it.
|
|
106
|
+
convert_to_llm: ContextTransform | None = None
|
|
107
|
+
transform_context: ContextTransform | None = None
|
|
108
|
+
get_steering_messages: MessageSource | None = None
|
|
109
|
+
get_follow_up_messages: MessageSource | None = None
|
|
110
|
+
# Fires as a message is recorded, before persistence and before the next
|
|
111
|
+
# request. Redaction fills it; a transcript logger would fill it too.
|
|
112
|
+
before_record: RecordMessage | None = None
|