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,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."""