fix: drives

This commit is contained in:
Shivering Ocelot committed 2026-10-14 02:01:34 -05:00
1 parent 0d79b1f706
commit b74f7cd3d9
11 files changed
+598 -16

No files matched your search

+169
View File
@@ -0,0 +1,169 @@
# Adding Channel Connections (Telegram, WhatsApp, Discord, Slack) to the Hub Backend
## Purpose & Scope
This report maps the *design seams* in the current Hub backend against the shape of four chat-service APIs, and lays out the realistic integration options. It is deliberately analysis-first: we do not yet have a design for channels, and the goal is to decide where such a feature can live without distorting the existing agent/chat/run model.
Sourced from `docs/agent.md`, `docs/api.md`, `docs/chat.md`, `docs/remotes.md`, `backend/src/app.py`, `backend/src/database.py`, `backend/src/agent.py`, `backend/src/agents/expert.py`, `backend/src/app_mixins/agent.py`, `backend/src/app_mixins/chat.py`.
---
## 1. The Hub model we must fit into
The backend today has four concrete concepts that a channel feature must interact with. Understanding their contracts is what determines the integration options.
### 1.1 Agents
- An agent is created by a user, has a `kind` resolved to a Python class (`agents/expert.py`), and holds config in `data` (schema fields) plus resources.
- Two entry points drive an agent (`docs/agent.md`):
- **`prompt(instruction, input=None, **options)`** — a one-shot; returns an async generator of raw `inference.Message` objects (role + content). Implemented in `agent.py` and mirrored in `AgentMixin.prompt_agent`.
- **`received_message(message)`** — the chat-driven path. It builds a `ChatView` from the message's thread, converts thread messages to LLM messages (roles derived from senders via `_role_for_message`), collects `remote` resources from the thread, creates an `AgentRun`, and streams output/reasoning/tool-call messages *back into the same thread* as new chat messages.
- The agent object is explicitly ephemeral: "Everything durable (config, resources, chat membership) lives in the database or in resources." This matters — a channel feature must not assume the connection survives in an agent object.
### 1.2 Chats, Threads, Messages
- `Chat` is owned by a user (`database.Chat.user`), has messages and threads. `Thread` can be parented (forking) and carries denormalized summary fields (`first_message`, `last_message`, `message_count`).
- `Message.sender` is either the literal string `'user'` or an **agent id**. Content may be a *stream reference* updated in place when the stream finishes.
- Message `kind` ∈ {`message`, `log`, `note`, `resource`}; `resource` messages are declarations (e.g. remotes) and are **not** delivered to agents.
- Chats may be **temporary** (in-memory, via `temporary.py`) or persistent. Temporary ids are distinguished by `tmp.is_tmp_id(...)` throughout the mixins — a branching pattern repeated everywhere (`get_agent`, `get_chat_messages`, `create_chat_thread`, etc.).
### 1.3 Runs
- `AgentRun` is the envelope for all agent activity: input dict (instruction/messages/tools/chat_id/thread_id/remotes), an event log with replay for late subscribers, output/reasoning streams, and cancellation. Runs are always in-memory (`self.tmp.runs`).
- Events are broadcast to websocket clients as `run-event`.
### 1.4 Transports already present
- **Inbound user input** arrives solely through websocket actions (`send-chat-message`, `send-agent-prompt` — see `docs/api.md`), handled in `server/websocket.py` and dispatched into the mixins.
- **Outbound** to the user is the websocket fan-out: `App.user_connections[user_id]` is a list of channels, and the chat mixin pushes packets (`notify_message_to_user`, `thread-update`, etc.) to them.
- **Remotes** (`remote.py`, `docs/remotes.md`) are the closest existing analog to an external integration: connection-scoped, keyed by `(user_id, name)`, in-memory, torn down with the serving session, expanded into tools at run time by `App.getRemoteTools`. They are **inbound-less**: a remote exposes functions the *agent calls out to*; nothing external ever initiates a run.
### 1.5 Key structural observation
There is exactly **one private, user-scoped, session-bound external-integration mechanism today (remotes)**, and it is strictly *agent-tool* shaped. There is **no** notion of an external party initiating a conversation, no external-identity mapping, and no persistent outbound destination. Those three gaps are the whole problem.
---
## 2. What the four chat APIs actually look like
Grouped by the mechanics that matter for integration (inbound delivery, outbound send, credentials/scoping, threading model).
### 2.1 Telegram (Bot API)
- **Transport:** HTTPS REST to `api.telegram.org/bot<token>/…`. Inbound is either **long polling** (`getUpdates`) or a **webhook** (`setWebhook`) — either way we receive `update` JSON objects.
- **Inbound shape:** `update.message.from.{id,username}`, `message.chat.{id,type}`, `message.text`, `message.message_id`, plus `callback_query` for inline buttons.
- **Outbound:** `sendMessage` with `chat_id` + `text`; richer payloads (`sendPhoto`, etc.). Replies can be threaded via `reply_to_message_id`.
- **Credentials:** a single **bot token**; one bot identity per token. No per-workspace OAuth — the mapping unit is *bot ↔ (many) chats*.
- **Threading:** chats/groups are the container; topics exist in forum groups.
- **Server model:** works from a normal always-on backend; long polling needs no public URL, webhooks need one.
### 2.2 WhatsApp (Meta Cloud API / Graph API)
- **Transport:** Graph API `POST /{phone-number-id}/messages` to send. Inbound is **webhooks only** — a GET verification handshake (hub.challenge) then POST payloads.
- **Inbound shape:** `entry[].changes[].value.messages[]` (`from`, `id`, `text.body`, `timestamp`) plus `contacts[]` for profile names; status updates share the envelope.
- **Outbound:** session messages within a **24-hour customer-service window**; outside it, pre-approved **template** messages are required. This is a real product constraint, not a transport detail.
- **Credentials:** business phone number id + access token; per-number scoping. Third-party BSPs common.
- **Threading:** 1:1 conversation keyed by the customer's phone number; no native threads.
- **Server model:** requires a public webhook endpoint.
### 2.3 Discord
- **Transport:** **Gateway websocket** for events (`MESSAGE_CREATE`, `INTERACTION_CREATE`) + REST for sending (`POST /channels/{channel_id}/messages`). Interaction endpoints can also be HTTP webhooks.
- **Inbound shape:** message object with `id`, `channel_id`, `guild_id`, `author.{id,username,bot}`, `content`, `referenced_message` for replies.
- **Credentials:** **bot token**; requires declaring **Gateway intents**, and reading message *content* requires the privileged **MESSAGE_CONTENT intent**. A bot joins a guild/channel by invite.
- **Threading:** channels and threads are first-class (Guild → channel → thread); DMs are a special channel type.
- **Server model:** a *persistent outbound websocket* connection is mandatory (or an interaction webhook). This is a long-lived background task, not request/response.
### 2.4 Slack
- **Transport:** Inbound via **Events API** (HTTP webhook with `url_verification` challenge and HMAC **signing-secret** verification) or **Socket Mode** (websocket, no public URL). Outbound via **Web API** `chat.postMessage` with a bot token (`xoxb-…`).
- **Inbound shape:** event envelope with `team_id`, `event.{type,channel,user,text,ts,thread_ts}`; events include `message`, `app_mention`.
- **Credentials:** per-workspace **OAuth install** producing a bot token scoped by `team_id`. This is a *multi-tenant* model unlike Telegram/Discord's single-bot model.
- **Threading:** messages keyed by `(channel, ts)`; `thread_ts` groups replies.
- **Server model:** webhook (public URL + signature verification) or Socket Mode websocket.
### 2.5 Cross-cutting pattern
Every one of the four reduces to the same three primitives, with different transport details:
1. **An inbound event stream** (webhook or long-lived socket) that yields "someone said something in an external thread."
2. **An outbound send** addressed by a per-service *destination id* (chat_id / phone / channel_id / channel+ts).
3. **A credential/identity scope** (bot token, phone-number id, bot token, workspace token) that a user authorizes once and which the server holds.
The variance is in **transport lifecycle** (poll vs webhook vs persistent socket), **tenancy** (single bot vs per-workspace OAuth), and **product constraints** (WhatsApp's 24h window and templates, Discord's privileged intent). None of these are agent-model questions; they are a new *transport/connection layer*.
---
## 3. The gap between the two
Mapping external concepts onto Hub concepts exposes the missing pieces:
| External concept | Hub equivalent today | Gap |
|---|---|---|
| External user identity (tg id, discord id, slack user, phone) | `Message.sender` = `'user'` or agent id | No external-identity ↔ Hub-user table |
| External thread/channel (tg chat, WS number, dc channel, slack channel+ts) | `Chat` / `Thread` | No persistent binding external-destination ↔ Hub thread |
| Inbound "says something" event | websocket `send-chat-message` | No non-websocket entry point into the chat/agent pipeline |
| Outbound "reply" | websocket fan-out to `user_connections` | No outbound destination; replies only reach live browser sessions |
| Credential per service | remotes `(user_id, name)` in-memory | Must be persisted (bots/workspaces outlive sessions) |
Two of these deserve emphasis because they cut against existing assumptions:
- **Persistence of connections.** Remotes are explicitly session-scoped and torn down with the serving session. Channels cannot be — a Telegram bot token and the resulting conversations exist whether or not a browser is connected. This means channels must be a *database-backed* resource, not an in-memory one like remotes.
- **Bidirectional, unsolicited input.** Every current run originates from an explicit user action (websocket send/prompt). A channel produces runs *on its own*, driven by external events. The run lifecycle (`AgentRun.create` + `received_message`) already supports this, but nothing today *instantiates* it outside a websocket handler.
---
## 4. Integration options
### Option A — Channel as a new App service layer ("channel mixin")
A `ChannelMixin` (parallel to `ChatMixin`/`AgentMixin`) owns channel connections: connect/disconnect actions, per-service adapters, inbound listeners, and an outbound dispatcher. It translates inbound events into `app.send_chat_message(...)` and subscribes to outgoing chat messages to relay them back out.
- **Pros:** matches the existing mixin topology; keeps transport concerns out of agents and chats; naturally bidirectional.
- **Cons:** the outbound leg has no hook today — the chat path only pushes to websocket `user_connections`. We would need an extension point (e.g. an event emitted on message creation that channels subscribe to), or channels would have to be woven into `notify_message_to_user`. This is the main new plumbing either way.
- **Fits:** Telegram, WhatsApp, Slack, Discord all equally; credentials persisted in new tables.
### Option B — Channel as a specialized agent kind
Model each external channel as an agent (kind `telegram`, etc.) that is a member of a chat. Inbound events become `received_message`; the agent's `run` sends output back out via the channel API instead of (or in addition to) streaming into the thread.
- **Pros:** reuses the entire agent lifecycle and membership model; the channel "participates" in chats exactly like other agents; no changes to the chat layer.
- **Cons:** conflates *transport* with *behavior* — a channel bot is not an LLM-driven agent, and forcing the loop (`chat_completion_loop`) onto it is a poor fit; `_role_for_message` and the run-message projection assume LLM roles. Also identity: the external sender must map to a Hub user independently of the agent.
- **Verdict:** attractive for symmetry, but the agent contract (`prompt`, inference loop, tool resolution) is the wrong abstraction for a raw transport adapter.
### Option C — Channel as an extended "remote"
Generalize remotes to include an *inbound* direction: a remote that both exposes functions (outbound tools) and can push external events in.
- **Pros:** reuses the connection registry, `(user_id, name)` scoping, and `getRemoteTools` expansion.
- **Cons:** remotes are deliberately in-memory and session-bound — the exact opposite of what channels need. Extending them would either break their lifecycle guarantees or require persisting them, muddling two different concepts.
### Option D — Channel as a standalone gateway process
A separate service that speaks each chat API and talks to Hub through the existing websocket API (acting as a synthetic user session that sends `send-chat-message` and listens for `run-event`/message broadcasts).
- **Pros:** **zero backend changes**; channels become just another websocket client; deployment-isolated and independently restartable; each service's idiosyncrasies (WhatsApp templates, Discord intents) live outside Hub.
- **Cons:** forces an external identity model into the websocket auth (a synthetic user per channel or shared user); cannot easily correlate an incoming external thread to an existing Hub thread without additional storage it would have to own; and it inherits the websocket API's user-scoped shape awkwardly.
- **Fits:** good as a *stepping stone* and for prototyping; less clean as the long-term home for identity/binding storage that the rest of Hub should know about.
---
## 5. Recommendation
The cleanest structure is **Option A, with Option D's containment discipline**:
1. **Add a database-backed channel layer.** New models (sketch): `ChannelConnection` (user, service kind, credentials, display name), `ChannelIdentity` (connection, external sender id → Hub user), and `ChannelBinding` (connection + external destination id ↔ Hub `chat`/`thread`). This is the piece remotes cannot provide because they are in-memory.
2. **Define one small adapter interface** — e.g. `start()/stop()` for the inbound listener, `send(destination, content)` for outbound, `normalize(event) → {external_destination, external_sender, content}` — and implement it four times. This absorbs all the per-service variance listed in §2.
3. **Inbound** adapters call the existing `ChatMixin`/message-creation path (translating an inbound event into `send_chat_message`), so agents are notified exactly as they are for a websocket message. No agent changes required — `received_message` already turns a thread into a run.
4. **Outbound** requires the *one genuinely new seam*: a message-created/settled event that channels can subscribe to, oriented by `ChatBinding`, so agent replies and streamed message updates relay back to the external service. Today only `App.emit_event`/`user_connections` exist for fan-out; channels should hook the same event stream rather than being spliced into `notify_message_to_user`.
Defer the outbound seam decision only if we choose Option D for a first cut (a channel *is* a websocket client), accepting the identity/binding cost.
## 6. Open questions to resolve before implementation
- **Identity:** do external senders become Hub users (1:1), or do external identities hang off a single owning Hub user? The `Message.sender` contract (`'user'` or agent id) does not currently express a *third-party* user.
- **Threading semantics:** how do Telegram chats / WhatsApp numbers / Discord channels / Slack `(channel, ts)` map onto Hub `Chat` vs `Thread`? One chat with many threads, or many chats?
- **Message kinds:** external messages are plain conversation — confirm they are `kind=message` and that `resource`/`note` (the non-delivered kinds) never leak outward.
- **Temporary vs persistent:** must channel-bound chats be forbidden from being temporary, given `tmp.is_tmp_id` branching everywhere?
- **Transport lifecycle:** Discord/Socket-Mode need persistent background tasks; Telegram/WhatsApp/Slack-webhook need public endpoints. Where does the server lifecycle own these (a `start_server` hook)?
- **WhatsApp constraints:** the 24-hour session window and template requirement are product-level; where are they enforced — adapter, or surfaced to the user as configuration?