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,546 @@
|
|
|
1
|
+
"""User-connected Sources, read on the Runner their owner hosts (PRD issue 50, map 16 A3).
|
|
2
|
+
|
|
3
|
+
The owner's scenario: a contractor whose employer is not on the platform, running this
|
|
4
|
+
Runner on their own laptop, paying their own key. Their mailbox (or Slack identity) is
|
|
5
|
+
read *here*, with a credential that lives only in this host's store, and nothing reaches
|
|
6
|
+
the control plane unless the Product's Intake Lead decides it is work:
|
|
7
|
+
|
|
8
|
+
1. **Poll** each Source the last heartbeat ack assigned, with the value its Credential
|
|
9
|
+
Reference names -- read from the host store at poll time and never logged.
|
|
10
|
+
2. **Filter locally** (16 A4) -- folders, senders, the addressing rule -- before any token.
|
|
11
|
+
3. **Triage** the survivors of one poll as *one* Directive (16 A6: the way a person reads
|
|
12
|
+
a channel in a sweep), through this Runner's own LLM proxy, so the one Usage Record it
|
|
13
|
+
meters rides the heartbeat like any other.
|
|
14
|
+
4. ``ignore`` sends an ids-only report; ``own``/``ask`` go through the create-Work-Record
|
|
15
|
+
seam (16 A10). With the Source's reply toggle on, the Runner answers in the thread with
|
|
16
|
+
the owner's own credential (16 A5); off, it says nothing and the owner is asked.
|
|
17
|
+
|
|
18
|
+
The connectors keep a per-Source high-water mark in memory. It is the connector's cursor,
|
|
19
|
+
not an activity's state (ADR-0013 §3 is about activities); a restart re-reads what is still
|
|
20
|
+
unseen and the control plane's idempotency key lands a re-sent message on its first record.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import asyncio
|
|
26
|
+
import contextlib
|
|
27
|
+
import email
|
|
28
|
+
import imaplib
|
|
29
|
+
import logging
|
|
30
|
+
import smtplib
|
|
31
|
+
from collections.abc import Awaitable, Callable, Mapping, Sequence
|
|
32
|
+
from dataclasses import dataclass, field
|
|
33
|
+
from email.message import EmailMessage
|
|
34
|
+
from email.policy import default as default_policy
|
|
35
|
+
from email.utils import getaddresses, make_msgid, parseaddr
|
|
36
|
+
from typing import Any, Final, Protocol
|
|
37
|
+
from uuid import UUID, uuid4
|
|
38
|
+
|
|
39
|
+
import httpx
|
|
40
|
+
|
|
41
|
+
from agentic_runner.credentials import HostCredentialStore
|
|
42
|
+
from agentic_runner.llm_proxy import LlmProxy
|
|
43
|
+
from agentic_runner.triage_activities import run_triage_turn
|
|
44
|
+
from agentic_runner_contracts.triage import TriageCandidate, TriageDecision, build_prompt, decide
|
|
45
|
+
from agentic_runner_contracts.user_sources import (
|
|
46
|
+
MEMBER_INSTALLS_DISABLED,
|
|
47
|
+
IntakeIgnoredReport,
|
|
48
|
+
IntakeWorkRequest,
|
|
49
|
+
IntakeWorkResponse,
|
|
50
|
+
SourceIntakeFilter,
|
|
51
|
+
SourceStatus,
|
|
52
|
+
UserSourceAssignment,
|
|
53
|
+
UserSourceKind,
|
|
54
|
+
message_ref,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
__all__ = [
|
|
58
|
+
"SLACK_MEMBER_INSTALL_REFUSALS",
|
|
59
|
+
"ImapMailbox",
|
|
60
|
+
"InboundMessage",
|
|
61
|
+
"IntakeStream",
|
|
62
|
+
"ProxyTriage",
|
|
63
|
+
"SlackUserConnector",
|
|
64
|
+
"SourceConnectRefusedError",
|
|
65
|
+
"SourceConnector",
|
|
66
|
+
"UserSourcePoller",
|
|
67
|
+
"passes_filter",
|
|
68
|
+
]
|
|
69
|
+
|
|
70
|
+
_logger = logging.getLogger(__name__)
|
|
71
|
+
|
|
72
|
+
# The Slack errors a workspace that forbids member app installs answers a user token
|
|
73
|
+
# with. Every one of them means the same thing to the owner -- this workspace will not
|
|
74
|
+
# let you connect yourself -- so they collapse into the one documented console message.
|
|
75
|
+
SLACK_MEMBER_INSTALL_REFUSALS: Final = frozenset(
|
|
76
|
+
{"restricted_action", "app_restricted", "access_denied", "team_access_not_granted"}
|
|
77
|
+
)
|
|
78
|
+
_DESCRIPTION_MAX: Final = 4000
|
|
79
|
+
_SLACK_API: Final = "https://slack.com/api"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class SourceConnectRefusedError(RuntimeError):
|
|
83
|
+
"""The Source could not be connected; ``code`` is what the console is told."""
|
|
84
|
+
|
|
85
|
+
def __init__(self, code: str, message: str) -> None:
|
|
86
|
+
super().__init__(message)
|
|
87
|
+
self.code = code
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass(frozen=True)
|
|
91
|
+
class InboundMessage:
|
|
92
|
+
message_id: str
|
|
93
|
+
sender: str
|
|
94
|
+
to: tuple[str, ...]
|
|
95
|
+
cc: tuple[str, ...] = ()
|
|
96
|
+
subject: str = ""
|
|
97
|
+
body: str = ""
|
|
98
|
+
folder: str = "INBOX"
|
|
99
|
+
# Slack only: the thread a reply goes into.
|
|
100
|
+
thread: str | None = None
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class SourceConnector(Protocol):
|
|
104
|
+
async def connect(self) -> None: ...
|
|
105
|
+
|
|
106
|
+
async def fetch_new(self) -> list[InboundMessage]: ...
|
|
107
|
+
|
|
108
|
+
async def reply(self, message: InboundMessage, text: str) -> None: ...
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class IntakeStream(Protocol):
|
|
112
|
+
"""The two outbound calls, signed with the Runner identity (``registration``)."""
|
|
113
|
+
|
|
114
|
+
async def create_work_record(self, request: IntakeWorkRequest) -> IntakeWorkResponse: ...
|
|
115
|
+
|
|
116
|
+
async def report_ignored(self, report: IntakeIgnoredReport) -> None: ...
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
Triage = Callable[
|
|
120
|
+
[UserSourceAssignment, Sequence[InboundMessage]], Awaitable[tuple[TriageDecision, str]]
|
|
121
|
+
]
|
|
122
|
+
ConnectorFactory = Callable[[UserSourceAssignment, str], SourceConnector]
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def passes_filter(
|
|
126
|
+
intake_filter: SourceIntakeFilter, message: InboundMessage, *, address: str
|
|
127
|
+
) -> bool:
|
|
128
|
+
"""The Intake Filter, before any token is spent (16 A4). Folders are the fetch's own
|
|
129
|
+
scope; here are the senders and the addressing rule."""
|
|
130
|
+
|
|
131
|
+
me = address.strip().lower()
|
|
132
|
+
senders = {sender.strip().lower() for sender in intake_filter.sender_ids}
|
|
133
|
+
if senders and message.sender.strip().lower() not in senders:
|
|
134
|
+
return False
|
|
135
|
+
if message.folder not in intake_filter.folders:
|
|
136
|
+
return False
|
|
137
|
+
to = {recipient.lower() for recipient in message.to}
|
|
138
|
+
cc = {recipient.lower() for recipient in message.cc}
|
|
139
|
+
rule = intake_filter.addressing_rule
|
|
140
|
+
if rule == "everything":
|
|
141
|
+
return True
|
|
142
|
+
if rule == "addressed_to_me":
|
|
143
|
+
return me in to
|
|
144
|
+
if rule == "mentions_me":
|
|
145
|
+
return me in to or me in cc
|
|
146
|
+
# ponytail: `replies_in_my_threads` needs the owner's sent mail to know which threads
|
|
147
|
+
# are theirs; it never passes rather than guess, as on the org-installed Source.
|
|
148
|
+
return False
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
# ----------------------------------------------------------------------------- email
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class ImapMailbox:
|
|
155
|
+
"""The owner's mailbox over IMAP (read) and SMTP (reply), with their own credential.
|
|
156
|
+
|
|
157
|
+
Read-only on the mailbox: messages are fetched with ``BODY.PEEK`` from folders opened
|
|
158
|
+
read-only, so a message the Runner has triaged is still unread for its owner.
|
|
159
|
+
"""
|
|
160
|
+
|
|
161
|
+
def __init__(
|
|
162
|
+
self,
|
|
163
|
+
assignment: UserSourceAssignment,
|
|
164
|
+
password: str,
|
|
165
|
+
*,
|
|
166
|
+
imap_factory: Callable[[str, int], Any] | None = None,
|
|
167
|
+
smtp_factory: Callable[[str, int], Any] | None = None,
|
|
168
|
+
) -> None:
|
|
169
|
+
if not assignment.imap_host:
|
|
170
|
+
raise SourceConnectRefusedError("connect_failed", "the Source has no IMAP host")
|
|
171
|
+
self._assignment = assignment
|
|
172
|
+
self._password = password
|
|
173
|
+
self._imap_factory = imap_factory or imaplib.IMAP4_SSL
|
|
174
|
+
self._smtp_factory = smtp_factory or smtplib.SMTP_SSL
|
|
175
|
+
self._high_water: dict[str, int] = {}
|
|
176
|
+
|
|
177
|
+
def __repr__(self) -> str:
|
|
178
|
+
# Never the password, whoever formats this object.
|
|
179
|
+
return f"ImapMailbox({self._assignment.address!r})"
|
|
180
|
+
|
|
181
|
+
async def connect(self) -> None:
|
|
182
|
+
await asyncio.to_thread(self._with_session, lambda _conn: None)
|
|
183
|
+
|
|
184
|
+
async def fetch_new(self) -> list[InboundMessage]:
|
|
185
|
+
return await asyncio.to_thread(self._with_session, self._fetch)
|
|
186
|
+
|
|
187
|
+
async def reply(self, message: InboundMessage, text: str) -> None:
|
|
188
|
+
await asyncio.to_thread(self._send_reply, message, text)
|
|
189
|
+
|
|
190
|
+
def _with_session[T](self, action: Callable[[Any], T]) -> T:
|
|
191
|
+
assignment = self._assignment
|
|
192
|
+
assert assignment.imap_host is not None
|
|
193
|
+
try:
|
|
194
|
+
conn = self._imap_factory(assignment.imap_host, assignment.imap_port)
|
|
195
|
+
except OSError as error:
|
|
196
|
+
raise SourceConnectRefusedError("connect_failed", type(error).__name__) from error
|
|
197
|
+
try:
|
|
198
|
+
try:
|
|
199
|
+
conn.login(assignment.address, self._password)
|
|
200
|
+
except imaplib.IMAP4.error as error:
|
|
201
|
+
# The server's own words can echo what was sent; only the type goes on.
|
|
202
|
+
raise SourceConnectRefusedError("connect_failed", "IMAP login refused") from error
|
|
203
|
+
return action(conn)
|
|
204
|
+
finally:
|
|
205
|
+
with contextlib.suppress(imaplib.IMAP4.error, OSError):
|
|
206
|
+
conn.logout()
|
|
207
|
+
|
|
208
|
+
def _fetch(self, conn: Any) -> list[InboundMessage]:
|
|
209
|
+
messages: list[InboundMessage] = []
|
|
210
|
+
for folder in self._assignment.intake_filter.folders:
|
|
211
|
+
status, _ = conn.select(_quote(folder), readonly=True)
|
|
212
|
+
if status != "OK":
|
|
213
|
+
continue
|
|
214
|
+
high = self._high_water.get(folder, 0)
|
|
215
|
+
status, data = conn.uid("SEARCH", None, f"UID {high + 1}:* UNSEEN")
|
|
216
|
+
if status != "OK" or not data or not data[0]:
|
|
217
|
+
continue
|
|
218
|
+
uids = sorted(int(uid) for uid in data[0].split() if int(uid) > high)
|
|
219
|
+
for uid in uids:
|
|
220
|
+
status, fetched = conn.uid("FETCH", str(uid), "(BODY.PEEK[])")
|
|
221
|
+
raw = _fetched_bytes(fetched) if status == "OK" else None
|
|
222
|
+
if raw is not None:
|
|
223
|
+
messages.append(_parse_email(raw, folder=folder))
|
|
224
|
+
if uids:
|
|
225
|
+
self._high_water[folder] = uids[-1]
|
|
226
|
+
return messages
|
|
227
|
+
|
|
228
|
+
def _send_reply(self, message: InboundMessage, text: str) -> None:
|
|
229
|
+
assignment = self._assignment
|
|
230
|
+
reply = EmailMessage()
|
|
231
|
+
reply["From"] = assignment.address
|
|
232
|
+
reply["To"] = message.sender
|
|
233
|
+
subject = message.subject or ""
|
|
234
|
+
reply["Subject"] = subject if subject.lower().startswith("re:") else f"Re: {subject}"
|
|
235
|
+
reply["Message-ID"] = make_msgid()
|
|
236
|
+
# The thread is the original's Message-ID: every client threads on these two.
|
|
237
|
+
reply["In-Reply-To"] = message.message_id
|
|
238
|
+
reply["References"] = message.message_id
|
|
239
|
+
reply.set_content(text)
|
|
240
|
+
host = assignment.smtp_host or assignment.imap_host
|
|
241
|
+
assert host is not None
|
|
242
|
+
with self._smtp_factory(host, assignment.smtp_port) as smtp:
|
|
243
|
+
smtp.login(assignment.address, self._password)
|
|
244
|
+
smtp.send_message(reply)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _quote(folder: str) -> str:
|
|
248
|
+
return f'"{folder}"' if " " in folder else folder
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _fetched_bytes(fetched: Sequence[Any]) -> bytes | None:
|
|
252
|
+
for part in fetched:
|
|
253
|
+
if isinstance(part, tuple) and len(part) >= 2 and isinstance(part[1], bytes):
|
|
254
|
+
return part[1]
|
|
255
|
+
return None
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _parse_email(raw: bytes, *, folder: str) -> InboundMessage:
|
|
259
|
+
parsed = email.message_from_bytes(raw, policy=default_policy)
|
|
260
|
+
body_part = parsed.get_body(preferencelist=("plain",)) if parsed.is_multipart() else parsed
|
|
261
|
+
body = body_part.get_content() if body_part is not None else ""
|
|
262
|
+
return InboundMessage(
|
|
263
|
+
message_id=str(parsed.get("Message-ID") or f"<{uuid4()}@runner.invalid>").strip(),
|
|
264
|
+
sender=parseaddr(str(parsed.get("From") or ""))[1],
|
|
265
|
+
to=tuple(address for _, address in getaddresses(parsed.get_all("To", []))),
|
|
266
|
+
cc=tuple(address for _, address in getaddresses(parsed.get_all("Cc", []))),
|
|
267
|
+
subject=str(parsed.get("Subject") or ""),
|
|
268
|
+
body=body if isinstance(body, str) else "",
|
|
269
|
+
folder=folder,
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
# ----------------------------------------------------------------------------- slack
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
class SlackUserApi(Protocol):
|
|
277
|
+
async def call(self, method: str, params: Mapping[str, str]) -> dict[str, Any]: ...
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
@dataclass
|
|
281
|
+
class HttpSlackUserApi:
|
|
282
|
+
"""Slack's Web API under the owner's *user* token -- messages go out as them."""
|
|
283
|
+
|
|
284
|
+
token: str
|
|
285
|
+
base_url: str = _SLACK_API
|
|
286
|
+
|
|
287
|
+
def __repr__(self) -> str:
|
|
288
|
+
return "HttpSlackUserApi(<token>)"
|
|
289
|
+
|
|
290
|
+
async def call(self, method: str, params: Mapping[str, str]) -> dict[str, Any]:
|
|
291
|
+
async with httpx.AsyncClient(timeout=30) as client:
|
|
292
|
+
response = await client.post(
|
|
293
|
+
f"{self.base_url}/{method}",
|
|
294
|
+
data=dict(params),
|
|
295
|
+
headers={"authorization": f"Bearer {self.token}"},
|
|
296
|
+
)
|
|
297
|
+
payload: dict[str, Any] = response.json()
|
|
298
|
+
return payload
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
class SlackUserConnector:
|
|
302
|
+
"""The owner's own Slack identity: their direct messages are what is addressed to them.
|
|
303
|
+
|
|
304
|
+
Connected only where the workspace allows member installs; a workspace that does not
|
|
305
|
+
refuses the user token, and the connector says so as :data:`MEMBER_INSTALLS_DISABLED`.
|
|
306
|
+
"""
|
|
307
|
+
|
|
308
|
+
def __init__(self, assignment: UserSourceAssignment, api: SlackUserApi) -> None:
|
|
309
|
+
self._assignment = assignment
|
|
310
|
+
self._api = api
|
|
311
|
+
self._me = assignment.address.rpartition("/")[2]
|
|
312
|
+
self._oldest: dict[str, str] = {}
|
|
313
|
+
|
|
314
|
+
async def connect(self) -> None:
|
|
315
|
+
answer = await self._api.call("auth.test", {})
|
|
316
|
+
if answer.get("ok"):
|
|
317
|
+
self._me = str(answer.get("user_id") or self._me)
|
|
318
|
+
return
|
|
319
|
+
error = str(answer.get("error") or "")
|
|
320
|
+
if error in SLACK_MEMBER_INSTALL_REFUSALS:
|
|
321
|
+
raise SourceConnectRefusedError(
|
|
322
|
+
MEMBER_INSTALLS_DISABLED, "the workspace does not allow member installs"
|
|
323
|
+
)
|
|
324
|
+
raise SourceConnectRefusedError("connect_failed", f"auth.test refused: {error}")
|
|
325
|
+
|
|
326
|
+
async def fetch_new(self) -> list[InboundMessage]:
|
|
327
|
+
listed = await self._api.call("conversations.list", {"types": "im"})
|
|
328
|
+
messages: list[InboundMessage] = []
|
|
329
|
+
for channel in listed.get("channels") or []:
|
|
330
|
+
channel_id = str(channel.get("id"))
|
|
331
|
+
params = {"channel": channel_id}
|
|
332
|
+
if channel_id in self._oldest:
|
|
333
|
+
params["oldest"] = self._oldest[channel_id]
|
|
334
|
+
history = await self._api.call("conversations.history", params)
|
|
335
|
+
for item in reversed(history.get("messages") or []):
|
|
336
|
+
ts = str(item.get("ts"))
|
|
337
|
+
if ts == self._oldest.get(channel_id) or item.get("user") == self._me:
|
|
338
|
+
continue
|
|
339
|
+
messages.append(
|
|
340
|
+
InboundMessage(
|
|
341
|
+
message_id=f"{channel_id}:{ts}",
|
|
342
|
+
sender=str(item.get("user") or ""),
|
|
343
|
+
# A direct message is addressed to its owner by construction,
|
|
344
|
+
# named the way the Source names them for the Intake Filter.
|
|
345
|
+
to=(self._assignment.address,),
|
|
346
|
+
body=str(item.get("text") or ""),
|
|
347
|
+
folder="INBOX",
|
|
348
|
+
thread=str(item.get("thread_ts") or ts),
|
|
349
|
+
)
|
|
350
|
+
)
|
|
351
|
+
self._oldest[channel_id] = ts
|
|
352
|
+
return messages
|
|
353
|
+
|
|
354
|
+
async def reply(self, message: InboundMessage, text: str) -> None:
|
|
355
|
+
channel_id = message.message_id.partition(":")[0]
|
|
356
|
+
await self._api.call(
|
|
357
|
+
"chat.postMessage",
|
|
358
|
+
{"channel": channel_id, "text": text, "thread_ts": message.thread or ""},
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def default_connector(assignment: UserSourceAssignment, secret: str) -> SourceConnector:
|
|
363
|
+
if assignment.kind is UserSourceKind.SLACK_USER:
|
|
364
|
+
return SlackUserConnector(assignment, HttpSlackUserApi(token=secret))
|
|
365
|
+
return ImapMailbox(assignment, secret)
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
# ----------------------------------------------------------------------------- triage
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
@dataclass
|
|
372
|
+
class ProxyTriage:
|
|
373
|
+
"""The Triage Directive's one turn, through this Runner's LLM proxy.
|
|
374
|
+
|
|
375
|
+
Funded on the Intake Lead's Contract and metered by the proxy like any Directive, so
|
|
376
|
+
it leaves exactly one Usage Record -- ``work_record_id`` empty, there is none yet. A
|
|
377
|
+
hang, a refusal or a malformed answer is ``None``, which :func:`decide` answers with
|
|
378
|
+
the keyword fallback (never-guess: what it cannot place is ``ask``).
|
|
379
|
+
"""
|
|
380
|
+
|
|
381
|
+
proxy: LlmProxy
|
|
382
|
+
model: str
|
|
383
|
+
timeout_seconds: float
|
|
384
|
+
client: httpx.AsyncClient | None = None
|
|
385
|
+
|
|
386
|
+
async def __call__(
|
|
387
|
+
self, assignment: UserSourceAssignment, messages: Sequence[InboundMessage]
|
|
388
|
+
) -> tuple[TriageDecision, str]:
|
|
389
|
+
directive_id = f"triage:{uuid4()}"
|
|
390
|
+
candidates = [
|
|
391
|
+
TriageCandidate(agent_id=c.agent_id, specialisation=c.specialisation)
|
|
392
|
+
for c in assignment.candidates
|
|
393
|
+
]
|
|
394
|
+
text = describe(messages)
|
|
395
|
+
prompt = build_prompt(
|
|
396
|
+
message_text=text, intake_brief=assignment.intake_brief, candidates=candidates
|
|
397
|
+
)
|
|
398
|
+
raw = await self._run(assignment, directive_id=directive_id, prompt=prompt)
|
|
399
|
+
return decide(raw, text, candidates=candidates), directive_id
|
|
400
|
+
|
|
401
|
+
async def _run(
|
|
402
|
+
self, assignment: UserSourceAssignment, *, directive_id: str, prompt: str
|
|
403
|
+
) -> str | None:
|
|
404
|
+
return await run_triage_turn(
|
|
405
|
+
self.proxy,
|
|
406
|
+
model=self.model,
|
|
407
|
+
timeout_seconds=self.timeout_seconds,
|
|
408
|
+
prompt=prompt,
|
|
409
|
+
directive_id=directive_id,
|
|
410
|
+
contract_id=assignment.intake_lead_contract_id,
|
|
411
|
+
agent_id=assignment.intake_lead_agent_id,
|
|
412
|
+
client=self.client,
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def describe(messages: Sequence[InboundMessage]) -> str:
|
|
417
|
+
"""One poll's survivors as one description, bounded to what a Work Record takes."""
|
|
418
|
+
|
|
419
|
+
parts = []
|
|
420
|
+
for message in messages:
|
|
421
|
+
head = [f"From: {message.sender}"]
|
|
422
|
+
if message.subject:
|
|
423
|
+
head.append(f"Subject: {message.subject}")
|
|
424
|
+
parts.append("\n".join([*head, message.body.strip()]))
|
|
425
|
+
text = "\n\n---\n\n".join(parts).strip() or "(empty message)"
|
|
426
|
+
return text[:_DESCRIPTION_MAX]
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
# ----------------------------------------------------------------------------- the poller
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
@dataclass
|
|
433
|
+
class UserSourcePoller:
|
|
434
|
+
"""Every assigned Source, one poll at a time: filter, triage once, act on the outcome."""
|
|
435
|
+
|
|
436
|
+
stream: IntakeStream
|
|
437
|
+
store: HostCredentialStore
|
|
438
|
+
triage: Triage
|
|
439
|
+
connector_factory: ConnectorFactory = default_connector
|
|
440
|
+
_assignments: list[UserSourceAssignment] = field(default_factory=list)
|
|
441
|
+
_connectors: dict[UUID, tuple[UserSourceAssignment, SourceConnector]] = field(
|
|
442
|
+
default_factory=dict
|
|
443
|
+
)
|
|
444
|
+
_status: dict[UUID, SourceStatus] = field(default_factory=dict)
|
|
445
|
+
|
|
446
|
+
def assign(self, assignments: Sequence[UserSourceAssignment]) -> None:
|
|
447
|
+
"""What the last ack pushed. Authoritative: a Source not in it is dropped."""
|
|
448
|
+
|
|
449
|
+
self._assignments = list(assignments)
|
|
450
|
+
live = {assignment.source_id for assignment in assignments}
|
|
451
|
+
for source_id in list(self._connectors):
|
|
452
|
+
if source_id not in live:
|
|
453
|
+
del self._connectors[source_id]
|
|
454
|
+
self._status = {k: v for k, v in self._status.items() if k in live}
|
|
455
|
+
|
|
456
|
+
def statuses(self) -> list[SourceStatus]:
|
|
457
|
+
return list(self._status.values())
|
|
458
|
+
|
|
459
|
+
async def poll(self) -> None:
|
|
460
|
+
for assignment in list(self._assignments):
|
|
461
|
+
try:
|
|
462
|
+
await self.poll_one(assignment)
|
|
463
|
+
except Exception as error: # noqa: BLE001 - one Source never stops the others
|
|
464
|
+
_logger.warning(
|
|
465
|
+
"user-connected Source %s poll failed: %s",
|
|
466
|
+
assignment.source_id,
|
|
467
|
+
type(error).__name__,
|
|
468
|
+
)
|
|
469
|
+
|
|
470
|
+
async def poll_one(self, assignment: UserSourceAssignment) -> None:
|
|
471
|
+
connector = await self._connector(assignment)
|
|
472
|
+
if connector is None:
|
|
473
|
+
return
|
|
474
|
+
try:
|
|
475
|
+
messages = await connector.fetch_new()
|
|
476
|
+
except SourceConnectRefusedError as error:
|
|
477
|
+
self._refused(assignment, error.code)
|
|
478
|
+
return
|
|
479
|
+
self._status[assignment.source_id] = SourceStatus(
|
|
480
|
+
source_id=assignment.source_id, connected=True
|
|
481
|
+
)
|
|
482
|
+
survivors = [
|
|
483
|
+
message
|
|
484
|
+
for message in messages
|
|
485
|
+
if passes_filter(assignment.intake_filter, message, address=assignment.address)
|
|
486
|
+
]
|
|
487
|
+
if not survivors:
|
|
488
|
+
return
|
|
489
|
+
decision, directive_id = await self.triage(assignment, survivors)
|
|
490
|
+
refs = [message_ref(message.message_id) for message in survivors]
|
|
491
|
+
if decision.outcome == "ignore":
|
|
492
|
+
await self.stream.report_ignored(
|
|
493
|
+
IntakeIgnoredReport(
|
|
494
|
+
source_id=assignment.source_id,
|
|
495
|
+
product_id=assignment.product_id,
|
|
496
|
+
directive_id=directive_id,
|
|
497
|
+
classification_source=decision.source,
|
|
498
|
+
message_refs=refs,
|
|
499
|
+
)
|
|
500
|
+
)
|
|
501
|
+
return
|
|
502
|
+
await self.stream.create_work_record(
|
|
503
|
+
IntakeWorkRequest(
|
|
504
|
+
source_id=assignment.source_id,
|
|
505
|
+
product_id=assignment.product_id,
|
|
506
|
+
directive_id=directive_id,
|
|
507
|
+
outcome=decision.outcome,
|
|
508
|
+
classification_source=decision.source,
|
|
509
|
+
reason=decision.reason[:500],
|
|
510
|
+
lead_agent_id=decision.lead_agent_id,
|
|
511
|
+
description=describe(survivors),
|
|
512
|
+
requester=survivors[0].sender or assignment.address,
|
|
513
|
+
message_refs=refs,
|
|
514
|
+
)
|
|
515
|
+
)
|
|
516
|
+
if assignment.reply_enabled:
|
|
517
|
+
text = (
|
|
518
|
+
f"Taken -- routed to a Lead. {decision.reason}"
|
|
519
|
+
if decision.outcome == "own"
|
|
520
|
+
else f"I need a hand with this: {decision.reason}"
|
|
521
|
+
)
|
|
522
|
+
await connector.reply(survivors[0], text)
|
|
523
|
+
|
|
524
|
+
async def _connector(self, assignment: UserSourceAssignment) -> SourceConnector | None:
|
|
525
|
+
held = self._connectors.get(assignment.source_id)
|
|
526
|
+
if held is not None and held[0] == assignment:
|
|
527
|
+
return held[1]
|
|
528
|
+
secret = self.store.get(assignment.credential_reference)
|
|
529
|
+
if secret is None:
|
|
530
|
+
self._refused(assignment, "credential_missing")
|
|
531
|
+
return None
|
|
532
|
+
try:
|
|
533
|
+
connector = self.connector_factory(assignment, secret)
|
|
534
|
+
await connector.connect()
|
|
535
|
+
except SourceConnectRefusedError as error:
|
|
536
|
+
self._refused(assignment, error.code)
|
|
537
|
+
return None
|
|
538
|
+
self._connectors[assignment.source_id] = (assignment, connector)
|
|
539
|
+
return connector
|
|
540
|
+
|
|
541
|
+
def _refused(self, assignment: UserSourceAssignment, code: str) -> None:
|
|
542
|
+
self._connectors.pop(assignment.source_id, None)
|
|
543
|
+
self._status[assignment.source_id] = SourceStatus(
|
|
544
|
+
source_id=assignment.source_id, connected=False, error_code=code
|
|
545
|
+
)
|
|
546
|
+
_logger.warning("user-connected Source %s not connected: %s", assignment.source_id, code)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""The Runner's process-local machinery: runtimes, sandbox, seams, settings."""
|