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.
Files changed (67) hide show
  1. omega_agent/__init__.py +19 -0
  2. omega_agent/agent_events.py +166 -0
  3. omega_agent/cancellation.py +52 -0
  4. omega_agent/events.py +157 -0
  5. omega_agent/harness.py +497 -0
  6. omega_agent/hooks.py +112 -0
  7. omega_agent/loop.py +190 -0
  8. omega_agent/provider.py +56 -0
  9. omega_agent/session/__init__.py +34 -0
  10. omega_agent/session/entries.py +48 -0
  11. omega_agent/session/jsonl.py +99 -0
  12. omega_agent/session/store.py +310 -0
  13. omega_agent/session/tree.py +101 -0
  14. omega_agent/tool_runner.py +116 -0
  15. omega_agent/tools.py +97 -0
  16. omega_agent/types.py +222 -0
  17. omega_ai/__init__.py +14 -0
  18. omega_ai/anthropic.py +709 -0
  19. omega_ai/fake.py +184 -0
  20. omega_ai/openai.py +580 -0
  21. omega_ai/openai_codex.py +720 -0
  22. omega_ai/provider.py +22 -0
  23. omega_ai/retry.py +146 -0
  24. omega_coding/__init__.py +18 -0
  25. omega_coding/approval.py +374 -0
  26. omega_coding/auth.py +384 -0
  27. omega_coding/builtin_tools.py +812 -0
  28. omega_coding/cli.py +1001 -0
  29. omega_coding/clipboard.py +224 -0
  30. omega_coding/commands.py +904 -0
  31. omega_coding/compact.py +377 -0
  32. omega_coding/context.py +187 -0
  33. omega_coding/cost.py +123 -0
  34. omega_coding/env.py +86 -0
  35. omega_coding/evals.py +137 -0
  36. omega_coding/eventlog.py +160 -0
  37. omega_coding/file_lock.py +57 -0
  38. omega_coding/headless.py +125 -0
  39. omega_coding/history.py +42 -0
  40. omega_coding/models.py +560 -0
  41. omega_coding/oauth.py +519 -0
  42. omega_coding/paths.py +159 -0
  43. omega_coding/redact.py +194 -0
  44. omega_coding/status.py +316 -0
  45. omega_coding/subagent.py +185 -0
  46. omega_coding/system_prompt.py +125 -0
  47. omega_coding/truncate.py +175 -0
  48. omega_coding/tui/__init__.py +13 -0
  49. omega_coding/tui/adapter.py +130 -0
  50. omega_coding/tui/app.py +1111 -0
  51. omega_coding/tui/approval.py +146 -0
  52. omega_coding/tui/banner.py +272 -0
  53. omega_coding/tui/config.py +81 -0
  54. omega_coding/tui/login.py +161 -0
  55. omega_coding/tui/state.py +299 -0
  56. omega_coding/tui/themes/__init__.py +267 -0
  57. omega_coding/tui/themes/high-contrast.json +33 -0
  58. omega_coding/tui/themes/oxblood-dark.json +33 -0
  59. omega_coding/tui/themes/oxblood-light.json +33 -0
  60. omega_coding/tui/themes/slate.json +33 -0
  61. omega_coding/tui/widgets.py +766 -0
  62. omega_coding/version.py +42 -0
  63. omega_coding_agent-0.1.0.dist-info/METADATA +447 -0
  64. omega_coding_agent-0.1.0.dist-info/RECORD +67 -0
  65. omega_coding_agent-0.1.0.dist-info/WHEEL +4 -0
  66. omega_coding_agent-0.1.0.dist-info/entry_points.txt +2 -0
  67. 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