This repository has been archived on 2026-08-04. You can view files and clone it, but cannot push or open issues or pull requests.
orchestrator/agent-team/agent_team/transport/base.py

110 lines
3.9 KiB
Python

"""Transport interface ABC + payload dataclasses (design §3.3.1).
The durable human-in-the-loop responder owns a notify+resume seam that is
transport-agnostic. This module defines the contract every adapter implements:
* :class:`Transport` — abstract base with ``post_question`` (deliver a
question-set, return a ``channel_ref`` that embeds the ``question_id``) and
``parse_answer`` (normalize an inbound raw payload to
``(question_id, answer, via)``).
* :class:`QuestionSet` — the question-set payload carried by a LangGraph
``interrupt()``.
* :class:`NormalizedAnswer` — the normalized inbound answer the responder
feeds into the §3.3.1 first-answer-wins compare-and-set.
Concrete Slack / GitHub / Claude-Code adapters subclass :class:`Transport` in
the leaves. The signatures here are CONTRACTS the leaf builders import
verbatim, so they are explicit and final.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import Any
__all__ = [
"NormalizedAnswer",
"QuestionSet",
"Transport",
]
# Marker template embedded in transports without native callback metadata
# (e.g. a GitHub issue comment), so an inbound answer can be mapped back to its
# question. Slack embeds the question_id in ``callback_id`` instead.
GITHUB_MARKER_TEMPLATE = "<!-- shq:{question_id} -->"
@dataclass
class QuestionSet:
"""A set of questions delivered to Adam for one ``turn`` of a task (§3.3.1).
Carried in the ``interrupt()`` payload alongside ``thread_id``,
``question_id``, ``turn``, ``transport``, and ``deadline``. ``questions`` is
the ordered list of prompts; ``context`` is optional rendering metadata
(repo, summary) the adapter may surface.
"""
thread_id: str
question_id: str
turn: int
questions: list[str]
context: dict[str, Any] = field(default_factory=dict)
@dataclass
class NormalizedAnswer:
"""A transport-normalized inbound answer (§3.3.1).
The responder maps this into the first-answer-wins compare-and-set:
``UPDATE ... SET status='answered' ... WHERE question_id=? AND
status='open'``. ``via`` records the answering channel/identity for the
audit trail (``answered_via``).
"""
question_id: str
answer: Any
via: str
class Transport(ABC):
"""Abstract transport adapter (§3.3.1).
Subclasses implement delivery and answer parsing for one channel. The
ledger and resume worker depend only on this interface, so an adapter can
ship first (Slack) and others follow without touching the durable core.
"""
@abstractmethod
def post_question(
self,
*,
thread_id: str,
question_id: str,
turn: int,
question_set: QuestionSet,
deadline: str,
) -> str:
"""Deliver ``question_set`` and return its ``channel_ref``.
The posted message MUST embed ``question_id`` so an inbound answer can
be mapped back (Slack ``callback_id``; a
``<!-- shq:<question_id> -->`` marker in a GitHub comment). The returned
``channel_ref`` is the transport's locator for the post (Slack message
``ts`` / issue-comment id / Claude session id) and is stored on the
ledger row so reconcile/recovery can act on it (§3.3.1).
"""
raise NotImplementedError
@abstractmethod
def parse_answer(self, raw: Any) -> tuple[str, Any, str]:
"""Normalize an inbound ``raw`` payload to ``(question_id, answer, via)``.
Extracts the embedded ``question_id`` (from the Slack ``callback_id`` /
the GitHub marker / the Claude session), the answer value, and the
``via`` channel identity. The responder feeds the result into the
atomic compare-and-set. Implementations may build a
:class:`NormalizedAnswer` internally and return its fields as the tuple
the contract specifies.
"""
raise NotImplementedError