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/slack_adapter.py
Adam Moussa 15a416d31a Add Plane-2 leaf scaffold (pipeline graph, nodes, HITL, transports, CI)
Consolidates the 18 leaf modules from the r720-plane2-scaffold workflow onto
the foundation commit. Full suite: 535 passed, 1 skipped; ruff + format clean.

Built (pre-deployment scaffold only — nothing provisioned/enabled):
- LangGraph pipeline graph.py (INTAKE->CLARIFY->PLAN, interrupt()/resume, checkpointer-injectable)
- nodes: clarifier (98% gate), planner, review_loop (GPT-4.1), builders->candidate diff, verifier
- §3.3.1 HITL: ledger ops, resume_worker, deadline_timer, recovery sweep, responder
- transports: slack / github / claude_code adapters
- ci_gate (pure-code pass/fail), operator_cli, run-team.py entry, P1 sim harness
- ci/agent-team-apply-verify.yml (split untrusted/privileged jobs) — authored, disabled

KNOWN OPEN FINDINGS (verifier/cross-review, not yet fixed — see follow-up):
- builders denylist: 4 execution-proven bypasses (delete, mode-change, copy-to, out-of-scope delete)
- §3.3.1 CAS: BEGIN IMMEDIATE outside try/except; shared-connection txn nesting unsafe under concurrency
- operator_cli: missing re-deliver/force-resume; audit-after-mutate ordering gap
- ci yaml: GPT-4.1 cross-review PASS w/ 4 FIX items (symlink path escape, etc.)
- P1 sim harness models the ledger layer, not real LangGraph interrupt/resume; P1 exit criteria not yet truly proven

Deploy-gated (NOT done): IAM/step-ca/Roles Anywhere/confluence-bot provisioning,
/sh-security-review sign-off, live Slack/CI, rsync, live dry-runs, Adam approval.
2026-06-17 15:16:12 -04:00

353 lines
13 KiB
Python

