optio-agents 0.1.0__tar.gz

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,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,28 @@
1
+ # optio-agents
2
+
3
+ The agent-coordination layer for [optio](https://github.com/deai-network/optio) task types.
4
+
5
+ `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.
6
+
7
+ ## What's in the box
8
+
9
+ - **`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`.
10
+ - **`optio_agents.protocol.parser`** — the keyword parser (`parse_log_line`, the typed `*Event` dataclasses, deliverable-path validation).
11
+ - **`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.
12
+ - **`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`).
13
+
14
+ ## Dependency direction
15
+
16
+ `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`.
17
+
18
+ ## Installation
19
+
20
+ ```bash
21
+ pip install optio-agents
22
+ ```
23
+
24
+ Python 3.11+.
25
+
26
+ ## License
27
+
28
+ Apache-2.0.
@@ -0,0 +1,48 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "optio-agents"
7
+ version = "0.1.0"
8
+ description = "Agent-coordination layer for optio task types: the optio.log keyword protocol, its session driver, and the LLM-facing keyword documentation (SSOT)."
9
+ readme = "README.md"
10
+ license = "Apache-2.0"
11
+ requires-python = ">=3.11"
12
+ authors = [
13
+ { name = "Kristof Csillag", email = "kristof.csillag@deai-labs.com" },
14
+ ]
15
+ classifiers = [
16
+ "Development Status :: 4 - Beta",
17
+ "Programming Language :: Python :: 3",
18
+ "Programming Language :: Python :: 3.11",
19
+ "Programming Language :: Python :: 3.12",
20
+ "Programming Language :: Python :: 3.13",
21
+ "Operating System :: POSIX :: Linux",
22
+ "Operating System :: MacOS",
23
+ "Topic :: Software Development :: Libraries :: Python Modules",
24
+ "Topic :: System :: Distributed Computing",
25
+ "Framework :: AsyncIO",
26
+ ]
27
+ dependencies = [
28
+ "optio-core>=0.2,<0.3",
29
+ "optio-host>=0.2,<0.3",
30
+ ]
31
+
32
+ [project.optional-dependencies]
33
+ dev = [
34
+ "pytest>=8.0",
35
+ "pytest-asyncio>=0.23",
36
+ ]
37
+
38
+ [project.urls]
39
+ Homepage = "https://github.com/deai-network/optio"
40
+ Repository = "https://github.com/deai-network/optio"
41
+ Issues = "https://github.com/deai-network/optio/issues"
42
+
43
+ [tool.setuptools.packages.find]
44
+ where = ["src"]
45
+
46
+ [tool.pytest.ini_options]
47
+ asyncio_mode = "auto"
48
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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
+ ]