Files
hub/.gral/design/shape.md
T
2026-10-14 02:01:34 -05:00

13 KiB

Channel Conversations in the Agent Context: Flatten, Don't Map

Purpose & the decision examined

docs/channels.md settled the transport question (Option A: a builtin channel layer as a new app mixin, database-backed, with a small per-service adapter interface). It explicitly deferred the question this report addresses: once an external conversation reaches an agent, what shape does that conversation take?

Two candidate shapes:

  • Map — bind each external conversation (Telegram chat, WhatsApp number, Discord/Slack channel) to a Hub Chat/Thread, and drive agents through the existing chat path (received_message).
  • Flatten — do not create a Hub chat. The channel layer owns the transcript and presents it to the agent as a plain messages list plus a framing instruction ("this is a conversation on Telegram"), with channel-specific tools (starting with simple outbound text).

This report argues the codebase is already shaped for flatten, identifies exactly which seams it uses, and flags the few that need care. Everything below is grounded in docs/agent.md, docs/api.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, and backend/src/tool.py.


1. Hub has two agent entry points, and only one is chat-shaped

docs/agent.md names them, and agent.py confirms the asymmetry:

Entry point Requires a chat/thread? Produces
prompt(instruction, input=None, **options) No async generator of raw inference.Message (role + content)
received_message(message) Yes — reads message.chat_id / message.thread_id side-effects: streams new messages into the thread

Agent.prompt (agent.py) is the chat-free path: it builds an AgentRun with input={"instruction": instruction, "input": input, **kwargs} and yields every message the run emits. It never touches a chat. This is the entry point flatten should use.

Agent.received_message (agent.py) is the chat-coupled path. It:

  • loads chat.get_thread(message.thread_id) and its page of messages,
  • derives LLM roles from senders via _role_for_message (sender == self.id → assistant, 'user' → user, tmp-* → assistant, else user),
  • collects remote resources from the thread,
  • and, crucially, writes back: each inference event becomes a new message via app.send_chat_message(...) in the same thread, emitting a chat-message run event.

That write-back is the heart of "map". If we flatten, we never call it, and none of its chatter — Chat, Thread, Message rows, membership, tmp.is_tmp_id branching — is required.

The first report said inbound should translate to send_chat_message and let received_message do the rest. That is precisely the mapped-chat choice, and flattening reverses it: inbound goes to a run created via prompt, and outbound is a tool call, not a message insert.


2. The Expert agent's run is already chat-agnostic

ExpertAgent.run (expert.py) consumes only the run input, never a chat:

tools = run.input.get("tools", [])
if run.input.get('tool_policy') != 'caller':
    tools += self.get_tools(run)

messages = inference.build_messages(
    system_prompt=self.config.get("system_prompt", ""),
    instruction=run.input.get("instruction"),
    input=run.input.get("input"),
    messages=run.input.get("messages"),
)
await chat_completion_loop(self.app, run, messages,
                           model_id=self.config.get("model"), tools=tools)

Three consequences for a flattened channel conversation:

  1. Conversation history is just run.input["messages"]. A channel layer passes the reconstructed transcript here. No chat object is involved.
  2. Framing is instruction (and optionally input). "This is a conversation on Telegram" belongs in instruction (per-run), leaving the agent's system_prompt general — or the channel binds a channel-configured agent whose system_prompt already speaks for that service. Both are available; instruction is the lighter, per-conversation lever.
  3. chat_completion_loop is reusable verbatim. It executes tools when tool_policy == 'auto' (the default), feeds results back, and closes the run. A flattened channel run rides this loop unchanged.

Note a config precedence quirk: ExpertAgent.run first overwrites temperature / repetition_penalty / reasoning_effort in run.input from the agent config whenever those are set. So agent config beats per-run input for those three; per-run framing should stay in instruction/input/messages/tools.


3. Outbound becomes a channel tool (this is the whole outbound story)

The first report called the outbound leg "the one genuinely new seam" because the chat path only fans out to websocket user_connections. Flattening dissolves that seam: outbound is a ToolSpec whose func calls the channel adapter's send(destination, content).

tool.py supports exactly this. A ToolSpec carries an optional func, and chat_completion_loop executes it in-loop when policy is auto:

tc.result = await tool.execute_async(**tc.arguments)

So a Telegram/Slack/… adapter constructs, per run, something like ToolSpec(name="send_message", func=partial(adapter.send, destination)), and the loop calls it when the model emits a tool call. Starting with "simple outbound text" is therefore a one-parameter tool (text), with richer payloads (photos, threads, buttons) added later as more tools — matching the report's adapter-interface idea, now expressed as run tools.

3.1 A caveat in prompt_agent that decides how to pass tools

AgentMixin.prompt_agent normalizes options['tools']:

def normalize_tool(t):
    if isinstance(t, dict):
        ... return ToolSpec(name=..., description=..., parameters=...)  # NOTE: no func
    else:
        return t

A dict tool descriptor is rebuilt into a ToolSpec without func — and chat_completion_loop treats a non-callable ToolSpec as cancel_tools = True (it aborts the turn and hands the tool back instead of executing). So:

  • Pass real ToolSpec objects (with func) through options['tools'], not dicts, and the loop executes the outbound send automatically (tool_policy default auto).

3.2 Per-run tool selection is additive, not exclusive

Contrary to docs/agent.md's "if the run carries tools, they are used as-is", the code always adds the agent's own tools unless tool_policy == 'caller':

tools = run.input.get("tools", [])
if run.input.get('tool_policy') != 'caller':
    tools += self.get_tools(run)   # resource tools + remote tools