"""Slack Block Kit transport adapter (design §3.3.1, §7.1 P1).
The first concrete :class:`~agent_team.transport.base.Transport` implementation.
P1 ships the human gate over **one** transport (Slack first), so this adapter
must satisfy the §3.3.1 contracts the durable responder depends on:
* :meth:`SlackTransport.post_question` renders a :class:`QuestionSet` as a Slack
Block Kit message, embeds the ``question_id`` in the message ``callback_id``
(Slack's native callback metadata, the analogue of the GitHub ``<!-- shq:... -->``
marker), posts it via an **injected** poster callable, and returns the Slack
message ``ts`` as the ``channel_ref`` stored on the ledger row.
* :meth:`SlackTransport.parse_answer` normalizes an inbound Slack payload
(an interactive ``block_actions`` callback or a plain text/slash reply) to
``(question_id, answer, via)`` for the first-answer-wins compare-and-set.
This module is **transport I/O only** and carries no live wiring: the network
call is a dependency-injected ``poster`` callable, so the adapter is unit
testable and ships nothing provisioned. A production deployment supplies a
poster backed by the Composio Slack connector or ``slack_sdk`` (design §3 —
"the R720 routes Slack/Jira/Notion through" the Composio connector); the
foundation must not import or require either, so the default poster raises a
clear "not configured" error rather than reaching the network.
"""
from __future__ import annotations
from collections.abc import Callable, Mapping, Sequence
from typing import Any
from agent_team.transport.base import (
NormalizedAnswer,
QuestionSet,
Transport,
)
__all__ = [
"CALLBACK_ID_PREFIX",
"VIA_SLACK",
"SlackPostError",
"SlackPoster",
"SlackTransport",
"build_callback_id",
"build_question_blocks",
"parse_callback_id",
]
# ``via`` channel-identity tag recorded in the ledger's ``answered_via`` column
# for the audit trail (§3.3.1).
VIA_SLACK = "slack"
# Namespacing prefix for the embedded ``callback_id`` so an inbound Slack
# interaction can be unambiguously mapped back to its ledger ``question_id``.
# Mirrors the GitHub ``<!-- shq:... -->`` marker convention (``shq`` = Sea Haven
# question) from the base module.
CALLBACK_ID_PREFIX = "shq"
# Type of the network seam: given the rendered Slack message kwargs, perform the
# ``chat.postMessage`` and return the response payload. The only field this
# adapter requires from the response is the message ``ts`` (the ``channel_ref``).
SlackPoster = Callable[[dict[str, Any]], Mapping[str, Any]]
class SlackPostError(RuntimeError):
"""Raised when posting a Slack message fails or no poster is configured.
The §3.3.1 delivery rule writes the ledger row ``open`` *before* posting, so
a raised :class:`SlackPostError` leaves the row ``open`` with no
``channel_ref`` and the reconcile loop retries delivery idempotently. The
caller (responder/delivery loop) is expected to catch this and leave the row
for reconcile rather than treating the question as delivered.
"""
def _default_poster(_message: dict[str, Any]) -> Mapping[str, Any]:
"""Default poster: refuse to reach the network (pre-deployment scaffolding).
The foundation must not provision or wire live infrastructure, so a
:class:`SlackTransport` constructed without an explicit ``poster`` cannot
post. Production injects a poster backed by the Composio Slack connector or
``slack_sdk``.
"""
raise SlackPostError(
"SlackTransport has no poster configured; inject a Slack poster "
"callable (Composio connector / slack_sdk) before posting."
)
def build_callback_id(question_id: str) -> str:
"""Embed ``question_id`` in a namespaced Slack ``callback_id``.
Slack echoes the message-level ``callback_id`` back on every interaction
payload, so it is the natural carrier for the ledger ``question_id``
(§3.3.1: "Slack ``callback_id``").
"""
return f"{CALLBACK_ID_PREFIX}:{question_id}"
def parse_callback_id(callback_id: str) -> str:
"""Recover the ``question_id`` from a :func:`build_callback_id` value.
Accepts both the namespaced form (``shq:<question_id>``) and a bare
``question_id`` (defensive, in case a payload surfaces the id directly).
Raises :class:`ValueError` on an empty / malformed callback id so a
mis-mapped answer fails loudly instead of being silently mis-attributed.
"""
if not callback_id:
raise ValueError("empty callback_id")
prefix = f"{CALLBACK_ID_PREFIX}:"
if callback_id.startswith(prefix):
question_id = callback_id[len(prefix) :]
if not question_id:
raise ValueError(f"callback_id missing question_id: {callback_id!r}")
return question_id
return callback_id
def build_question_blocks(
question_set: QuestionSet, deadline: str
) -> list[dict[str, Any]]:
"""Render a :class:`QuestionSet` as Slack Block Kit blocks.
Surfaces optional ``context`` (``repo`` / ``summary``) as a context block,
lists the ordered questions, and footnotes the ``deadline`` so Adam sees the
answer window. Returns a plain JSON-serializable list (no Slack SDK types),
keeping the adapter dependency-free.
"""
blocks: list[dict[str, Any]] = [
{
"type": "header",
"text": {
"type": "plain_text",
"text": f"Agent-team needs input (turn {question_set.turn})",
},
}
]
context_elements: list[dict[str, Any]] = []
repo = question_set.context.get("repo")
if repo:
context_elements.append({"type": "mrkdwn", "text": f"*repo:* {repo}"})
summary = question_set.context.get("summary")
if summary:
context_elements.append({"type": "mrkdwn", "text": str(summary)})
if context_elements:
blocks.append({"type": "context", "elements": context_elements})
for index, question in enumerate(question_set.questions, start=1):
blocks.append(
{
"type": "section",
"text": {"type": "mrkdwn", "text": f"*{index}.* {question}"},
}
)
blocks.append(
{
"type": "context",
"elements": [
{"type": "mrkdwn", "text": f"_Reply in this thread by {deadline}._"}
],
}
)
return blocks
def _join_answer_actions(actions: Sequence[Mapping[str, Any]]) -> Any:
"""Reduce one-or-more interactive actions to a single answer value.
A button click yields one action; a multi-select yields several. A single
action collapses to its scalar value; multiple actions return the ordered
list of values so the responder records every selection.
"""
values = [_action_value(action) for action in actions]
if len(values) == 1:
return values[0]
return values
def _action_value(action: Mapping[str, Any]) -> Any:
"""Extract the answer value from one Slack ``block_actions`` action."""
if "value" in action and action["value"] is not None:
return action["value"]
selected = action.get("selected_option")
if isinstance(selected, Mapping):
return selected.get("value")
selected_options = action.get("selected_options")
if isinstance(selected_options, Sequence) and not isinstance(
selected_options, (str, bytes)
):
return [
opt.get("value") for opt in selected_options if isinstance(opt, Mapping)
]
selected_user = action.get("selected_user")
if selected_user is not None:
return selected_user
# Fall back to the action_id so a payload without an explicit value still
# maps to *some* deterministic answer rather than ``None``.
return action.get("action_id")
class SlackTransport(Transport):
"""Concrete Slack Block Kit :class:`Transport` (§3.3.1, P1 — Slack first).
``channel`` is the target Slack channel id. ``poster`` is the injected
network seam (defaults to a non-networking poster that raises, so the
foundation ships nothing live). ``thread_ts`` mode is implicit: when a
``QuestionSet`` is delivered the adapter posts a top-level message and uses
its ``ts`` as the ``channel_ref``; answers arrive as thread replies or
interactive callbacks carrying the embedded ``question_id``.
"""
def __init__(
self,
channel: str,
poster: SlackPoster | None = None,
) -> None:
self.channel = channel
self._poster: SlackPoster = poster if poster is not None else _default_poster
def post_question(
self,
*,
thread_id: str,
question_id: str,
turn: int,
question_set: QuestionSet,
deadline: str,
) -> str:
"""Render + post the question-set; return the Slack ``ts`` channel_ref.
Embeds ``question_id`` in the message ``callback_id`` so an inbound
answer maps back (§3.3.1). On any poster failure raises
:class:`SlackPostError` so the ledger row stays ``open`` for reconcile.
"""
blocks = build_question_blocks(question_set, deadline)
message: dict[str, Any] = {
"channel": self.channel,
"callback_id": build_callback_id(question_id),
"text": (
f"Agent-team needs input on task {thread_id} "
f"(turn {turn}); reply by {deadline}."
),
"blocks": blocks,
"metadata": {
"event_type": "agent_team_question",
"event_payload": {
"thread_id": thread_id,
"question_id": question_id,
"turn": turn,
},
},
}
try:
response = self._poster(message)
except SlackPostError:
raise
except Exception as exc: # noqa: BLE001 — normalize any poster failure
raise SlackPostError(f"Slack post failed: {exc}") from exc
channel_ref = _extract_ts(response)
if not channel_ref:
raise SlackPostError(
"Slack post response missing message 'ts'; cannot record "
f"channel_ref (response keys: {sorted(response.keys())})"
)
return channel_ref
def parse_answer(self, raw: Any) -> tuple[str, Any, str]:
"""Normalize an inbound Slack payload to ``(question_id, answer, via)``.
Handles the two inbound shapes for P1:
* an interactive ``block_actions`` payload — ``callback_id`` carries the
``question_id`` and ``actions[*]`` carry the answer value(s);
* a plain text / slash reply — ``callback_id`` (or ``question_id``)
carries the id and ``text`` / ``answer`` carries the value.
Raises :class:`ValueError` on a payload with no recoverable
``question_id`` so a malformed answer is rejected loudly rather than
mis-attributed.
"""
if not isinstance(raw, Mapping):
raise ValueError(f"Slack payload must be a mapping, got {type(raw)!r}")
question_id = _extract_question_id(raw)
answer = _extract_answer(raw)
normalized = NormalizedAnswer(
question_id=question_id, answer=answer, via=VIA_SLACK
)
return normalized.question_id, normalized.answer, normalized.via
def _extract_ts(response: Mapping[str, Any]) -> str | None:
"""Pull the message ``ts`` from a ``chat.postMessage`` response.
Slack returns the timestamp at the top level (``{"ts": ...}``) and also
nested under ``message`` (``{"message": {"ts": ...}}``); accept either.
"""
ts = response.get("ts")
if ts:
return str(ts)
message = response.get("message")
if isinstance(message, Mapping) and message.get("ts"):
return str(message["ts"])
return None
def _extract_question_id(raw: Mapping[str, Any]) -> str:
"""Recover the ledger ``question_id`` from any supported inbound payload."""
callback_id = raw.get("callback_id")
if callback_id:
return parse_callback_id(str(callback_id))
# Interactive payloads nest the callback metadata under ``view`` / ``message``.
view = raw.get("view")
if isinstance(view, Mapping) and view.get("callback_id"):
return parse_callback_id(str(view["callback_id"]))
message = raw.get("message")
if isinstance(message, Mapping):
metadata = message.get("metadata")
if isinstance(metadata, Mapping):
payload = metadata.get("event_payload")
if isinstance(payload, Mapping) and payload.get("question_id"):
return str(payload["question_id"])
question_id = raw.get("question_id")
if question_id:
return str(question_id)
raise ValueError("Slack payload carries no recoverable question_id")
def _extract_answer(raw: Mapping[str, Any]) -> Any:
"""Recover the answer value from any supported inbound payload."""
actions = raw.get("actions")
if (
isinstance(actions, Sequence)
and not isinstance(actions, (str, bytes))
and actions
):
return _join_answer_actions(
[action for action in actions if isinstance(action, Mapping)]
)
if "answer" in raw:
return raw["answer"]
if "text" in raw:
return raw["text"]
if "value" in raw:
return raw["value"]
raise ValueError("Slack payload carries no recoverable answer")