optio-agents 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.
- optio_agents/__init__.py +45 -0
- optio_agents/browser_capture.py +53 -0
- optio_agents/context.py +244 -0
- optio_agents/protocol/__init__.py +49 -0
- optio_agents/protocol/parser.py +169 -0
- optio_agents/protocol/prompt.py +54 -0
- optio_agents/protocol/session.py +330 -0
- optio_agents-0.1.0.dist-info/METADATA +55 -0
- optio_agents-0.1.0.dist-info/RECORD +11 -0
- optio_agents-0.1.0.dist-info/WHEEL +5 -0
- optio_agents-0.1.0.dist-info/top_level.txt +1 -0
optio_agents/__init__.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""optio-agents — agent-coordination layer for optio task types.
|
|
2
|
+
|
|
3
|
+
Owns the optio.log keyword protocol (parser + session driver), the
|
|
4
|
+
``HookContext`` passed to agent task hooks, and the single source of
|
|
5
|
+
truth for the LLM-facing keyword-protocol documentation.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from optio_agents.context import HookContext, HookContextProtocol
|
|
9
|
+
from optio_agents.protocol import (
|
|
10
|
+
DELIVERABLE_QUEUE_BOUND,
|
|
11
|
+
DeliverableCallback,
|
|
12
|
+
DeliverableEvent,
|
|
13
|
+
DoneEvent,
|
|
14
|
+
ErrorEvent,
|
|
15
|
+
HookCallback,
|
|
16
|
+
LogEvent,
|
|
17
|
+
StatusEvent,
|
|
18
|
+
UnknownLine,
|
|
19
|
+
fetch_deliverable_text,
|
|
20
|
+
parse_log_line,
|
|
21
|
+
relativize_deliverable_path,
|
|
22
|
+
run_log_protocol_session,
|
|
23
|
+
validate_deliverable_path,
|
|
24
|
+
)
|
|
25
|
+
from optio_agents import browser_capture
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"HookContext",
|
|
29
|
+
"browser_capture",
|
|
30
|
+
"HookContextProtocol",
|
|
31
|
+
"run_log_protocol_session",
|
|
32
|
+
"fetch_deliverable_text",
|
|
33
|
+
"DeliverableCallback",
|
|
34
|
+
"HookCallback",
|
|
35
|
+
"DELIVERABLE_QUEUE_BOUND",
|
|
36
|
+
"parse_log_line",
|
|
37
|
+
"DeliverableEvent",
|
|
38
|
+
"DoneEvent",
|
|
39
|
+
"ErrorEvent",
|
|
40
|
+
"LogEvent",
|
|
41
|
+
"StatusEvent",
|
|
42
|
+
"UnknownLine",
|
|
43
|
+
"validate_deliverable_path",
|
|
44
|
+
"relativize_deliverable_path",
|
|
45
|
+
]
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Opt-in browser-open capture shims for the agent launch environment.
|
|
2
|
+
|
|
3
|
+
`enable(host)` writes capture-only shims for the common browser-opener
|
|
4
|
+
commands (xdg-open, gio, open, sensible-browser, www-browser) under
|
|
5
|
+
``<workdir>/bin``. Each shim appends a ``BROWSER: "<url>"`` line to
|
|
6
|
+
``<workdir>/optio.log`` and exits 0 — it never launches a real browser
|
|
7
|
+
(there is none on the worker). It returns env additions to merge into
|
|
8
|
+
the agent launch env: ``BROWSER`` pointing at the shim and a
|
|
9
|
+
``<workdir>/bin`` PATH prepend.
|
|
10
|
+
|
|
11
|
+
Opt-in (default off) so it never collides with opencode's own browser
|
|
12
|
+
*suppression* shims (the two shim sets are never enabled together).
|
|
13
|
+
|
|
14
|
+
Mirrors the opencode suppression-shim pattern in
|
|
15
|
+
``optio_opencode.host_actions`` but captures instead of suppressing.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import os
|
|
21
|
+
|
|
22
|
+
from optio_host.host import Host
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
_SHIM_NAMES = ("xdg-open", "gio", "open", "sensible-browser", "www-browser")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
async def enable(host: Host) -> dict[str, str]:
|
|
29
|
+
"""Write the capture shims under ``<workdir>/bin`` and return env additions.
|
|
30
|
+
|
|
31
|
+
Returns a dict with ``BROWSER`` and ``PATH`` keys to merge into the
|
|
32
|
+
agent launch env (``PATH`` prepends ``<workdir>/bin``).
|
|
33
|
+
"""
|
|
34
|
+
# The shim appends `BROWSER: "<first-arg>"` to optio.log and exits 0.
|
|
35
|
+
# $1 is the URL the opener was invoked with. Quote it so the captured
|
|
36
|
+
# marker is unambiguous even if the URL contains spaces.
|
|
37
|
+
shim_body = (
|
|
38
|
+
"#!/bin/sh\n"
|
|
39
|
+
f'printf \'BROWSER: "%s"\\n\' "$1" >> {host.workdir}/optio.log\n'
|
|
40
|
+
"exit 0\n"
|
|
41
|
+
)
|
|
42
|
+
for name in _SHIM_NAMES:
|
|
43
|
+
await host.write_text(f"bin/{name}", shim_body)
|
|
44
|
+
await host.run_command(f"chmod +x {host.workdir}/bin/*")
|
|
45
|
+
|
|
46
|
+
workdir_bin = f"{host.workdir}/bin"
|
|
47
|
+
extra_path = workdir_bin + ":" + os.environ.get(
|
|
48
|
+
"PATH", "/usr/local/bin:/usr/bin:/bin",
|
|
49
|
+
)
|
|
50
|
+
return {
|
|
51
|
+
"BROWSER": f"{workdir_bin}/xdg-open",
|
|
52
|
+
"PATH": extra_path,
|
|
53
|
+
}
|
optio_agents/context.py
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
"""HookContext: ProcessContext + host primitives for task-body hooks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from typing import Any, AsyncIterator, Awaitable, Callable, Protocol
|
|
7
|
+
|
|
8
|
+
# Imported at module top so tests can monkeypatch this name on the module.
|
|
9
|
+
from optio_host.download import create_download_task
|
|
10
|
+
from optio_host.host import HostCommandError, RunResult
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class HookContext:
|
|
14
|
+
"""ProcessContext + host primitives, passed to before/after_execute hooks
|
|
15
|
+
and to on_deliverable callbacks.
|
|
16
|
+
|
|
17
|
+
Attributes not defined on this class fall through to the wrapped
|
|
18
|
+
ProcessContext via __getattr__, so consumers can call e.g.
|
|
19
|
+
``hook_ctx.report_progress(...)`` directly.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(self, ctx, host) -> None:
|
|
23
|
+
# Use object.__setattr__ to avoid __getattr__ recursion in __init__.
|
|
24
|
+
object.__setattr__(self, "_ctx", ctx)
|
|
25
|
+
object.__setattr__(self, "_host", host)
|
|
26
|
+
|
|
27
|
+
def __getattr__(self, name: str) -> Any:
|
|
28
|
+
# Only called if the attribute isn't found on the instance / class.
|
|
29
|
+
return getattr(self._ctx, name)
|
|
30
|
+
|
|
31
|
+
async def run_on_host(
|
|
32
|
+
self,
|
|
33
|
+
command: str,
|
|
34
|
+
*,
|
|
35
|
+
check: bool = True,
|
|
36
|
+
capture_stderr: bool = False,
|
|
37
|
+
cwd: str | None = None,
|
|
38
|
+
):
|
|
39
|
+
result = await self._host.run_command(command, cwd=cwd)
|
|
40
|
+
if check:
|
|
41
|
+
if result.exit_code != 0:
|
|
42
|
+
raise HostCommandError(
|
|
43
|
+
command=command,
|
|
44
|
+
exit_code=result.exit_code,
|
|
45
|
+
stdout=result.stdout,
|
|
46
|
+
stderr=result.stderr,
|
|
47
|
+
)
|
|
48
|
+
return result.stdout + (result.stderr if capture_stderr else "")
|
|
49
|
+
return result
|
|
50
|
+
|
|
51
|
+
async def copy_file(
|
|
52
|
+
self,
|
|
53
|
+
source,
|
|
54
|
+
target: str,
|
|
55
|
+
*,
|
|
56
|
+
skip_if_unchanged: bool = False,
|
|
57
|
+
) -> None:
|
|
58
|
+
from bson import ObjectId # local import to avoid a hard top-level dep
|
|
59
|
+
|
|
60
|
+
host_home = await self._host.resolve_host_home()
|
|
61
|
+
abs_target = _resolve_target_path(target, self._host.workdir, host_home)
|
|
62
|
+
basename = os.path.basename(abs_target) or abs_target
|
|
63
|
+
ctx = self._ctx
|
|
64
|
+
skipped = False
|
|
65
|
+
|
|
66
|
+
def _progress_cb(percent, message):
|
|
67
|
+
nonlocal skipped
|
|
68
|
+
if message == "already up to date":
|
|
69
|
+
skipped = True
|
|
70
|
+
ctx.report_progress(None, f"Already up to date: {basename}")
|
|
71
|
+
elif percent is not None:
|
|
72
|
+
ctx.report_progress(percent, None)
|
|
73
|
+
|
|
74
|
+
# Resolve blob sources to (iterator, expected_sha) pair.
|
|
75
|
+
expected_sha: str | None = None
|
|
76
|
+
if isinstance(source, ObjectId):
|
|
77
|
+
payload = await self._read_blob_bytes(source)
|
|
78
|
+
if skip_if_unchanged:
|
|
79
|
+
import hashlib
|
|
80
|
+
expected_sha = hashlib.sha256(payload).hexdigest()
|
|
81
|
+
|
|
82
|
+
async def _gen():
|
|
83
|
+
# Yield the payload as one (or a few) chunks. For very large
|
|
84
|
+
# blobs we could stream via load_blob, but reading-then-iterating
|
|
85
|
+
# is simpler and correct.
|
|
86
|
+
yield payload
|
|
87
|
+
|
|
88
|
+
source = _gen()
|
|
89
|
+
|
|
90
|
+
if skip_if_unchanged:
|
|
91
|
+
ctx.report_progress(None, f"Verifying {basename}...")
|
|
92
|
+
else:
|
|
93
|
+
ctx.report_progress(None, f"Copying {basename}...")
|
|
94
|
+
|
|
95
|
+
await self._host.put_file_to_host(
|
|
96
|
+
source,
|
|
97
|
+
abs_target,
|
|
98
|
+
expected_sha256=expected_sha,
|
|
99
|
+
skip_if_unchanged=skip_if_unchanged,
|
|
100
|
+
progress_cb=_progress_cb,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
if skip_if_unchanged and not skipped:
|
|
104
|
+
ctx.report_progress(None, f"Copying {basename}...")
|
|
105
|
+
|
|
106
|
+
async def _read_blob_bytes(self, file_id) -> bytes:
|
|
107
|
+
async with self._ctx.load_blob(file_id) as reader:
|
|
108
|
+
return await reader.read()
|
|
109
|
+
|
|
110
|
+
async def read_from_host(self, path: str, *, silent: bool = False) -> bytes:
|
|
111
|
+
host_home = await self._host.resolve_host_home()
|
|
112
|
+
abs_path = _resolve_target_path(path, self._host.workdir, host_home)
|
|
113
|
+
if not silent:
|
|
114
|
+
basename = os.path.basename(abs_path) or abs_path
|
|
115
|
+
self._ctx.report_progress(None, f"Reading {basename}...")
|
|
116
|
+
|
|
117
|
+
def _progress_cb(percent, message):
|
|
118
|
+
if percent is not None:
|
|
119
|
+
self._ctx.report_progress(percent, None)
|
|
120
|
+
|
|
121
|
+
return await self._host.fetch_bytes_from_host(
|
|
122
|
+
abs_path, progress_cb=_progress_cb,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
async def read_text_from_host(self, path: str, *, silent: bool = False) -> str:
|
|
126
|
+
data = await self.read_from_host(path, silent=silent)
|
|
127
|
+
return data.decode("utf-8")
|
|
128
|
+
|
|
129
|
+
async def download_file(
|
|
130
|
+
self,
|
|
131
|
+
url: str,
|
|
132
|
+
target: str,
|
|
133
|
+
*,
|
|
134
|
+
description: str | None = None,
|
|
135
|
+
cleanup_on_fail: bool = True,
|
|
136
|
+
) -> None:
|
|
137
|
+
"""Download ``url`` to ``target`` on the host as a child task.
|
|
138
|
+
|
|
139
|
+
Spawns a child process under the current task that runs curl on the
|
|
140
|
+
same host the parent runs on. Reports a single "Downloading
|
|
141
|
+
<basename>" message followed by numeric progress percent updates on
|
|
142
|
+
the child's ProcessContext.
|
|
143
|
+
|
|
144
|
+
``target`` is resolved by the same rules as ``copy_file``: absolute
|
|
145
|
+
path | ``~`` / ``~/...`` home-relative | workdir-relative. On a
|
|
146
|
+
workdir-escape attempt this raises ``ValueError`` without spawning
|
|
147
|
+
a child.
|
|
148
|
+
|
|
149
|
+
Returns None on success. Raises
|
|
150
|
+
``optio_core.exceptions.ChildProcessFailed`` if the child fails;
|
|
151
|
+
the original ``DownloadFailed`` is preserved via ``__cause__``
|
|
152
|
+
(and on the child's ``ChildResult.original_exception`` when caller
|
|
153
|
+
uses ``parallel_group``). Parent-task cancellation propagates to
|
|
154
|
+
the child automatically.
|
|
155
|
+
"""
|
|
156
|
+
host_home = await self._host.resolve_host_home()
|
|
157
|
+
abs_target = _resolve_target_path(target, self._host.workdir, host_home)
|
|
158
|
+
basename = os.path.basename(abs_target) or abs_target
|
|
159
|
+
|
|
160
|
+
n = self._ctx._child_counter.get("next", 0)
|
|
161
|
+
child_process_id = f"{self._ctx.process_id}.download-{n}"
|
|
162
|
+
child_name = f"download {basename}"
|
|
163
|
+
|
|
164
|
+
task = create_download_task(
|
|
165
|
+
process_id=child_process_id,
|
|
166
|
+
name=child_name,
|
|
167
|
+
url=url,
|
|
168
|
+
target=abs_target,
|
|
169
|
+
host=self._host,
|
|
170
|
+
description=description,
|
|
171
|
+
cleanup_on_fail=cleanup_on_fail,
|
|
172
|
+
)
|
|
173
|
+
await self._ctx.run_child_task(task)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class HookContextProtocol(Protocol):
|
|
177
|
+
"""Type-hint surface for hook authors who want IDE discoverability.
|
|
178
|
+
|
|
179
|
+
Subset of ProcessContext + the four new methods.
|
|
180
|
+
"""
|
|
181
|
+
|
|
182
|
+
process_id: str
|
|
183
|
+
|
|
184
|
+
@property
|
|
185
|
+
def params(self) -> dict: ...
|
|
186
|
+
|
|
187
|
+
@property
|
|
188
|
+
def metadata(self) -> dict: ...
|
|
189
|
+
|
|
190
|
+
def report_progress(self, percent: float | None, message: str | None = None) -> None: ...
|
|
191
|
+
def should_continue(self) -> bool: ...
|
|
192
|
+
async def copy_file(
|
|
193
|
+
self,
|
|
194
|
+
source,
|
|
195
|
+
target: str,
|
|
196
|
+
*,
|
|
197
|
+
skip_if_unchanged: bool = False,
|
|
198
|
+
) -> None: ...
|
|
199
|
+
async def run_on_host(
|
|
200
|
+
self,
|
|
201
|
+
command: str,
|
|
202
|
+
*,
|
|
203
|
+
check: bool = True,
|
|
204
|
+
capture_stderr: bool = False,
|
|
205
|
+
cwd: str | None = None,
|
|
206
|
+
) -> "str | RunResult": ...
|
|
207
|
+
async def read_from_host(self, path: str, *, silent: bool = False) -> bytes: ...
|
|
208
|
+
async def read_text_from_host(self, path: str, *, silent: bool = False) -> str: ...
|
|
209
|
+
async def download_file(
|
|
210
|
+
self,
|
|
211
|
+
url: str,
|
|
212
|
+
target: str,
|
|
213
|
+
*,
|
|
214
|
+
description: str | None = None,
|
|
215
|
+
cleanup_on_fail: bool = True,
|
|
216
|
+
) -> None: ...
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _resolve_target_path(path: str, workdir: str, host_home: str) -> str:
|
|
220
|
+
"""Resolve a user-supplied path to an absolute host path.
|
|
221
|
+
|
|
222
|
+
Three forms:
|
|
223
|
+
- starts with `/` → absolute, used as-is
|
|
224
|
+
- `~` or starts with `~/` → home-relative, expand once
|
|
225
|
+
- otherwise → workdir-relative; reject `..` and any escape
|
|
226
|
+
|
|
227
|
+
workdir and host_home must both be absolute paths.
|
|
228
|
+
"""
|
|
229
|
+
if not path:
|
|
230
|
+
raise ValueError("path must not be empty")
|
|
231
|
+
if path.startswith("/"):
|
|
232
|
+
return path
|
|
233
|
+
if path == "~":
|
|
234
|
+
return host_home
|
|
235
|
+
if path.startswith("~/"):
|
|
236
|
+
return host_home + "/" + path[2:]
|
|
237
|
+
# workdir-relative
|
|
238
|
+
if ".." in path.split("/"):
|
|
239
|
+
raise ValueError(f"workdir-relative path may not contain '..': {path!r}")
|
|
240
|
+
resolved = os.path.normpath(os.path.join(workdir, path))
|
|
241
|
+
workdir_norm = os.path.normpath(workdir).rstrip("/")
|
|
242
|
+
if resolved != workdir_norm and not resolved.startswith(workdir_norm + "/"):
|
|
243
|
+
raise ValueError(f"workdir-relative path escapes workdir: {path!r}")
|
|
244
|
+
return resolved
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""optio-agents log/deliverables coordination protocol.
|
|
2
|
+
|
|
3
|
+
Built on top of optio_host.host primitives. Knows nothing about specific
|
|
4
|
+
consumer task types (opencode, recipe execution, ...).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from optio_agents.protocol.parser import (
|
|
8
|
+
AttentionEvent,
|
|
9
|
+
BrowserEvent,
|
|
10
|
+
DeliverableEvent,
|
|
11
|
+
DomainMessageEvent,
|
|
12
|
+
DoneEvent,
|
|
13
|
+
ErrorEvent,
|
|
14
|
+
LogEvent,
|
|
15
|
+
StatusEvent,
|
|
16
|
+
UnknownLine,
|
|
17
|
+
parse_log_line,
|
|
18
|
+
relativize_deliverable_path,
|
|
19
|
+
validate_deliverable_path,
|
|
20
|
+
)
|
|
21
|
+
from optio_agents.protocol.session import (
|
|
22
|
+
DELIVERABLE_QUEUE_BOUND,
|
|
23
|
+
DeliverableCallback,
|
|
24
|
+
HookCallback,
|
|
25
|
+
fetch_deliverable_text,
|
|
26
|
+
run_log_protocol_session,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
# parser
|
|
31
|
+
"parse_log_line",
|
|
32
|
+
"DeliverableEvent",
|
|
33
|
+
"DoneEvent",
|
|
34
|
+
"ErrorEvent",
|
|
35
|
+
"BrowserEvent",
|
|
36
|
+
"AttentionEvent",
|
|
37
|
+
"DomainMessageEvent",
|
|
38
|
+
"LogEvent",
|
|
39
|
+
"StatusEvent",
|
|
40
|
+
"UnknownLine",
|
|
41
|
+
"validate_deliverable_path",
|
|
42
|
+
"relativize_deliverable_path",
|
|
43
|
+
# session
|
|
44
|
+
"run_log_protocol_session",
|
|
45
|
+
"DeliverableCallback",
|
|
46
|
+
"HookCallback",
|
|
47
|
+
"fetch_deliverable_text",
|
|
48
|
+
"DELIVERABLE_QUEUE_BOUND",
|
|
49
|
+
]
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""Parse lines from the optio.log file that the on-host agent appends to.
|
|
2
|
+
|
|
3
|
+
The format is keyword-prefixed, one line per event. See the design spec
|
|
4
|
+
Section 6 of the optio-opencode design (the protocol's original home;
|
|
5
|
+
unchanged after the optio-host split).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from typing import Union
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class StatusEvent:
|
|
19
|
+
percent: int | None
|
|
20
|
+
message: str
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class DeliverableEvent:
|
|
25
|
+
path: str
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class DoneEvent:
|
|
30
|
+
summary: str | None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class ErrorEvent:
|
|
35
|
+
message: str | None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class BrowserEvent:
|
|
40
|
+
url: str
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class AttentionEvent:
|
|
45
|
+
reason: str
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True)
|
|
49
|
+
class DomainMessageEvent:
|
|
50
|
+
keyword: str
|
|
51
|
+
data: object
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True)
|
|
55
|
+
class UnknownLine:
|
|
56
|
+
text: str
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
LogEvent = Union[
|
|
60
|
+
StatusEvent, DeliverableEvent, DoneEvent, ErrorEvent,
|
|
61
|
+
BrowserEvent, AttentionEvent, DomainMessageEvent, UnknownLine,
|
|
62
|
+
]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
_RE_STATUS = re.compile(r"^STATUS:\s*(?:(\d{1,3})%\s+)?(.*)$")
|
|
66
|
+
_RE_DELIVERABLE = re.compile(r"^DELIVERABLE:\s*(.+?)\s*$")
|
|
67
|
+
_RE_DONE = re.compile(r"^DONE(?::\s*(.*))?\s*$")
|
|
68
|
+
_RE_ERROR = re.compile(r"^ERROR(?::\s*(.*))?\s*$")
|
|
69
|
+
_RE_BROWSER = re.compile(r"^BROWSER:\s*(.+?)\s*$")
|
|
70
|
+
_RE_ATTENTION = re.compile(r"^ATTENTION:\s*(.+?)\s*$")
|
|
71
|
+
_RE_DOMAIN_MESSAGE = re.compile(r"^DOMAIN_MESSAGE:\s*(\S+)\s+(.*)$")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def parse_log_line(line: str) -> LogEvent:
|
|
75
|
+
"""Classify one line from optio.log into a LogEvent."""
|
|
76
|
+
stripped = line.rstrip("\r\n").rstrip()
|
|
77
|
+
m = _RE_STATUS.match(stripped)
|
|
78
|
+
if m:
|
|
79
|
+
pct_raw, msg = m.group(1), m.group(2)
|
|
80
|
+
percent: int | None
|
|
81
|
+
if pct_raw is None:
|
|
82
|
+
percent = None
|
|
83
|
+
else:
|
|
84
|
+
percent = min(int(pct_raw), 100)
|
|
85
|
+
return StatusEvent(percent=percent, message=msg)
|
|
86
|
+
|
|
87
|
+
m = _RE_DELIVERABLE.match(stripped)
|
|
88
|
+
if m:
|
|
89
|
+
return DeliverableEvent(path=m.group(1))
|
|
90
|
+
|
|
91
|
+
m = _RE_DONE.match(stripped)
|
|
92
|
+
if m:
|
|
93
|
+
summary = m.group(1) if m.group(1) else None
|
|
94
|
+
return DoneEvent(summary=summary)
|
|
95
|
+
|
|
96
|
+
m = _RE_ERROR.match(stripped)
|
|
97
|
+
if m:
|
|
98
|
+
msg = m.group(1) if m.group(1) else None
|
|
99
|
+
return ErrorEvent(message=msg)
|
|
100
|
+
|
|
101
|
+
m = _RE_BROWSER.match(stripped)
|
|
102
|
+
if m:
|
|
103
|
+
return BrowserEvent(url=m.group(1))
|
|
104
|
+
|
|
105
|
+
m = _RE_ATTENTION.match(stripped)
|
|
106
|
+
if m:
|
|
107
|
+
return AttentionEvent(reason=m.group(1))
|
|
108
|
+
|
|
109
|
+
m = _RE_DOMAIN_MESSAGE.match(stripped)
|
|
110
|
+
if m:
|
|
111
|
+
keyword, payload = m.group(1), m.group(2)
|
|
112
|
+
try:
|
|
113
|
+
data = json.loads(payload)
|
|
114
|
+
except (ValueError, json.JSONDecodeError):
|
|
115
|
+
# Malformed JSON: drop (not dispatched). Surfaced as UnknownLine
|
|
116
|
+
# so the tail loop logs the raw line for diagnosis.
|
|
117
|
+
return UnknownLine(text=stripped)
|
|
118
|
+
return DomainMessageEvent(keyword=keyword, data=data)
|
|
119
|
+
|
|
120
|
+
return UnknownLine(text=stripped)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def validate_deliverable_path(path: str, workdir: str) -> str:
|
|
124
|
+
"""Resolve ``path`` against ``workdir`` and ensure it stays inside.
|
|
125
|
+
|
|
126
|
+
Returns the absolute, normalized path. Raises ValueError on escape.
|
|
127
|
+
"""
|
|
128
|
+
workdir_abs = os.path.realpath(workdir)
|
|
129
|
+
if os.path.isabs(path):
|
|
130
|
+
candidate = os.path.realpath(path)
|
|
131
|
+
else:
|
|
132
|
+
candidate = os.path.realpath(os.path.join(workdir_abs, path))
|
|
133
|
+
|
|
134
|
+
# Ensure the resolved path is inside workdir_abs.
|
|
135
|
+
rel = os.path.relpath(candidate, workdir_abs)
|
|
136
|
+
if rel == ".." or rel.startswith(".." + os.sep):
|
|
137
|
+
raise ValueError(
|
|
138
|
+
f"deliverable path escapes workdir: {path!r} (resolved to {candidate!r}, "
|
|
139
|
+
f"workdir={workdir_abs!r})"
|
|
140
|
+
)
|
|
141
|
+
return candidate
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
DELIVERABLES_SUBDIR = "deliverables"
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def relativize_deliverable_path(absolute_path: str, workdir: str) -> str:
|
|
148
|
+
"""Return ``absolute_path`` made relative to ``<workdir>/deliverables/``.
|
|
149
|
+
|
|
150
|
+
Precondition: ``absolute_path`` has already been validated to be
|
|
151
|
+
inside ``workdir`` (via :func:`validate_deliverable_path`). Both
|
|
152
|
+
arguments may be any absolute paths; this function realpaths them
|
|
153
|
+
internally before relativizing.
|
|
154
|
+
|
|
155
|
+
Raises ``ValueError`` if ``absolute_path`` is not strictly under
|
|
156
|
+
``<workdir>/deliverables/`` (including when it equals the
|
|
157
|
+
deliverables root itself or escapes outside it).
|
|
158
|
+
"""
|
|
159
|
+
deliverables_root = os.path.realpath(
|
|
160
|
+
os.path.join(workdir, DELIVERABLES_SUBDIR)
|
|
161
|
+
)
|
|
162
|
+
target = os.path.realpath(absolute_path)
|
|
163
|
+
rel = os.path.relpath(target, deliverables_root)
|
|
164
|
+
if rel == "." or rel == ".." or rel.startswith(".." + os.sep):
|
|
165
|
+
raise ValueError(
|
|
166
|
+
f"deliverable path is not under <workdir>/{DELIVERABLES_SUBDIR}/: "
|
|
167
|
+
f"{absolute_path!r} (workdir={workdir!r})"
|
|
168
|
+
)
|
|
169
|
+
return rel
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Single source of truth for the LLM-facing keyword-protocol documentation.
|
|
2
|
+
|
|
3
|
+
This module lives next to ``parser.py`` so the prose that teaches an agent
|
|
4
|
+
how to speak the protocol cannot drift from the regexes that enforce it.
|
|
5
|
+
Consumers (e.g. optio-opencode) compose ``LOG_CHANNEL_PROMPT`` into their
|
|
6
|
+
own AGENTS.md framing.
|
|
7
|
+
|
|
8
|
+
Documents the keywords parsed by ``optio_agents.protocol.parser``:
|
|
9
|
+
``STATUS:`` / ``DELIVERABLE:`` / ``DONE`` / ``ERROR`` / ``BROWSER:`` /
|
|
10
|
+
``ATTENTION:`` / ``DOMAIN_MESSAGE:``.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
LOG_CHANNEL_PROMPT = """## Log channel
|
|
15
|
+
|
|
16
|
+
Append one line per entry to `./optio.log` in this directory. Each line
|
|
17
|
+
must start with one of:
|
|
18
|
+
|
|
19
|
+
- `STATUS:` — progress update for the human. Optional leading percent,
|
|
20
|
+
e.g. `STATUS: 50% counting my fingers`.
|
|
21
|
+
- `DELIVERABLE:` — absolute or workdir-relative path to a file you've
|
|
22
|
+
just produced, e.g. `DELIVERABLE: ./deliverables/summary.md`.
|
|
23
|
+
- `DONE` — you have finished the task. May be followed by an optional
|
|
24
|
+
summary on the same line: `DONE: wrote the report`.
|
|
25
|
+
- `ERROR` — you cannot continue. May be followed by an optional
|
|
26
|
+
message: `ERROR: provider auth failed`.
|
|
27
|
+
- `BROWSER:` — ask the operator's browser to open a URL, e.g.
|
|
28
|
+
`BROWSER: https://example.com/login`. Use for flows that require the
|
|
29
|
+
human to visit a page (e.g. an auth/login URL).
|
|
30
|
+
- `ATTENTION:` — request human attention with a short reason, e.g.
|
|
31
|
+
`ATTENTION: waiting for your approval`.
|
|
32
|
+
- `DOMAIN_MESSAGE:` — push an application-specific message: a keyword
|
|
33
|
+
token followed by single-line JSON, e.g.
|
|
34
|
+
`DOMAIN_MESSAGE: build-finished {"artifact":"app.zip"}`. The JSON must
|
|
35
|
+
be valid and on one line; malformed JSON is dropped.
|
|
36
|
+
|
|
37
|
+
**Every entry must end with a newline character (`\\n`).** The host
|
|
38
|
+
reads `optio.log` with a line-oriented tailer that only emits a line
|
|
39
|
+
once it sees `\\n`; an entry written without a trailing newline (e.g.
|
|
40
|
+
via `printf 'DONE'`) will be buffered indefinitely and never reach the
|
|
41
|
+
host. Use `echo`, `>>` redirection of a heredoc, or any other mechanism
|
|
42
|
+
that guarantees a trailing newline. If unsure, double-check with
|
|
43
|
+
`tail -c 1 ./optio.log` — the result must be a newline.
|
|
44
|
+
|
|
45
|
+
After writing `DONE` or `ERROR`, the session will terminate. Do not
|
|
46
|
+
write further lines.
|
|
47
|
+
|
|
48
|
+
## Deliverables
|
|
49
|
+
|
|
50
|
+
Place files you want to hand back to the host under `./deliverables/`.
|
|
51
|
+
For each file, write a `DELIVERABLE:` log line *after* the file exists
|
|
52
|
+
and its contents are final. The host fetches files by reading these
|
|
53
|
+
log lines.
|
|
54
|
+
"""
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
"""Generic log/deliverables session driver.
|
|
2
|
+
|
|
3
|
+
`run_log_protocol_session` runs a caller-supplied ``body`` callable
|
|
4
|
+
against a ``Host`` while two cooperating tasks consume the
|
|
5
|
+
``<workdir>/optio.log`` channel:
|
|
6
|
+
|
|
7
|
+
- ``_tail_and_dispatch`` parses each log line into a typed event
|
|
8
|
+
(STATUS / DELIVERABLE / DONE / ERROR) and dispatches accordingly.
|
|
9
|
+
- ``_deliverable_fetch_loop`` drains the deliverable queue, fetches
|
|
10
|
+
each file from the host, decodes UTF-8, and invokes the
|
|
11
|
+
consumer's ``on_deliverable`` callback.
|
|
12
|
+
|
|
13
|
+
The driver knows nothing about specific consumers (opencode,
|
|
14
|
+
recipe-execution, ...). Each consumer's body is responsible for its
|
|
15
|
+
own subprocess management and arranging for the agent on the host to
|
|
16
|
+
write events to ``<workdir>/optio.log``.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import asyncio
|
|
22
|
+
import logging
|
|
23
|
+
from typing import TYPE_CHECKING, Awaitable, Callable
|
|
24
|
+
|
|
25
|
+
from optio_agents.context import HookContext
|
|
26
|
+
from optio_host.host import Host
|
|
27
|
+
from optio_agents.protocol.parser import (
|
|
28
|
+
AttentionEvent,
|
|
29
|
+
BrowserEvent,
|
|
30
|
+
DeliverableEvent,
|
|
31
|
+
DomainMessageEvent,
|
|
32
|
+
DoneEvent,
|
|
33
|
+
ErrorEvent,
|
|
34
|
+
LogEvent,
|
|
35
|
+
StatusEvent,
|
|
36
|
+
UnknownLine,
|
|
37
|
+
parse_log_line,
|
|
38
|
+
relativize_deliverable_path,
|
|
39
|
+
validate_deliverable_path,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
if TYPE_CHECKING:
|
|
43
|
+
from optio_core.context import ProcessContext
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
_LOG = logging.getLogger(__name__)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
DELIVERABLE_QUEUE_BOUND = 64
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# Public type aliases. ``HookContext`` is forward-quoted in these aliases
|
|
53
|
+
# so consumers don't need to import HookContext to type-check.
|
|
54
|
+
DeliverableCallback = Callable[["HookContext", str, str], Awaitable[None]]
|
|
55
|
+
"""Consumer callback invoked per fetched DELIVERABLE.
|
|
56
|
+
|
|
57
|
+
Arguments: ``(hook_ctx, deliverable_path, decoded_text)``.
|
|
58
|
+
|
|
59
|
+
``deliverable_path`` is the path of the deliverable file relative to
|
|
60
|
+
``<workdir>/deliverables/`` (e.g. ``"summary.md"`` or
|
|
61
|
+
``"sub/summary.md"``). It matches the value emitted in the
|
|
62
|
+
auto-generated ``"Deliverable: <path>"`` progress message.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
HookCallback = Callable[["HookContext"], Awaitable[None]]
|
|
67
|
+
"""Hook callback receiving a HookContext. Used by before_execute and
|
|
68
|
+
after_execute."""
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class _SessionFailed(Exception):
|
|
72
|
+
"""Internal signal: drive the surrounding session to ``failed``.
|
|
73
|
+
|
|
74
|
+
Re-raised by ``run_log_protocol_session`` when:
|
|
75
|
+
* the agent emits ERROR
|
|
76
|
+
* the body returns without DONE having fired
|
|
77
|
+
|
|
78
|
+
Consumers catch this and translate to their own failure semantics.
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
async def fetch_deliverable_text(host: Host, absolute_path: str) -> str:
|
|
83
|
+
"""Read the host file at ``absolute_path`` and decode it as UTF-8.
|
|
84
|
+
|
|
85
|
+
Thin wrapper around ``host.fetch_bytes_from_host`` for the common
|
|
86
|
+
text-deliverable case used by the protocol session driver.
|
|
87
|
+
"""
|
|
88
|
+
data = await host.fetch_bytes_from_host(absolute_path)
|
|
89
|
+
return data.decode("utf-8")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
async def run_log_protocol_session(
|
|
93
|
+
host: Host,
|
|
94
|
+
ctx: "ProcessContext",
|
|
95
|
+
*,
|
|
96
|
+
body: Callable[[Host, HookContext], Awaitable[None]],
|
|
97
|
+
on_deliverable: DeliverableCallback | None = None,
|
|
98
|
+
before_execute: HookCallback | None = None,
|
|
99
|
+
after_execute: HookCallback | None = None,
|
|
100
|
+
) -> None:
|
|
101
|
+
"""Run ``body`` against ``host`` while the log/deliverables protocol
|
|
102
|
+
cooperates with it.
|
|
103
|
+
|
|
104
|
+
Lifecycle:
|
|
105
|
+
1. ``host.setup_workdir()`` (mkdir workdir).
|
|
106
|
+
2. Create ``<workdir>/deliverables/`` and an empty
|
|
107
|
+
``<workdir>/optio.log``.
|
|
108
|
+
3. ``before_execute(hook_ctx)`` if set.
|
|
109
|
+
4. Spawn three concurrent tasks:
|
|
110
|
+
- ``_tail_and_dispatch``: parse lines from ``optio.log``,
|
|
111
|
+
emit progress / queue deliverables / set done/error flags.
|
|
112
|
+
- ``_deliverable_fetch_loop``: drain queue, fetch + decode,
|
|
113
|
+
invoke ``on_deliverable``.
|
|
114
|
+
- ``body(host, hook_ctx)``: caller's work.
|
|
115
|
+
5. Await ``{tail, body, cancel}`` with ``FIRST_COMPLETED``.
|
|
116
|
+
6. Drain queue, cancel the still-running watchers.
|
|
117
|
+
7. ``after_execute(hook_ctx)`` if set, with the same failure
|
|
118
|
+
semantics: re-raises if the session was healthy, logged
|
|
119
|
+
otherwise.
|
|
120
|
+
|
|
121
|
+
Outcomes:
|
|
122
|
+
* Agent emits ``DONE`` → returns clean.
|
|
123
|
+
* Agent emits ``ERROR`` → raises ``_SessionFailed``.
|
|
124
|
+
* Body returns without ``DONE`` having fired → raises
|
|
125
|
+
``_SessionFailed`` (the body finished prematurely; no
|
|
126
|
+
successful completion signal observed).
|
|
127
|
+
* Process cancellation → returns clean (caller decides what to
|
|
128
|
+
do next).
|
|
129
|
+
|
|
130
|
+
What this driver does NOT do:
|
|
131
|
+
* Workdir teardown / ``host.cleanup_taskdir`` — caller's
|
|
132
|
+
responsibility (caller may want to capture a snapshot first).
|
|
133
|
+
* Subprocess termination — body owns its handles.
|
|
134
|
+
* Snapshot / resume — caller brackets around this call.
|
|
135
|
+
"""
|
|
136
|
+
hook_ctx = HookContext(ctx, host)
|
|
137
|
+
|
|
138
|
+
# Workdir + protocol artifacts. ``setup_workdir`` mkdirs the workdir
|
|
139
|
+
# only; the protocol-specific deliverables/ dir + empty optio.log
|
|
140
|
+
# channel are owned by the protocol driver itself.
|
|
141
|
+
await host.setup_workdir()
|
|
142
|
+
deliverables_dir = f"{host.workdir}/deliverables"
|
|
143
|
+
await host.run_command(f"mkdir -p {deliverables_dir}")
|
|
144
|
+
await host.write_text("optio.log", "")
|
|
145
|
+
|
|
146
|
+
session_error: BaseException | None = None
|
|
147
|
+
cancelled = False
|
|
148
|
+
fetch_task: asyncio.Task | None = None
|
|
149
|
+
tail_task: asyncio.Task | None = None
|
|
150
|
+
body_task: asyncio.Task | None = None
|
|
151
|
+
cancel_task: asyncio.Task | None = None
|
|
152
|
+
|
|
153
|
+
try:
|
|
154
|
+
# before_execute runs inside the try so a failure here still
|
|
155
|
+
# triggers the after_execute cleanup in the outer finally.
|
|
156
|
+
if before_execute is not None:
|
|
157
|
+
await before_execute(hook_ctx)
|
|
158
|
+
|
|
159
|
+
deliverable_queue: asyncio.Queue[tuple[str, str]] = asyncio.Queue(
|
|
160
|
+
maxsize=DELIVERABLE_QUEUE_BOUND,
|
|
161
|
+
)
|
|
162
|
+
done_flag = asyncio.Event()
|
|
163
|
+
error_flag: list[str | None] = [] # [message] or [] if not fired
|
|
164
|
+
|
|
165
|
+
fetch_task = asyncio.create_task(
|
|
166
|
+
_deliverable_fetch_loop(host, on_deliverable, deliverable_queue, ctx, hook_ctx),
|
|
167
|
+
)
|
|
168
|
+
tail_task = asyncio.create_task(
|
|
169
|
+
_tail_and_dispatch(host, ctx, deliverable_queue, done_flag, error_flag),
|
|
170
|
+
)
|
|
171
|
+
body_task = asyncio.create_task(body(host, hook_ctx))
|
|
172
|
+
cancel_task = asyncio.create_task(_watch_cancellation(ctx))
|
|
173
|
+
|
|
174
|
+
done, _pending = await asyncio.wait(
|
|
175
|
+
{tail_task, body_task, cancel_task},
|
|
176
|
+
return_when=asyncio.FIRST_COMPLETED,
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
cancelled = (
|
|
180
|
+
cancel_task in done
|
|
181
|
+
and not cancel_task.cancelled()
|
|
182
|
+
and cancel_task.exception() is None
|
|
183
|
+
and cancel_task.result() is True
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
if error_flag:
|
|
187
|
+
raise _SessionFailed(error_flag[0] or "agent reported ERROR")
|
|
188
|
+
|
|
189
|
+
if body_task in done and not cancelled and not done_flag.is_set():
|
|
190
|
+
# Body completed without DONE — premature exit.
|
|
191
|
+
exc = body_task.exception()
|
|
192
|
+
if exc is not None:
|
|
193
|
+
raise exc
|
|
194
|
+
raise _SessionFailed("body returned before DONE was observed")
|
|
195
|
+
|
|
196
|
+
# Drain remaining deliverables before returning.
|
|
197
|
+
await deliverable_queue.join()
|
|
198
|
+
|
|
199
|
+
except BaseException as exc:
|
|
200
|
+
session_error = exc
|
|
201
|
+
raise
|
|
202
|
+
|
|
203
|
+
finally:
|
|
204
|
+
active_tasks = [
|
|
205
|
+
t for t in (tail_task, body_task, cancel_task, fetch_task)
|
|
206
|
+
if t is not None
|
|
207
|
+
]
|
|
208
|
+
for t in active_tasks:
|
|
209
|
+
if not t.done():
|
|
210
|
+
t.cancel()
|
|
211
|
+
if active_tasks:
|
|
212
|
+
await asyncio.gather(*active_tasks, return_exceptions=True)
|
|
213
|
+
|
|
214
|
+
if after_execute is not None:
|
|
215
|
+
try:
|
|
216
|
+
await after_execute(hook_ctx)
|
|
217
|
+
except BaseException as after_exc:
|
|
218
|
+
if session_error is None:
|
|
219
|
+
raise
|
|
220
|
+
ctx.report_progress(
|
|
221
|
+
None,
|
|
222
|
+
f"after_execute callback raised: {after_exc!r}",
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
# --- private helpers ---------------------------------------------------
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
async def _tail_and_dispatch(
|
|
230
|
+
host: Host,
|
|
231
|
+
ctx: "ProcessContext",
|
|
232
|
+
deliverable_queue: asyncio.Queue[tuple[str, str]],
|
|
233
|
+
done_flag: asyncio.Event,
|
|
234
|
+
error_flag: list,
|
|
235
|
+
) -> None:
|
|
236
|
+
"""Consume tail_file(optio.log), parse each line, dispatch by keyword."""
|
|
237
|
+
async for line in host.tail_file(f"{host.workdir}/optio.log"):
|
|
238
|
+
ev: LogEvent = parse_log_line(line)
|
|
239
|
+
if isinstance(ev, StatusEvent):
|
|
240
|
+
ctx.report_progress(ev.percent, ev.message)
|
|
241
|
+
elif isinstance(ev, DeliverableEvent):
|
|
242
|
+
try:
|
|
243
|
+
absolute = validate_deliverable_path(ev.path, host.workdir)
|
|
244
|
+
except ValueError:
|
|
245
|
+
ctx.report_progress(
|
|
246
|
+
None, f"invalid deliverable path {ev.path!r}, skipping",
|
|
247
|
+
)
|
|
248
|
+
continue
|
|
249
|
+
try:
|
|
250
|
+
display = relativize_deliverable_path(absolute, host.workdir)
|
|
251
|
+
except ValueError:
|
|
252
|
+
ctx.report_progress(
|
|
253
|
+
None,
|
|
254
|
+
f"deliverable {ev.path!r}: not under deliverables/, "
|
|
255
|
+
"skipping (malfunction)",
|
|
256
|
+
)
|
|
257
|
+
continue
|
|
258
|
+
ctx.report_progress(None, f"Deliverable: {display}")
|
|
259
|
+
item = (absolute, display)
|
|
260
|
+
try:
|
|
261
|
+
deliverable_queue.put_nowait(item)
|
|
262
|
+
except asyncio.QueueFull:
|
|
263
|
+
await deliverable_queue.put(item)
|
|
264
|
+
elif isinstance(ev, BrowserEvent):
|
|
265
|
+
await ctx.request_browser_open(ev.url)
|
|
266
|
+
elif isinstance(ev, AttentionEvent):
|
|
267
|
+
await ctx.need_attention(ev.reason)
|
|
268
|
+
elif isinstance(ev, DomainMessageEvent):
|
|
269
|
+
await ctx.domain_message(ev.keyword, ev.data)
|
|
270
|
+
elif isinstance(ev, DoneEvent):
|
|
271
|
+
if ev.summary:
|
|
272
|
+
ctx.report_progress(None, ev.summary)
|
|
273
|
+
done_flag.set()
|
|
274
|
+
return
|
|
275
|
+
elif isinstance(ev, ErrorEvent):
|
|
276
|
+
error_flag.append(ev.message)
|
|
277
|
+
return
|
|
278
|
+
else:
|
|
279
|
+
assert isinstance(ev, UnknownLine)
|
|
280
|
+
if ev.text:
|
|
281
|
+
ctx.report_progress(None, ev.text)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
async def _deliverable_fetch_loop(
|
|
285
|
+
host: Host,
|
|
286
|
+
callback: DeliverableCallback | None,
|
|
287
|
+
queue: asyncio.Queue[tuple[str, str]],
|
|
288
|
+
ctx: "ProcessContext",
|
|
289
|
+
hook_ctx: HookContext,
|
|
290
|
+
) -> None:
|
|
291
|
+
"""Drain the deliverable queue: fetch each file, decode UTF-8,
|
|
292
|
+
invoke the consumer callback."""
|
|
293
|
+
while True:
|
|
294
|
+
absolute, display = await queue.get()
|
|
295
|
+
try:
|
|
296
|
+
try:
|
|
297
|
+
text = await fetch_deliverable_text(host, absolute)
|
|
298
|
+
except UnicodeDecodeError:
|
|
299
|
+
ctx.report_progress(
|
|
300
|
+
None,
|
|
301
|
+
f"Deliverable {display}: not valid UTF-8, skipping callback",
|
|
302
|
+
)
|
|
303
|
+
continue
|
|
304
|
+
except FileNotFoundError:
|
|
305
|
+
ctx.report_progress(None, f"Deliverable {display}: not found")
|
|
306
|
+
continue
|
|
307
|
+
except Exception as exc: # noqa: BLE001
|
|
308
|
+
ctx.report_progress(
|
|
309
|
+
None,
|
|
310
|
+
f"Deliverable {display}: fetch failed: {exc!r}, skipping",
|
|
311
|
+
)
|
|
312
|
+
continue
|
|
313
|
+
|
|
314
|
+
if callback is None:
|
|
315
|
+
continue
|
|
316
|
+
try:
|
|
317
|
+
await callback(hook_ctx, display, text)
|
|
318
|
+
except Exception as exc: # noqa: BLE001
|
|
319
|
+
ctx.report_progress(
|
|
320
|
+
None, f"on_deliverable callback raised: {exc!r}",
|
|
321
|
+
)
|
|
322
|
+
finally:
|
|
323
|
+
queue.task_done()
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
async def _watch_cancellation(ctx: "ProcessContext") -> bool:
|
|
327
|
+
"""Return True when the process is cancelled."""
|
|
328
|
+
while ctx.should_continue():
|
|
329
|
+
await asyncio.sleep(0.1)
|
|
330
|
+
return True
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: optio-agents
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Agent-coordination layer for optio task types: the optio.log keyword protocol, its session driver, and the LLM-facing keyword documentation (SSOT).
|
|
5
|
+
Author-email: Kristof Csillag <kristof.csillag@deai-labs.com>
|
|
6
|
+
License-Expression: Apache-2.0
|
|
7
|
+
Project-URL: Homepage, https://github.com/deai-network/optio
|
|
8
|
+
Project-URL: Repository, https://github.com/deai-network/optio
|
|
9
|
+
Project-URL: Issues, https://github.com/deai-network/optio/issues
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
16
|
+
Classifier: Operating System :: MacOS
|
|
17
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
18
|
+
Classifier: Topic :: System :: Distributed Computing
|
|
19
|
+
Classifier: Framework :: AsyncIO
|
|
20
|
+
Requires-Python: >=3.11
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
Requires-Dist: optio-core<0.3,>=0.2
|
|
23
|
+
Requires-Dist: optio-host<0.3,>=0.2
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
26
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
|
|
27
|
+
|
|
28
|
+
# optio-agents
|
|
29
|
+
|
|
30
|
+
The agent-coordination layer for [optio](https://github.com/deai-network/optio) task types.
|
|
31
|
+
|
|
32
|
+
`optio-agents` owns the **log/deliverables keyword protocol** that long-running on-host agents use to talk back to optio, the **session driver** that parses and dispatches it, the **`HookContext`** handle passed to agent task hooks, and the **single source of truth** for the LLM-facing keyword documentation.
|
|
33
|
+
|
|
34
|
+
## What's in the box
|
|
35
|
+
|
|
36
|
+
- **`optio_agents.protocol`** — a line-oriented session driver. A long-running agent on the host writes lines prefixed `STATUS:`, `DELIVERABLE:`, `DONE`, or `ERROR` to `./optio.log`. `run_log_protocol_session` tails the log, dispatches progress events, fetches deliverable files, and resolves the session on `DONE` / `ERROR`.
|
|
37
|
+
- **`optio_agents.protocol.parser`** — the keyword parser (`parse_log_line`, the typed `*Event` dataclasses, deliverable-path validation).
|
|
38
|
+
- **`optio_agents.protocol.prompt`** — `LOG_CHANNEL_PROMPT`, the canonical LLM-facing documentation of the keywords, co-located with the parser regexes it documents so the two cannot drift. Consumers compose it into their own agent-facing prompt.
|
|
39
|
+
- **`HookContext` / `HookContextProtocol`** — the handle passed into task hooks and `on_deliverable` callbacks, wrapping a `ProcessContext` plus host primitives (`run_on_host`, `copy_file`, `read_from_host`, `download_file`).
|
|
40
|
+
|
|
41
|
+
## Dependency direction
|
|
42
|
+
|
|
43
|
+
`optio-agents` depends on `optio-host` (host transport: running commands, file transfer, tunnels) and `optio-core`. It is consumed by agent task packages such as `optio-opencode`.
|
|
44
|
+
|
|
45
|
+
## Installation
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
pip install optio-agents
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Python 3.11+.
|
|
52
|
+
|
|
53
|
+
## License
|
|
54
|
+
|
|
55
|
+
Apache-2.0.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
optio_agents/__init__.py,sha256=IIzBvFpaF0UEr2jSPEamWRTuld2hUHstovoDrDLIS0M,1153
|
|
2
|
+
optio_agents/browser_capture.py,sha256=Ac6dGfkS4LYYnvpx287pebxpbAP8bhGPXJ2Ak__z3MA,1944
|
|
3
|
+
optio_agents/context.py,sha256=mYSe4ihp-z1rGyQJVZAVHCocNp9OMkmSfxfEL1cs5cE,8631
|
|
4
|
+
optio_agents/protocol/__init__.py,sha256=S-AsTRcnSytulOnk5q_Bl8YRhlIgl6gMPDxZ-doMoPQ,1111
|
|
5
|
+
optio_agents/protocol/parser.py,sha256=kgKhcFsLgqUzDK1iOt_r5BGFM0CdafTPK3JZvp6-u4Q,4819
|
|
6
|
+
optio_agents/protocol/prompt.py,sha256=cqxGFG6YRB7p2JGSZwYgxWmD2sbGnO-iGwi2ur4a_m4,2474
|
|
7
|
+
optio_agents/protocol/session.py,sha256=g6B2-MA5hPrGIhLyx-It0_x9SdAzsubIIdCyS9BjTHk,11719
|
|
8
|
+
optio_agents-0.1.0.dist-info/METADATA,sha256=3SHUdk9Ba0Y6JQxzdd3S871VYpSjsoUXw5PO-pw-0dE,2925
|
|
9
|
+
optio_agents-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
10
|
+
optio_agents-0.1.0.dist-info/top_level.txt,sha256=AysTbkJKGJoarjJEZ2Nc34HZ2Pyakz7VtavQlpm8CPo,13
|
|
11
|
+
optio_agents-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
optio_agents
|