get_tools merges resource tools and remotes and dedupes by name. So to give a channel agent a clean, channel-only toolset, bind it to an agent with no resource tools and no remotes. Using tool_policy == 'caller' is not a good way to restrict tools — it also stops in-loop execution (the loop stops after emitting tool calls, giving you one-shot, non-iterated tool calls), which defeats the simple outbound-send tool.

3.3 Observability for free

Every run event is broadcast: AgentRun.emit_event calls self.app.emit_event("run-event", ...), and the global bus (app.on_event) plus per-run listeners are available. A channel layer can subscribe to the run's inference / tool_call / done events for logging, metrics, or to observe outbound sends without inventing new plumbing.


4. State, identity, and the transcript

Flattening moves the source of truth for the conversation into the channel layer, which is also where persistence must live (the first report's "Persistence of connections" point). Two facts from the code make this clean:

  • Agents and runs are ephemeral. docs/agent.md and agent.py: runs live in app.tmp.runs, are always in-memory, and are closed with the run. So the transcript cannot live in a run — it must be rebuilt each inbound event from channel-owned storage.
  • Message.sender cannot express a third party. database.Message.sender is a plain CharField, set to the literal 'user' or an agent id; _role_for_message only knows self/user/tmp-*. Flattening sidesteps this entirely: external speakers become ordinary LLM roles in the messages list (with names embedded in content, or as role labels), and no third-party Hub User is needed in the chat model. Identity is a channel-layer concern (which external sender is talking, and whether they are authorized to trigger the agent), not a Hub-chat concern.

So the channel layer owns, at minimum: the connection/credential, the external destination ↔ Hub-agent binding, and the transcript used to build run.input["messages"]. This is a smaller footprint than the mapped-chat option, which additionally needs external-destination ↔ Chat/Thread bindings and would have to answer "one chat with many threads, or many chats?".


5. Flatten vs map — weighted comparison

Dimension Flatten (recommended) Map to Hub Chat/Thread
Entry point prompt (chat-free) received_message (chat-coupled)
Agent sees run.input["messages"] + instruction framing thread history projected via _role_for_message
Outbound channel tool (ToolSpec.func) executed by the loop send_chat_message + websocket/relay fan-out
New backend plumbing channel-owned storage + adapters (needed either way) plus chat/thread bindings and an outbound relay seam
Identity external senders are roles; no third-party User needs an external-identity ↔ User model
Threading semantics channel layer chooses its own conversation model forced to answer Chat-vs-Thread mapping per service
tmp.is_tmp_id branching avoided (no Chat) must handle/handle-forbid temporary chats
Reuse AgentRun, Expert loop, tool execution, run events same, plus the whole chat write-back path
Cost of service differences absorbed by per-channel tools + adapters leaks service quirks into the chat model

The mapped option's only structural advantage — "the channel is just another chat participant" — is largely cosmetic here, because the participant would be a transport adapter, not an LLM agent, and it drags the identity/binding/threading questions into Hub's durable schema.


6. What flattening reuses vs. what must be built

Reused unchanged

  • Agent.prompt + AgentRun (events, streams, cancellation).
  • ExpertAgent.run / chat_completion_loop (inference, tool execution, multi-turn tool loop).
  • AgentMixin.prompt_agent as the mixin-level entry (it forwards instruction, input, options → run input).
  • Global event bus for run observability.

Must be built (channel layer)

  • Adapters with start()/stop(), send(destination, content), normalize(event) (per docs/channels.md §5).
  • Durable storage: connection/credentials, external destination ↔ Hub-agent binding, transcript.
  • Per-run tool construction: ToolSpec objects (with func closures over the adapter + destination), passed via options['tools'].
  • An inbound handler that, per external event, rebuilds run.input["messages"], sets instruction framing, and fires prompt_agent (ideally as a tracked task, for cancellation/reaping).
  • Lifecycle hooks for persistent listeners (Discord gateway, Slack Socket Mode) and public endpoints (WhatsApp/Slack webhooks) — a server-start seam, per the first report.

7. Open questions to resolve before implementation

  1. History window. How much transcript is rebuilt per inbound event, and does the channel layer or the agent own truncation/summarization? Runs are in-memory and closed, so any cross-event memory must be persisted by the channel layer.
  2. Framing channel. Is "this is a conversation on Telegram" carried purely in instruction, or does each channel bind a dedicated agent whose system_prompt already speaks for it? (Recommend a dedicated, resource-free agent per channel binding, so §3.2 gives a clean toolset.)
  3. Tool granularity. Start with one send_message(text) per channel; define how richer features (reply-to, threads, buttons, WhatsApp templates/24h window) surface — as more tools, or as tool parameters.
  4. Sender representation. Embed names in message content (e.g. Alice: …) vs. synthetic roles — and where authorization (is this external sender allowed to drive the agent?) is enforced.
  5. Run lifetime & cancellation. Are inbound runs awaited inline or spawned as tasks, and how are they tracked/cancelled (the run's cancel() and app.tmp.runs are the available hooks)?
  6. The normalize_tool/tool_policy traps. Confirm the contract that channel tools are always passed as executable ToolSpec objects; consider tightening prompt_agent.normalize_tool so a dict descriptor can carry a callable, or documenting the object-only rule.

One-line summary

Flatten is the lower-friction shape: it uses prompt (not received_message), presents the external conversation as run.input["messages"] with a per-channel instruction, and makes outbound a channel-specific executable ToolSpec — reusing the entire run/loop/tool/event machinery while keeping Hub's chat, identity, and threading models out of the channel path.