Skip to content

Hosted channels: generic provider behaviour (provider table, answer and session modes) - #603

Open
alex-clickhouse wants to merge 10 commits into
mainfrom
alex/hosted-channel-behaviour
Open

alex-clickhouse wants to merge 10 commits into
mainfrom
alex/hosted-channel-behaviour

Conversation

@alex-clickhouse

Copy link
Copy Markdown
Collaborator

Stack (merge in this order): this PR (generic hosted channel behaviour) -> #601 the email channel. The channel gateway that sends the new capabilities members and the email events ships first.


What

  • The channel contract model reads two optional members of a connection's
    capabilities: provider and behaviour (answer: post or tool,
    reply_tool, session: rotate or keep). A copy of the contract's
    new sample capabilities_behaviour.json is in
    tests/fixtures/channel_contract/.
  • A table of hosted chat providers (nerve/channels/hosted/providers.py)
    replaces the fixed Slack list. Each entry gives the provider's hosted mode,
    its switch, its channel settings, and what its channel does while the
    provider is switched off. Slack is the only entry.
  • A hosted channel uses only the connections of its own provider when the
    gateway names the provider.
  • A hosted channel rejects an event on a connection that announces another
    provider and sends no operation through such a connection.
  • The router resolves the session of one conversation one message at a
    time, so simultaneous first messages share one session.
  • In tool mode, Nerve posts nothing for a turn: no typing mark, no
    streamed placeholder, no answer, and no "Error:" message. The answer stays
    in the session. Nerve puts one line before the turn's text that says where
    the message came from, that the answer goes to no one, and which tool can
    answer.
  • In keep mode, a conversation continues its last session, however long
    it was idle, while that session is not archived or deleted.

How

  • ConnectionBehaviour and the new Capabilities members follow the
    gateway's checks. Absent members have the defaults post, no tool, and
    rotate, so an older gateway changes nothing.
  • HostedChatProvider entries give hosted, switched_on, settings
    (HostedChannelSettings, with defaults for a provider without a Slack
    section), hold_while_off, and channel_options. Slack holds its events
    while it is off, as before. Any other provider rejects its events while it
    is off (configuration_invalid), so it never stops the inbox reader.
  • StreamManager.connections() and OperationRunner.connections() take an
    optional provider.
  • InboundMessage.post_answer tells the router to skip all channel output
    for a turn. The hosted channel sets it from the behaviour of the event's
    connection and, for keep, sets session_id to the conversation's last
    session.

Why

A provider such as email has no conversation that Nerve can post to, its
conversations can be idle for days, and it has no Slack-shaped settings.
Before this change, Nerve could only serve Slack: a second provider stopped
the inbox reader, shared Slack's connections, and got automatic answers and
new sessions after two idle hours. Email is added on top of this in a
separate PR.

Notes

  • No visible change for Slack. A switched-off hosted Slack still holds the
    events of every hosted provider, as it holds GitHub records today, and a
    hosted channel with 100 turns in progress pauses the reading for every
    provider; both are in docs/config.md.
  • Deploy order: the gateway that sends the new members ships first. Older
    Nerves ignore them.

Tests

New tests/test_hosted_channel_behaviour.py (a second provider without a
config section, refuse while off, scoping with two providers, a connection
of another provider, tool mode posts nothing and a real engine stores the
answer, the note line, keep vs rotate, simultaneous first messages, router
batches) and contract tests, including the fixture's SHA-256. Existing hosted channel tests pass unchanged.

🤖 Generated with Claude Code

@nerveai

nerveai commented Nov 3, 2026 •

Copy link
Copy Markdown

Nerve PR review · ✅ Ready to merge

Updated for 22fb672: another rebase onto main 85bb512. The PR's content is identical (range-diff: 9 same, 1 changed only in context). Main's #415 gave send_file a keyword-only session_id, and the rebase keeps it in the hosted channel and the router (channel.py:698, router.py:486). Verdict unchanged.

This is the base that #601 (email) stacks on. It replaces the hard-coded Slack list with a provider table (hosted/providers.py), scopes each hosted channel to its own provider's connections, and reads a per-connection behaviour (answer: post|tool, session: rotate|keep). When those members are absent, Slack keeps today's behaviour. The router lock lets simultaneous first messages share one session, which helps every channel.

Completeness: a behaviour suite, including a real engine that stores a tool-mode answer; contract tests with a pinned sample; config.md and architecture.md updated; no config keys, no migration.

