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.
- optio_agents-0.1.0/PKG-INFO +55 -0
- optio_agents-0.1.0/README.md +28 -0
- optio_agents-0.1.0/pyproject.toml +48 -0
- optio_agents-0.1.0/setup.cfg +4 -0
- optio_agents-0.1.0/src/optio_agents/__init__.py +45 -0
- optio_agents-0.1.0/src/optio_agents/browser_capture.py +53 -0
- optio_agents-0.1.0/src/optio_agents/context.py +244 -0
- optio_agents-0.1.0/src/optio_agents/protocol/__init__.py +49 -0
- optio_agents-0.1.0/src/optio_agents/protocol/parser.py +169 -0
- optio_agents-0.1.0/src/optio_agents/protocol/prompt.py +54 -0
- optio_agents-0.1.0/src/optio_agents/protocol/session.py +330 -0
- optio_agents-0.1.0/src/optio_agents.egg-info/PKG-INFO +55 -0
- optio_agents-0.1.0/src/optio_agents.egg-info/SOURCES.txt +21 -0
- optio_agents-0.1.0/src/optio_agents.egg-info/dependency_links.txt +1 -0
- optio_agents-0.1.0/src/optio_agents.egg-info/requires.txt +6 -0
- optio_agents-0.1.0/src/optio_agents.egg-info/top_level.txt +1 -0
- optio_agents-0.1.0/tests/test_browser_capture.py +45 -0
- optio_agents-0.1.0/tests/test_client_directed_dispatch.py +56 -0
- optio_agents-0.1.0/tests/test_context.py +361 -0
- optio_agents-0.1.0/tests/test_download.py +116 -0
- optio_agents-0.1.0/tests/test_package_exports.py +19 -0
- optio_agents-0.1.0/tests/test_prompt.py +31 -0
- optio_agents-0.1.0/tests/test_protocol_parser.py +212 -0
|
@@ -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,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
|
+
]
|