horus-agent 0.1.2__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,6 @@
1
+ #
2
+ # horus_agent
3
+ # Copyright (c) 2026 Temple Compute
4
+ #
5
+ # MIT License
6
+ #
@@ -0,0 +1,13 @@
1
+ #
2
+ # horus_agent
3
+ # Copyright (c) 2026 Temple Compute
4
+ #
5
+ # MIT License
6
+ #
7
+ """``python -m horus_agent``."""
8
+
9
+ import sys
10
+
11
+ from horus_agent.cli import main
12
+
13
+ sys.exit(main())
horus_agent/blobs.py ADDED
@@ -0,0 +1,89 @@
1
+ #
2
+ # horus_agent
3
+ # Copyright (c) 2026 Temple Compute
4
+ #
5
+ # MIT License
6
+ #
7
+ """
8
+ Streamed transfers to and from presigned staging URLs.
9
+
10
+ Used on both sides: the orchestrator and the agent each move large files
11
+ through object storage instead of through the broker.
12
+ """
13
+
14
+ import time
15
+ from collections.abc import AsyncIterator
16
+ from pathlib import Path
17
+
18
+ import httpx
19
+ from horus_runtime.logging import horus_logger
20
+
21
+ from horus_agent.logs import safe_url
22
+
23
+ _CHUNK = 1024 * 1024
24
+ MAX_SINGLE_PUT = 5 * 1024**3
25
+ """S3's limit for one PUT."""
26
+
27
+
28
+ async def _read_chunks(path: Path) -> AsyncIterator[bytes]:
29
+ with path.open("rb") as fh:
30
+ while chunk := fh.read(_CHUNK):
31
+ yield chunk
32
+
33
+
34
+ async def upload(url: str, source: Path | bytes) -> None:
35
+ """
36
+ PUT *source* to the presigned *url*, streaming a file from disk.
37
+
38
+ ``Content-Length`` is always sent: S3 refuses a chunked presigned PUT.
39
+ """
40
+ if isinstance(source, Path):
41
+ size = source.stat().st_size
42
+ if size > MAX_SINGLE_PUT:
43
+ # shortcut: one presigned PUT, S3's limit; add multipart if
44
+ # agents need to move larger single files.
45
+ raise ValueError(
46
+ f"{source} is {size} bytes; files over 5 GiB cannot be "
47
+ "moved to or from an agent yet"
48
+ )
49
+ content: bytes | AsyncIterator[bytes] = _read_chunks(source)
50
+ else:
51
+ size = len(source)
52
+ content = source
53
+ log = horus_logger.log
54
+ log.debug(f"Upload {size} bytes to {safe_url(url)}")
55
+ started = time.monotonic()
56
+ async with httpx.AsyncClient(timeout=None) as client:
57
+ response = await client.put(
58
+ url, content=content, headers={"Content-Length": str(size)}
59
+ )
60
+ response.raise_for_status()
61
+ log.debug(f"Uploaded {size} bytes in {time.monotonic() - started:.2f}s")
62
+
63
+
64
+ async def download_to(url: str, dest: Path) -> None:
65
+ """GET the presigned *url* into *dest*, streamed, creating parents."""
66
+ dest.parent.mkdir(parents=True, exist_ok=True)
67
+ log = horus_logger.log
68
+ log.debug(f"Download {safe_url(url)} to {dest}")
69
+ started = time.monotonic()
70
+ async with (
71
+ httpx.AsyncClient(timeout=None) as client,
72
+ client.stream("GET", url) as response,
73
+ ):
74
+ response.raise_for_status()
75
+ with dest.open("wb") as fh:
76
+ async for chunk in response.aiter_bytes(_CHUNK):
77
+ fh.write(chunk)
78
+ log.debug(
79
+ f"Downloaded {dest.stat().st_size} bytes to {dest} in "
80
+ f"{time.monotonic() - started:.2f}s"
81
+ )
82
+
83
+
84
+ async def download(url: str) -> bytes:
85
+ """GET the presigned *url* into memory."""
86
+ async with httpx.AsyncClient(timeout=None) as client:
87
+ response = await client.get(url)
88
+ response.raise_for_status()
89
+ return response.content
horus_agent/broker.py ADDED
@@ -0,0 +1,223 @@
1
+ #
2
+ # horus_agent
3
+ # Copyright (c) 2026 Temple Compute
4
+ #
5
+ # MIT License
6
+ #
7
+ """
8
+ Orchestrator-side client for the broker that relays ops to agents.
9
+ """
10
+
11
+ import asyncio
12
+ import random
13
+ import time
14
+ import weakref
15
+ from typing import Any
16
+ from uuid import uuid4
17
+
18
+ import httpx
19
+ from horus_runtime.core.task.exceptions import (
20
+ TargetLostError,
21
+ TaskExecutionError,
22
+ )
23
+ from horus_runtime.logging import horus_logger
24
+
25
+ from horus_agent.logs import describe_args
26
+ from horus_agent.protocol import (
27
+ IDEMPOTENT_METHODS,
28
+ POLL_WAIT_S,
29
+ AgentInfo,
30
+ Op,
31
+ OpStatus,
32
+ StagingUrls,
33
+ )
34
+
35
+ BACKOFF_MAX_S = 30.0
36
+
37
+ _CLIENTS: weakref.WeakKeyDictionary[
38
+ asyncio.AbstractEventLoop, dict[tuple[str, str], "BrokerClient"]
39
+ ] = weakref.WeakKeyDictionary()
40
+
41
+
42
+ _WORK_DIRS: dict[tuple[str, str], str] = {}
43
+
44
+
45
+ def agent_work_dir(url: str, token: str, agent: str) -> str:
46
+ """
47
+ The working directory *agent* reported, asked once per process.
48
+
49
+ Blocking: target paths are computed synchronously, before any op runs.
50
+ shortcut: a sync request inside the event loop, once per agent per run;
51
+ move it into an async setup hook if the runtime grows one.
52
+
53
+ Raises:
54
+ TaskExecutionError: The broker does not know the agent, or the agent
55
+ has not reported its working directory yet.
56
+ """
57
+ key = (url, agent)
58
+ if key in _WORK_DIRS:
59
+ return _WORK_DIRS[key]
60
+ try:
61
+ response = httpx.get(
62
+ f"{url.rstrip('/')}/agents/info/{agent}",
63
+ headers={"Authorization": f"Bearer {token}"},
64
+ timeout=30.0,
65
+ )
66
+ except httpx.TransportError as exc:
67
+ raise TaskExecutionError(f"Broker unreachable: {exc}") from exc
68
+ if response.status_code >= 400:
69
+ raise TaskExecutionError(
70
+ f"Cannot look up agent {agent!r}: HTTP {response.status_code}: "
71
+ f"{response.text}"
72
+ )
73
+ info = AgentInfo.model_validate(response.json())
74
+ if not info.work_dir:
75
+ raise TaskExecutionError(
76
+ f"Agent {agent!r} has not reported its working directory yet; "
77
+ "start it with horus-agent run"
78
+ )
79
+ horus_logger.log.debug(f"Agent {agent!r} runs tasks in {info.work_dir}")
80
+ _WORK_DIRS[key] = info.work_dir
81
+ return info.work_dir
82
+
83
+
84
+ def broker_client(url: str, token: str) -> "BrokerClient":
85
+ """
86
+ The running loop's client for *url* and *token*, so every target in a
87
+ run shares one connection pool.
88
+ """
89
+ clients = _CLIENTS.setdefault(asyncio.get_running_loop(), {})
90
+ key = (url, token)
91
+ if key not in clients:
92
+ clients[key] = BrokerClient(url, token)
93
+ return clients[key]
94
+
95
+
96
+ class BrokerClient:
97
+ """Submits ops to the broker and waits for their results."""
98
+
99
+ def __init__(self, url: str, token: str) -> None:
100
+ """Bind to the broker at *url*, authenticating with *token*."""
101
+ self._http = httpx.AsyncClient(
102
+ base_url=url.rstrip("/") + "/",
103
+ headers={"Authorization": f"Bearer {token}"},
104
+ # A long-poll holds the response for up to POLL_WAIT_S.
105
+ timeout=httpx.Timeout(30.0, read=POLL_WAIT_S + 30.0),
106
+ )
107
+
108
+ async def call(
109
+ self,
110
+ agent: str,
111
+ method: str,
112
+ args: dict[str, Any],
113
+ *,
114
+ offline_timeout: float,
115
+ ) -> Any:
116
+ """
117
+ Run *method* on *agent* and return its result.
118
+
119
+ Raises:
120
+ TargetLostError: The agent has not polled for *offline_timeout*
121
+ seconds, or restarted while running a non-idempotent op.
122
+ TaskExecutionError: The op failed on the agent, or the broker
123
+ refused or stayed unreachable.
124
+ """
125
+ log = horus_logger.log
126
+ started = time.monotonic()
127
+ while True:
128
+ op = Op(op_id=uuid4().hex, method=method, args=args)
129
+ tag = f"Op {op.op_id[:8]} {method} on agent {agent!r}"
130
+ log.debug(f"{tag}: submit {describe_args(args)}")
131
+ status = await self._request(
132
+ "POST",
133
+ "agents/ops/",
134
+ offline_timeout,
135
+ json={"agent": agent, "op": op.model_dump(exclude={"run_id"})},
136
+ )
137
+ while not status.done and not status.lost:
138
+ seen = status.agent_seen_ago
139
+ if seen is None or seen > offline_timeout:
140
+ raise TargetLostError(
141
+ f"Agent {agent!r} is offline (last seen: "
142
+ f"{'never' if seen is None else f'{seen:.0f}s ago'})"
143
+ )
144
+ log.debug(
145
+ f"{tag}: waiting (agent last polled {seen:.0f}s ago)"
146
+ )
147
+ status = await self._request(
148
+ "GET",
149
+ f"agents/ops/{op.op_id}",
150
+ offline_timeout,
151
+ params={"wait": POLL_WAIT_S},
152
+ )
153
+ if status.done:
154
+ break
155
+ if method not in IDEMPOTENT_METHODS:
156
+ raise TargetLostError(
157
+ f"Agent {agent!r} restarted while running {method}"
158
+ )
159
+ log.debug(f"{tag}: lost on the way; sending it again")
160
+ log.debug(
161
+ f"{tag}: {'failed' if status.error else 'done'} after "
162
+ f"{time.monotonic() - started:.2f}s"
163
+ )
164
+ if status.error is not None:
165
+ raise TaskExecutionError(
166
+ f"{method} on agent {agent!r} failed: "
167
+ f"{status.error.type}: {status.error.message}"
168
+ )
169
+ return status.result
170
+
171
+ async def staging(self, offline_timeout: float) -> StagingUrls:
172
+ """Presigned URLs for a fresh staging object."""
173
+ response = await self._send("POST", "agents/staging/", offline_timeout)
174
+ return StagingUrls.model_validate(response.json())
175
+
176
+ async def _request(
177
+ self,
178
+ verb: str,
179
+ path: str,
180
+ offline_timeout: float,
181
+ **kwargs: Any,
182
+ ) -> OpStatus:
183
+ response = await self._send(verb, path, offline_timeout, **kwargs)
184
+ return OpStatus.model_validate(response.json())
185
+
186
+ async def _send(
187
+ self,
188
+ verb: str,
189
+ path: str,
190
+ offline_timeout: float,
191
+ **kwargs: Any,
192
+ ) -> httpx.Response:
193
+ """
194
+ Send one request, retrying with backoff while the broker is down.
195
+
196
+ A resent ``POST agents/ops/`` carries the same ``op_id``, which the
197
+ broker queues only once.
198
+ """
199
+ deadline = time.monotonic() + offline_timeout
200
+ delay = 1.0
201
+ while True:
202
+ try:
203
+ response = await self._http.request(verb, path, **kwargs)
204
+ except httpx.TransportError as exc:
205
+ failure = f"{type(exc).__name__}: {exc}"
206
+ else:
207
+ if response.status_code < 500:
208
+ break
209
+ failure = f"HTTP {response.status_code}"
210
+ if time.monotonic() + delay > deadline:
211
+ raise TaskExecutionError(f"Broker unreachable: {failure}")
212
+ await asyncio.sleep(delay * random.uniform(0.5, 1.0))
213
+ delay = min(delay * 2, BACKOFF_MAX_S)
214
+ if response.status_code >= 400:
215
+ try:
216
+ detail = response.json().get("detail", response.text)
217
+ except ValueError:
218
+ detail = response.text
219
+ raise TaskExecutionError(
220
+ f"Broker refused {verb} {path}: "
221
+ f"HTTP {response.status_code}: {detail}"
222
+ )
223
+ return response
horus_agent/cli.py ADDED
@@ -0,0 +1,153 @@
1
+ #
2
+ # horus_agent
3
+ # Copyright (c) 2026 Temple Compute
4
+ #
5
+ # MIT License
6
+ #
7
+ """
8
+ ``horus-agent register`` and ``horus-agent run``.
9
+ """
10
+
11
+ import argparse
12
+ import asyncio
13
+ import dataclasses
14
+ import platform
15
+ import signal
16
+ import socket
17
+ import sys
18
+ from pathlib import Path
19
+
20
+ import httpx
21
+
22
+ from horus_agent import config, logs
23
+ from horus_agent.daemon import Agent, AgentUnauthorizedError, agent_version
24
+ from horus_agent.protocol import Registration
25
+
26
+
27
+ def _work_dir(value: str | None) -> Path:
28
+ """*value* as an absolute path, created if missing."""
29
+ path = Path(value).expanduser() if value else config.default_work_dir()
30
+ path = path.resolve()
31
+ path.mkdir(parents=True, exist_ok=True)
32
+ return path
33
+
34
+
35
+ def _register(
36
+ url: str, token: str, name: str | None, work_dir: str | None
37
+ ) -> int:
38
+ directory = _work_dir(work_dir)
39
+ body = {
40
+ "token": token,
41
+ "hostname": socket.gethostname(),
42
+ "os": platform.platform(),
43
+ "version": agent_version(),
44
+ "work_dir": str(directory),
45
+ }
46
+ if name is not None:
47
+ body["name"] = name
48
+ response = httpx.post(
49
+ f"{url.rstrip('/')}/agents/register", json=body, timeout=30.0
50
+ )
51
+ if response.status_code >= 400:
52
+ try:
53
+ detail = response.json().get("detail", response.text)
54
+ except ValueError:
55
+ detail = response.text
56
+ sys.stderr.write(f"Registration failed: {detail}\n")
57
+ return 1
58
+ registration = Registration.model_validate(response.json())
59
+ path = config.config_path()
60
+ config.save(
61
+ config.AgentConfig(
62
+ url=url,
63
+ agent_id=registration.agent_id,
64
+ name=registration.name,
65
+ token=registration.agent_token,
66
+ work_dir=str(directory),
67
+ ),
68
+ path,
69
+ )
70
+ sys.stdout.write(
71
+ f"Registered as {registration.name!r} (config: {path}).\n"
72
+ f"Tasks run in {directory}.\n"
73
+ "Start it with: horus-agent run\n"
74
+ )
75
+ return 0
76
+
77
+
78
+ async def _run(work_dir: str | None) -> int:
79
+ path = config.config_path()
80
+ try:
81
+ registered = config.load(path)
82
+ except FileNotFoundError:
83
+ sys.stderr.write(
84
+ f"No registration at {path}. Run horus-agent register first.\n"
85
+ )
86
+ return 1
87
+ directory = _work_dir(work_dir or registered.work_dir or None)
88
+ agent = Agent(dataclasses.replace(registered, work_dir=str(directory)))
89
+ stop = asyncio.Event()
90
+ loop = asyncio.get_running_loop()
91
+
92
+ def _stop() -> None:
93
+ # A second signal falls through to the default: exit now.
94
+ for sig in (signal.SIGINT, signal.SIGTERM):
95
+ loop.remove_signal_handler(sig)
96
+ stop.set()
97
+
98
+ for sig in (signal.SIGINT, signal.SIGTERM):
99
+ loop.add_signal_handler(sig, _stop)
100
+ try:
101
+ await agent.serve(stop)
102
+ except AgentUnauthorizedError as exc:
103
+ sys.stderr.write(f"{exc}\n")
104
+ return 1
105
+ return 0
106
+
107
+
108
+ def main(argv: list[str] | None = None) -> int:
109
+ """Entry point of the ``horus-agent`` command."""
110
+ parser = argparse.ArgumentParser(
111
+ prog="horus-agent",
112
+ description="Run Horus tasks on this machine without opening "
113
+ "inbound connections.",
114
+ )
115
+ common = argparse.ArgumentParser(add_help=False)
116
+ common.add_argument(
117
+ "--debug",
118
+ action="store_true",
119
+ help="Log every connection, request, op and command (also: "
120
+ f"{logs.DEBUG_ENV}=1). Secret values are never logged.",
121
+ )
122
+ commands = parser.add_subparsers(dest="command", required=True)
123
+ register = commands.add_parser(
124
+ "register",
125
+ parents=[common],
126
+ help="Register this machine with a one-time token.",
127
+ )
128
+ register.add_argument("--url", required=True, help="Broker API URL.")
129
+ register.add_argument(
130
+ "--token", required=True, help="One-time registration token."
131
+ )
132
+ register.add_argument(
133
+ "--name",
134
+ help="Agent name, used as the target's 'agent' field, when the "
135
+ "token does not set one. Default: this machine's hostname.",
136
+ )
137
+ work_dir_help = (
138
+ "Directory where tasks run on this machine, created if missing. "
139
+ f"Default: {config.default_work_dir()}."
140
+ )
141
+ register.add_argument("--work-dir", help=work_dir_help)
142
+ run = commands.add_parser(
143
+ "run", parents=[common], help="Poll for work until stopped."
144
+ )
145
+ run.add_argument(
146
+ "--work-dir",
147
+ help="Use this directory instead of the one given at registration.",
148
+ )
149
+ args = parser.parse_args(argv)
150
+ logs.setup(args.debug)
151
+ if args.command == "register":
152
+ return _register(args.url, args.token, args.name, args.work_dir)
153
+ return asyncio.run(_run(args.work_dir))
horus_agent/config.py ADDED
@@ -0,0 +1,62 @@
1
+ #
2
+ # horus_agent
3
+ # Copyright (c) 2026 Temple Compute
4
+ #
5
+ # MIT License
6
+ #
7
+ """
8
+ The agent's registration, kept in one owner-only JSON file.
9
+ """
10
+
11
+ import json
12
+ import os
13
+ from dataclasses import asdict, dataclass
14
+ from pathlib import Path
15
+
16
+ CONFIG_ENV = "HORUS_AGENT_CONFIG"
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class AgentConfig:
21
+ """What ``horus-agent register`` stores for ``horus-agent run``."""
22
+
23
+ url: str
24
+ """Broker base URL."""
25
+
26
+ agent_id: str
27
+ name: str
28
+ token: str
29
+ """The agent's own credential. Never logged."""
30
+ work_dir: str = ""
31
+ """
32
+ Where tasks run on this machine. Empty in configs written before it
33
+ existed: :func:`default_work_dir` then.
34
+ """
35
+
36
+
37
+ def default_work_dir() -> Path:
38
+ """``~/horus-agent``: where tasks run unless ``--work-dir`` says else."""
39
+ return Path.home() / "horus-agent"
40
+
41
+
42
+ def config_path() -> Path:
43
+ """``$HORUS_AGENT_CONFIG``, else ``$XDG_CONFIG_HOME/horus-agent``."""
44
+ if explicit := os.environ.get(CONFIG_ENV):
45
+ return Path(explicit)
46
+ base = os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config"
47
+ return Path(base) / "horus-agent" / "agent.json"
48
+
49
+
50
+ def save(config: AgentConfig, path: Path) -> None:
51
+ """Write *config* to *path*, readable by its owner only."""
52
+ path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
53
+ fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
54
+ # O_CREAT's mode does not apply to a file that already existed.
55
+ os.fchmod(fd, 0o600)
56
+ with os.fdopen(fd, "w") as fh:
57
+ json.dump(asdict(config), fh)
58
+
59
+
60
+ def load(path: Path) -> AgentConfig:
61
+ """Read the config written by :func:`save`."""
62
+ return AgentConfig(**json.loads(path.read_text()))