agentic-runner 2.6.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.
- agentic_runner/__init__.py +12 -0
- agentic_runner/activities.py +4918 -0
- agentic_runner/callback.py +342 -0
- agentic_runner/child_watcher.py +66 -0
- agentic_runner/cli.py +416 -0
- agentic_runner/config.py +105 -0
- agentic_runner/credentials.py +252 -0
- agentic_runner/device_login_activities.py +79 -0
- agentic_runner/egress.py +243 -0
- agentic_runner/heartbeat_link.py +249 -0
- agentic_runner/hooks.py +455 -0
- agentic_runner/host_store.py +295 -0
- agentic_runner/integrations/__init__.py +0 -0
- agentic_runner/integrations/git/__init__.py +1 -0
- agentic_runner/integrations/git/contracts.py +198 -0
- agentic_runner/integrations/git/evidence.py +442 -0
- agentic_runner/integrations/git/fake_workspace.py +339 -0
- agentic_runner/integrations/git/workspace.py +921 -0
- agentic_runner/integrations/github/__init__.py +53 -0
- agentic_runner/integrations/github/auth.py +171 -0
- agentic_runner/integrations/github/fake_client.py +494 -0
- agentic_runner/integrations/github/gh_client.py +944 -0
- agentic_runner/lifecycle.py +48 -0
- agentic_runner/llm_proxy.py +937 -0
- agentic_runner/mcp.py +342 -0
- agentic_runner/message_store.py +341 -0
- agentic_runner/py.typed +0 -0
- agentic_runner/recipient_key_secret.py +134 -0
- agentic_runner/registration.py +363 -0
- agentic_runner/runtime/__init__.py +0 -0
- agentic_runner/runtime/verifier_command.py +344 -0
- agentic_runner/sealed_box.py +509 -0
- agentic_runner/service.py +1068 -0
- agentic_runner/tiny_http.py +133 -0
- agentic_runner/triage_activities.py +113 -0
- agentic_runner/user_sources.py +546 -0
- agentic_runner/workers/__init__.py +1 -0
- agentic_runner/workers/_runtime_support.py +388 -0
- agentic_runner/workers/agent_runtime.py +93 -0
- agentic_runner/workers/claude_runtime.py +226 -0
- agentic_runner/workers/codex_runtime.py +311 -0
- agentic_runner/workers/command_policy.py +250 -0
- agentic_runner/workers/contract_device_login.py +211 -0
- agentic_runner/workers/contract_isolation.py +500 -0
- agentic_runner/workers/fastapi_client.py +396 -0
- agentic_runner/workers/harness_usage.py +65 -0
- agentic_runner/workers/mcp_config.py +111 -0
- agentic_runner/workers/settings.py +314 -0
- agentic_runner/workstation.py +687 -0
- agentic_runner-2.6.0.dist-info/METADATA +49 -0
- agentic_runner-2.6.0.dist-info/RECORD +54 -0
- agentic_runner-2.6.0.dist-info/WHEEL +4 -0
- agentic_runner-2.6.0.dist-info/entry_points.txt +2 -0
- agentic_runner-2.6.0.dist-info/licenses/LICENSE +661 -0
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""The few hundred bytes of HTTP the Runner serves to its own Directives.
|
|
2
|
+
|
|
3
|
+
Two servers live behind it: the per-attempt callback socket (PRD issue 45) and the
|
|
4
|
+
relocated LLM proxy (issue 43). Neither is a web server — one speaks over a Unix socket
|
|
5
|
+
the Contract's uid owns, the other over loopback TCP — and both answer a closed set of
|
|
6
|
+
routes to a caller that is a subprocess this same process spawned.
|
|
7
|
+
|
|
8
|
+
So: ``asyncio.start_unix_server``/``start_server`` and this module, rather than a web
|
|
9
|
+
framework in a package that gets shipped to clients. The proxy also has to *stream* an
|
|
10
|
+
upstream SSE body straight through, which is why responses are written as head-then-body
|
|
11
|
+
rather than returned whole.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import asyncio
|
|
17
|
+
import contextlib
|
|
18
|
+
import hmac
|
|
19
|
+
import json
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from typing import Any, Final
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"MAX_BODY_BYTES",
|
|
25
|
+
"MAX_HEADER_BYTES",
|
|
26
|
+
"HttpRequest",
|
|
27
|
+
"bearer_matches",
|
|
28
|
+
"read_request",
|
|
29
|
+
"response_head",
|
|
30
|
+
"write_json",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
MAX_HEADER_BYTES: Final[int] = 8192
|
|
34
|
+
MAX_BODY_BYTES: Final[int] = 1024 * 1024
|
|
35
|
+
|
|
36
|
+
_REASONS: Final[dict[int, bytes]] = {
|
|
37
|
+
200: b"OK",
|
|
38
|
+
202: b"Accepted",
|
|
39
|
+
400: b"Bad Request",
|
|
40
|
+
401: b"Unauthorized",
|
|
41
|
+
402: b"Payment Required",
|
|
42
|
+
403: b"Forbidden",
|
|
43
|
+
404: b"Not Found",
|
|
44
|
+
405: b"Method Not Allowed",
|
|
45
|
+
407: b"Proxy Authentication Required",
|
|
46
|
+
413: b"Payload Too Large",
|
|
47
|
+
422: b"Unprocessable Entity",
|
|
48
|
+
431: b"Request Header Fields Too Large",
|
|
49
|
+
500: b"Internal Server Error",
|
|
50
|
+
502: b"Bad Gateway",
|
|
51
|
+
504: b"Gateway Timeout",
|
|
52
|
+
503: b"Service Unavailable",
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True, slots=True)
|
|
57
|
+
class HttpRequest:
|
|
58
|
+
method: str
|
|
59
|
+
target: str
|
|
60
|
+
headers: dict[str, str]
|
|
61
|
+
body: bytes
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def authorization(self) -> str:
|
|
65
|
+
return self.headers.get("authorization", "")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
async def read_request(
|
|
69
|
+
reader: asyncio.StreamReader,
|
|
70
|
+
*,
|
|
71
|
+
max_header_bytes: int = MAX_HEADER_BYTES,
|
|
72
|
+
max_body_bytes: int = MAX_BODY_BYTES,
|
|
73
|
+
) -> HttpRequest | tuple[int, dict[str, Any]]:
|
|
74
|
+
"""One request, or the ``(status, body)`` to answer instead of parsing it."""
|
|
75
|
+
|
|
76
|
+
try:
|
|
77
|
+
head = await reader.readuntil(b"\r\n\r\n")
|
|
78
|
+
except asyncio.LimitOverrunError:
|
|
79
|
+
return 431, {"detail": "request headers too large"}
|
|
80
|
+
except (asyncio.IncompleteReadError, ConnectionError):
|
|
81
|
+
return 400, {"detail": "the request head was incomplete"}
|
|
82
|
+
if len(head) > max_header_bytes:
|
|
83
|
+
return 431, {"detail": "request headers too large"}
|
|
84
|
+
lines = head.decode("latin-1").split("\r\n")
|
|
85
|
+
method, _, rest = lines[0].partition(" ")
|
|
86
|
+
target = rest.rpartition(" ")[0].strip() or rest.strip()
|
|
87
|
+
headers = {
|
|
88
|
+
name.strip().lower(): value.strip()
|
|
89
|
+
for name, _, value in (line.partition(":") for line in lines[1:])
|
|
90
|
+
if name
|
|
91
|
+
}
|
|
92
|
+
try:
|
|
93
|
+
length = int(headers.get("content-length", "0") or 0)
|
|
94
|
+
except ValueError:
|
|
95
|
+
return 400, {"detail": "content-length is not a number"}
|
|
96
|
+
if length > max_body_bytes:
|
|
97
|
+
return 413, {"detail": "request body too large"}
|
|
98
|
+
body = await reader.readexactly(length) if length > 0 else b""
|
|
99
|
+
return HttpRequest(method=method.upper(), target=target, headers=headers, body=body)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def response_head(
|
|
103
|
+
status: int,
|
|
104
|
+
*,
|
|
105
|
+
content_type: str,
|
|
106
|
+
content_length: int | None = None,
|
|
107
|
+
) -> bytes:
|
|
108
|
+
"""The status line and headers. ``content_length=None`` leaves the body open-ended,
|
|
109
|
+
closed by the connection itself — which is how an SSE relay ends."""
|
|
110
|
+
|
|
111
|
+
head = (
|
|
112
|
+
f"HTTP/1.1 {status} {_REASONS.get(status, b'Unknown').decode()}\r\n"
|
|
113
|
+
f"Content-Type: {content_type}\r\n"
|
|
114
|
+
)
|
|
115
|
+
if content_length is not None:
|
|
116
|
+
head += f"Content-Length: {content_length}\r\n"
|
|
117
|
+
return (head + "Connection: close\r\n\r\n").encode("latin-1")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
async def write_json(writer: asyncio.StreamWriter, status: int, payload: dict[str, Any]) -> None:
|
|
121
|
+
body = json.dumps(payload).encode()
|
|
122
|
+
writer.write(response_head(status, content_type="application/json", content_length=len(body)))
|
|
123
|
+
writer.write(body)
|
|
124
|
+
with contextlib.suppress(ConnectionResetError, BrokenPipeError):
|
|
125
|
+
await writer.drain()
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def bearer_matches(header: str, token: str) -> bool:
|
|
129
|
+
"""Constant time: a bearer is the only thing standing between one Contract's uid and
|
|
130
|
+
another attempt's socket, and a socket accepts as many guesses as it is given."""
|
|
131
|
+
|
|
132
|
+
scheme, _, offered = header.partition(" ")
|
|
133
|
+
return scheme.lower() == "bearer" and hmac.compare_digest(offered.strip(), token)
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""The Triage Directive's one turn, on the Runner (PRD issue 29, issue 81).
|
|
2
|
+
|
|
3
|
+
Dispatched to ``runner.{runner_id}`` (the locality rule, ADR-0013 §2): the turn spends the
|
|
4
|
+
Intake Lead's Contract's LLM slot, which lives only in this Runner's proxy (ADR-0013 §4),
|
|
5
|
+
so the control plane -- which holds no Organisation LLM credential (ADR-0010 §4) -- only
|
|
6
|
+
prepares the prompt and dispatches the answer. The user-connected Sources' poller runs
|
|
7
|
+
the same turn in-process (``user_sources.ProxyTriage``).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from collections.abc import Callable
|
|
13
|
+
from uuid import UUID
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
from temporalio import activity
|
|
17
|
+
|
|
18
|
+
from agentic_runner.llm_proxy import LlmProxy
|
|
19
|
+
from agentic_runner_contracts.activity_io import TriageTurnInput, TriageTurnOutput
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
async def run_triage_turn(
|
|
23
|
+
proxy: LlmProxy,
|
|
24
|
+
*,
|
|
25
|
+
model: str,
|
|
26
|
+
timeout_seconds: float,
|
|
27
|
+
prompt: str,
|
|
28
|
+
directive_id: str,
|
|
29
|
+
contract_id: UUID | None,
|
|
30
|
+
agent_id: UUID | None,
|
|
31
|
+
reserve_max_tokens: int | None = None,
|
|
32
|
+
client: httpx.AsyncClient | None = None,
|
|
33
|
+
) -> str | None:
|
|
34
|
+
"""One completion through this Runner's proxy, metered like any Directive.
|
|
35
|
+
|
|
36
|
+
``None`` on anything that keeps the answer from being trusted -- a hang, a ceiling or
|
|
37
|
+
Reserve refusal, a missing slot, a malformed body: the caller's fallback is the
|
|
38
|
+
keyword classifier, never an Incident (PRD issue 29).
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
http = client or httpx.AsyncClient()
|
|
42
|
+
try:
|
|
43
|
+
async with proxy.attempt(
|
|
44
|
+
directive_id=directive_id,
|
|
45
|
+
contract_id=contract_id,
|
|
46
|
+
agent_id=agent_id,
|
|
47
|
+
reserve_max_tokens=reserve_max_tokens,
|
|
48
|
+
) as handle:
|
|
49
|
+
response = await http.post(
|
|
50
|
+
f"{handle.base_url}/chat/completions",
|
|
51
|
+
json={"model": model, "messages": [{"role": "user", "content": prompt}]},
|
|
52
|
+
headers={"authorization": f"Bearer {handle.token}"},
|
|
53
|
+
timeout=timeout_seconds,
|
|
54
|
+
)
|
|
55
|
+
if response.status_code >= 400:
|
|
56
|
+
return None
|
|
57
|
+
return _content(response.json())
|
|
58
|
+
except (httpx.HTTPError, ValueError):
|
|
59
|
+
return None
|
|
60
|
+
finally:
|
|
61
|
+
if client is None:
|
|
62
|
+
await http.aclose()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _content(completion: object) -> str | None:
|
|
66
|
+
if not isinstance(completion, dict):
|
|
67
|
+
return None
|
|
68
|
+
choices = completion.get("choices")
|
|
69
|
+
if not isinstance(choices, list) or not choices or not isinstance(choices[0], dict):
|
|
70
|
+
return None
|
|
71
|
+
message = choices[0].get("message")
|
|
72
|
+
content = message.get("content") if isinstance(message, dict) else None
|
|
73
|
+
return content if isinstance(content, str) else None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class RunnerTriageActivities:
|
|
77
|
+
def __init__(
|
|
78
|
+
self,
|
|
79
|
+
*,
|
|
80
|
+
proxy: LlmProxy,
|
|
81
|
+
model: str,
|
|
82
|
+
timeout_seconds: float,
|
|
83
|
+
client: httpx.AsyncClient | None = None,
|
|
84
|
+
) -> None:
|
|
85
|
+
self._proxy = proxy
|
|
86
|
+
self._model = model
|
|
87
|
+
self._timeout_seconds = timeout_seconds
|
|
88
|
+
self._client = client
|
|
89
|
+
|
|
90
|
+
def activity_callables(self) -> list[Callable[..., object]]:
|
|
91
|
+
return [self.run_triage_turn]
|
|
92
|
+
|
|
93
|
+
@activity.defn(name="run_triage_turn")
|
|
94
|
+
async def run_triage_turn(self, request: TriageTurnInput) -> TriageTurnOutput:
|
|
95
|
+
raw_text = await run_triage_turn(
|
|
96
|
+
self._proxy,
|
|
97
|
+
model=self._model,
|
|
98
|
+
timeout_seconds=self._timeout_seconds,
|
|
99
|
+
prompt=request.prompt,
|
|
100
|
+
directive_id=request.directive_id,
|
|
101
|
+
contract_id=_optional_uuid(request.intake_lead_contract_id),
|
|
102
|
+
agent_id=_optional_uuid(request.intake_lead_agent_id),
|
|
103
|
+
reserve_max_tokens=request.reserve_max_tokens,
|
|
104
|
+
client=self._client,
|
|
105
|
+
)
|
|
106
|
+
return TriageTurnOutput(raw_text=raw_text)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _optional_uuid(value: str) -> UUID | None:
|
|
110
|
+
try:
|
|
111
|
+
return UUID(value)
|
|
112
|
+
except ValueError:
|
|
113
|
+
return None
|