Worth considering (both matter once #601 adds tool mode):

  • Unknown capabilities default to post/rotate (channel.py:1154), while the reader starts at negotiation (stream.py:442). A backlog read on wake can therefore get a fresh session and no reply-tool note.
  • A tool-mode turn that a drain refuses is already acked and only logged (channel.py:1181).

Checked: full suite in an offline sandbox at 22fb672 (3 chunks): 8866 passed, 39 skipped; test_slack_integration.py excluded (hangs offline on main too); GitHub CI green; merges cleanly into main; range-diff vs a27f301; read router.py, base.py, channel.py · automated review by nerveai — mention @nerveai to ask a follow-up.

@alex-clickhouse
alex-clickhouse force-pushed the alex/hosted-channel-behaviour branch from 8489a28 to a27f301 Compare November 8, 2026 17:23
@alex-clickhouse

Copy link
Copy Markdown
Collaborator Author

Rebased onto main d87526e (8489a28 -> a27f301, the same 10 commits). #601 is restacked on it.

Conflicts and how they were merged:

  • channel.py, runtime.py: main (Settings page, with a Notifications section #625) sends every hosted card with notify and removes HostedChannel(notification_target=...). The provider table keeps that rule: _slack_channel_options gives no notification_target, and the config does not choose the card's conversation.
  • base.py: InboundMessage has both conversation_title (main, Sessions: record and show what started each session #621) and post_answer.
  • router.py: the per-conversation resolution lock stays, and get_active_session gets main's conversation_title.
  • docs/architecture.md: the provider paragraph uses main's notification sentence.

Checked: hosted, channel and contract tests plus test_slack_channel, test_session_origin, test_drain, test_send_file: 1231 passed. Full backend suite on #601's tip (includes this branch): 8681 passed, 38 skipped (= 8719 collected), test_slack_integration.py excluded.

pufit
pufit previously approved these changes Nov 8, 2026
alex-clickhouse and others added 10 commits November 10, 2026 03:15
The gateway can now announce, in the capabilities frame, which
provider a connection belongs to and how Nerve answers its events:
post the answer or let the agent answer with a tool, and rotate or
keep the conversation's session. A hosted channel needs both to serve
a provider other than Slack. Absent members keep today's meaning, so
an older gateway changes nothing. The checks follow the gateway's
contract, and the contract's new sample is copied byte for byte.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The hosted runtime built a channel only for Slack, and the channel read
Slack-shaped settings and a Slack-shaped switch from the provider's
config section. A provider without such a section had a channel that
was never switched on, which stopped the inbox reader for every
provider.

A table of hosted chat providers now gives each provider its hosted
mode, its switch, its channel settings with defaults, and its
presentation rules. Slack is the only entry and keeps its behaviour,
including the pause of intake while it is switched off. A provider
added later rejects its events while it is off, so it never stops the
reader. A channel uses only the connections of its own provider when
the gateway names the provider, for the choice of connection, limits,
availability, notify, and addressed delivery.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A provider such as email has no conversation that Nerve can post a
turn's answer to: the agent answers only with a tool. The router
always showed typing, streamed a placeholder, and posted the answer
or an "Error:" message, so such a provider needed a way to stay
silent.

An inbound message now says whether its turn posts its answer. When
it does not, the router sends nothing to the channel for the turn,
and the answer stays in the session. A hosted channel takes this from
the behaviour that the event's connection announced, and puts one
generic line before the turn's text: where the message came from, that
the answer goes to no one, and which tool can answer. A connection
without an announced behaviour posts, so Slack does not change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A conversation of a provider such as email is one thread that can go
quiet for days, and Nerve's sticky period moved its next message into
a new session after two idle hours. The agent then lost the thread's
context.

A connection that announces the keep session mode now sends each turn
of a conversation to that conversation's last session, however long
it was idle, while the session is not archived or deleted. Rotate, and
a connection that announces nothing, keep the sticky period.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The hosted mode docs now say that a channel uses only the connections
of its own provider, what the answer and session modes of a
connection do, the line that Nerve puts before a turn in tool mode,
and what a switched-off provider does with its events. Hosted Slack
still holds the events of every provider while it is off.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A provider's switch need not be a key named after the provider, so the
log detail of a rejected event says only that the provider is off. The
runtime docstring also says that a provider's switch and settings
follow a reload.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Two messages of a new conversation that arrived together could each
find no session and create one, so one conversation was split across
two sessions. With the keep session mode this breaks the rule of one
session per conversation; in rotate mode it was a race as well.

The router now resolves the session and queues the message under a
lock per channel key. The second message then finds the session that
the first one created, and the queue keeps the arrival order.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A channel remembered the connection of every accepted event and used
it for replies, and it read the behaviour of that connection, without
a check of the connection's provider. An event of one provider on a
connection of another, from a gateway fault or a changed announcement,
could thus take the other provider's behaviour and send through its
connection.

A channel now rejects an event on a connection whose capabilities name
another provider, and it sends no operation through such a connection:
a remembered connection that now serves another provider is skipped,
and the channel chooses among its own connections. A connection that
names no provider still serves every channel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The inbox of the gateway is one ordered queue, so Nerve reads no event
of any provider while one hosted channel has its maximum of turns in
progress. The hosted mode docs now say so where they describe the
switched-off hold.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The copy of the contract's capabilities sample must stay byte for byte
the same as the gateway's, so a test checks its SHA-256. A test with a
real engine, whose model process is a stand-in, shows that the answer
of a tool turn is stored in the session while nothing is posted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants