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.
Files changed (54) hide show
  1. agentic_runner/__init__.py +12 -0
  2. agentic_runner/activities.py +4918 -0
  3. agentic_runner/callback.py +342 -0
  4. agentic_runner/child_watcher.py +66 -0
  5. agentic_runner/cli.py +416 -0
  6. agentic_runner/config.py +105 -0
  7. agentic_runner/credentials.py +252 -0
  8. agentic_runner/device_login_activities.py +79 -0
  9. agentic_runner/egress.py +243 -0
  10. agentic_runner/heartbeat_link.py +249 -0
  11. agentic_runner/hooks.py +455 -0
  12. agentic_runner/host_store.py +295 -0
  13. agentic_runner/integrations/__init__.py +0 -0
  14. agentic_runner/integrations/git/__init__.py +1 -0
  15. agentic_runner/integrations/git/contracts.py +198 -0
  16. agentic_runner/integrations/git/evidence.py +442 -0
  17. agentic_runner/integrations/git/fake_workspace.py +339 -0
  18. agentic_runner/integrations/git/workspace.py +921 -0
  19. agentic_runner/integrations/github/__init__.py +53 -0
  20. agentic_runner/integrations/github/auth.py +171 -0
  21. agentic_runner/integrations/github/fake_client.py +494 -0
  22. agentic_runner/integrations/github/gh_client.py +944 -0
  23. agentic_runner/lifecycle.py +48 -0
  24. agentic_runner/llm_proxy.py +937 -0
  25. agentic_runner/mcp.py +342 -0
  26. agentic_runner/message_store.py +341 -0
  27. agentic_runner/py.typed +0 -0
  28. agentic_runner/recipient_key_secret.py +134 -0
  29. agentic_runner/registration.py +363 -0
  30. agentic_runner/runtime/__init__.py +0 -0
  31. agentic_runner/runtime/verifier_command.py +344 -0
  32. agentic_runner/sealed_box.py +509 -0
  33. agentic_runner/service.py +1068 -0
  34. agentic_runner/tiny_http.py +133 -0
  35. agentic_runner/triage_activities.py +113 -0
  36. agentic_runner/user_sources.py +546 -0
  37. agentic_runner/workers/__init__.py +1 -0
  38. agentic_runner/workers/_runtime_support.py +388 -0
  39. agentic_runner/workers/agent_runtime.py +93 -0
  40. agentic_runner/workers/claude_runtime.py +226 -0
  41. agentic_runner/workers/codex_runtime.py +311 -0
  42. agentic_runner/workers/command_policy.py +250 -0
  43. agentic_runner/workers/contract_device_login.py +211 -0
  44. agentic_runner/workers/contract_isolation.py +500 -0
  45. agentic_runner/workers/fastapi_client.py +396 -0
  46. agentic_runner/workers/harness_usage.py +65 -0
  47. agentic_runner/workers/mcp_config.py +111 -0
  48. agentic_runner/workers/settings.py +314 -0
  49. agentic_runner/workstation.py +687 -0
  50. agentic_runner-2.6.0.dist-info/METADATA +49 -0
  51. agentic_runner-2.6.0.dist-info/RECORD +54 -0
  52. agentic_runner-2.6.0.dist-info/WHEEL +4 -0
  53. agentic_runner-2.6.0.dist-info/entry_points.txt +2 -0
  54. 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