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.
@@ -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
+ }
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ optio_agents