Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ Abstract communication layer with three components:
Implementations:
- **Telegram** — python-telegram-bot v21+ with partial message streaming (edit-in-place, 1.5s rate limit), inline keyboard buttons for notification questions, `/reply` command for free-text answers; a reply to a question card answers it too. With `telegram.reply_routes_to_origin_session`, a reply routes to the session that produced the replied-to message; the message→session map is the `channel_reply_routes` table (recorded whether or not the flag is on, pruned after 90 days by the maintenance tick), so it survives restarts
- **Slack** — slack_sdk Socket Mode (outbound WebSocket, no public URL) with partial streaming via `chat.update`, Block Kit buttons, `/nerve` slash command, and per-thread sessions. `SlackAccessPolicy` (`slack_access.py`) composes user, channel, and DM guardrails; in channels the bot answers only on mention or in a thread it already owns.
- **Hosted** (`hosted/`): provider traffic that a shared channel gateway holds (`slack.mode: hosted`). Nerve has no provider tokens. Each gateway replica opens a WebSocket to `/_internal/channel/v1/stream`, authenticated with a token that the gateway signs (ES256, issuer `nerve-gateway`, audience `nerve-channel`, subject naming this exact tenant and agent, at most 300 seconds of lifetime). One inbox reader pulls admitted events over the preferred stream: read, process, acknowledge, and read again until a page is empty. `HostedChannel` registers under the provider's name, so the router and tools see `slack` as with Socket Mode, and uses the same session keys. The gateway decides which events invoke the agent: a direct message, a mention of the agent, a thread reply, a reaction, an interaction, or a command. A mention of the agent carries the `self` flag, so intake needs no capabilities. Each invoke event names the person that the gateway resolved from the provider user; in external mode, the router resolves it to a human actor (`ChannelRouter.actor_for_principal`), and the turn runs as that person. An observe event can name the person that the gateway linked to its author; the inbox record shows that person for attribution only, and no actor comes from it. Outbound messages are contract operations: `OperationRunner` (`hosted/operations.py`) sends each on a stream that advertised it for the connection, within the stream and connection in-flight limits, and never sends a side-effecting operation again after its result is lost. File uploads and attachment reads use transfers on the stream of their operation; a read is sent only while its length fits Nerve's stream memory limit. Notification cards carry buttons, and a press arrives as an `interaction` event that answers the notification. A card goes with `notify`, and the gateway posts it in the notification channel that the control plane set for the agent; the config does not choose this conversation. `hosted/contract/` holds the version 1 channel contract frames that Nerve reads and sends. The gateway checks the contract; Nerve checks only the members and values that its code uses, and its size limits.
- **Hosted** (`hosted/`): provider traffic that a shared channel gateway holds (`slack.mode: hosted`). Nerve has no provider tokens. Each gateway replica opens a WebSocket to `/_internal/channel/v1/stream`, authenticated with a token that the gateway signs (ES256, issuer `nerve-gateway`, audience `nerve-channel`, subject naming this exact tenant and agent, at most 300 seconds of lifetime). One inbox reader pulls admitted events over the preferred stream: read, process, acknowledge, and read again until a page is empty. The runtime builds one `HostedChannel` for each provider in the table of hosted chat providers (`hosted/providers.py`), which gives each provider its hosted mode, its switch, and its channel settings, with defaults for a provider whose config has none; Slack is the only entry. `HostedChannel` registers under the provider's name, so the router and tools see `slack` as with Socket Mode, and uses the same session keys. When the gateway names the provider in a connection's capabilities, a channel uses only the connections of its own provider. The connection's behaviour can tell the router to post nothing for a turn (`InboundMessage.post_answer`), so that the agent answers only with a tool, and can keep a conversation in its last session past the sticky period. The gateway decides which events invoke the agent: a direct message, a mention of the agent, a thread reply, a reaction, an interaction, or a command. A mention of the agent carries the `self` flag, so intake needs no capabilities. Each invoke event names the person that the gateway resolved from the provider user; in external mode, the router resolves it to a human actor (`ChannelRouter.actor_for_principal`), and the turn runs as that person. An observe event can name the person that the gateway linked to its author; the inbox record shows that person for attribution only, and no actor comes from it. Outbound messages are contract operations: `OperationRunner` (`hosted/operations.py`) sends each on a stream that advertised it for the connection, within the stream and connection in-flight limits, and never sends a side-effecting operation again after its result is lost. File uploads and attachment reads use transfers on the stream of their operation; a read is sent only while its length fits Nerve's stream memory limit. Notification cards carry buttons, and a press arrives as an `interaction` event that answers the notification. A card goes with `notify`, and the gateway posts it in the notification channel that the control plane set for the agent; the config does not choose this conversation. `hosted/contract/` holds the version 1 channel contract frames that Nerve reads and sends. The gateway checks the contract; Nerve checks only the members and values that its code uses, and its size limits.
- **Web** — Passive channel using gateway WebSocket

Adding a new channel (Discord, WhatsApp, etc.) requires implementing ~5 methods and zero session/routing logic.
Expand Down
45 changes: 42 additions & 3 deletions docs/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -256,7 +256,7 @@ A reload is always explicit. Two things cause one:
| `external_agents.targets` (including each target's `enabled`), `.sync_interval_minutes`, `.conflict_policy` | ✅ from the next sweep, provided at least one target existed at startup (see the restart table) |
| `sessions.sticky_period_minutes` | ✅ |
| `telegram.dm_policy`, `.stream_mode`, `.reply_routes_to_origin_session` | ✅ read per update. Tightening `open` to `pairing` takes effect on the next message; toggling `reply_routes_to_origin_session` takes effect on the next message, and applies to replies to earlier messages too (routes are recorded while it is off); `allowed_users` does not follow it (see the restart table) |
| `slack.*` | ✅ `enabled` starts or stops the channel; same-workspace token changes reconnect and roll back on failure; other changes apply to the next event. In hosted mode `enabled: false` pauses intake, the records of a hosted GitHub source included, and notifications until it is `true` again, `source.*` applies to the next event, and `mode` needs a restart (see the restart table) |
| `slack.*` | ✅ `enabled` starts or stops the channel; same-workspace token changes reconnect and roll back on failure; other changes apply to the next event. In hosted mode `enabled: false` pauses intake, the records of a hosted GitHub source and the events of the other hosted providers included, and notifications until it is `true` again, `source.*` applies to the next event, and `mode` needs a restart (see the restart table) |
| `workflows.*` and `workflows.review_loop.*` — budget caps, concurrency, the warning fraction, iteration and criteria caps, leg engines/models, the verifier sandbox | ✅ read per use, by loops and runs already in flight as well as new ones. The two `enabled` flags and the two loop cadences are the exceptions; see the restart table |
| `provider.*` and the API keys it selects (`aws_region`, `aws_profile`, `aws_access_key_id`, and the effective Anthropic key) | ✅ for sessions started **after** the reload. Each client's environment is built from the live reference when the session is created, by the same seam as `agent.*` below |
| **`agent.*` and `codex.*`**: backend choice and models (`agent.backend`, `agent.cron_model`, `agent.model`, `codex.model`, `codex.cron_model`, `codex.models`), `max_turns`, `agent.effort`/`cron_effort` and `codex.effort_map`, `agent.thinking`, `agent.context_1m*`, `agent.background_agent_permissions`, `agent.agent_teams`, `agent.subagents.*`, idle timeouts, cache TTL, `codex.sandbox`, `.approval_policy`, `.web_search`, `.extra_config`, `.tool_timeout_sec`, `.bin_path`, `.auth`/`.api_key`/`.api_key_env`, `.base_url`, `.pricing`, `.min_version`/`.max_version`, `.ultracode.*` | ✅ for sessions and turns **started after** the reload. The engine and both backends resolve these through one live reference, so a key cannot be hot in one and frozen in the other |
Expand Down Expand Up @@ -1372,7 +1372,10 @@ hosted mode the switch is ignored, with a warning.

Nerve accepts events from every Slack connection that the gateway serves
for this agent. For each conversation, Nerve records the connection of the
latest accepted event.
latest accepted event. When the gateway names the provider of each
connection, the Slack channel uses only the Slack connections for its
replies, notifications, and limits. A connection whose provider the gateway
does not name serves every hosted chat channel.

Each event that starts a turn or answers a notification names the person that
the gateway resolved from the Slack user. In external mode, the turn runs as
Expand All @@ -1394,7 +1397,8 @@ gateway's inbox until a reload sets `enabled` to `true`. While it is paused,
Nerve sends no notifications and refuses `send_channel_message`. A turn that
is already running still sends its reply. Nerve reads nothing from the
gateway's inbox while Slack is paused, so the records of a hosted GitHub
source wait there too (see below).
source and the events of the other hosted providers wait there too (see
below and [Connection behaviour](#connection-behaviour)).

Replies, reactions, and `send_channel_message` go to Slack as operations on
a gateway stream that advertises them for the connection. A reply goes on the
Expand Down Expand Up @@ -1463,6 +1467,41 @@ Slack switched on while GitHub is hosted; `nerve doctor` warns when it is off.
This is a known limitation: a read for each provider on its own, which would
keep GitHub records arriving while hosted Slack is off, is a follow-up.

#### Connection behaviour

Nerve builds one hosted channel for each chat provider in hosted mode; Slack
is the only hosted chat provider of this version. The capabilities that the
gateway sends for a connection can name the connection's provider and a
behaviour, which tells Nerve how to answer the turns of its events and which
session they go to:

| Member | Values | Default | Effect |
|--------|--------|---------|--------|
| `answer` | `post`, `tool` | `post` | `post`: Nerve posts the turn's answer in the conversation, with the typing indicator, the streamed placeholder, and an `Error:` message when the turn fails. `tool`: Nerve posts nothing in the conversation for the turn. The answer stays in the session, and the agent answers only with a tool |
| `reply_tool` | tool name | none | Only with `tool`: the tool that the agent can answer with |
| `session` | `rotate`, `keep` | `rotate` | `rotate`: a conversation gets a new session after `sessions.sticky_period_minutes` of idle time. `keep`: a conversation continues its last session, however long it was idle, until that session is archived or deleted |

Slack connections post and rotate, so a gateway that sends no behaviour
changes nothing. A connection whose capabilities Nerve does not have posts
and rotates too. In `tool` mode, Nerve puts one line before the text of the
turn: "This message came through *provider*. Your answer in this session is
not sent to anyone. To answer, use the *reply tool* tool." Without a reply
tool, the line ends with "This channel does not send answers."

Each hosted chat provider has its own switch. Because the gateway keeps the
events of every provider in one ordered inbox, a provider that is switched
off either holds the events of all providers or refuses its own events.
Hosted Slack holds them: while `slack.enabled` is `false`, Nerve reads
nothing, and the events of the other hosted providers wait in the gateway
inbox too. Any other hosted chat provider refuses: while it is switched off,
Nerve rejects its events and continues to read the events of the other
providers. Nerve also stops reading, for every provider, while one hosted
channel has 100 turns in progress, and continues when one of them ends.

An event on a connection whose capabilities name another provider is
rejected, and Nerve sends no operation of a channel through such a
connection.

### Setting up the Slack app

Socket Mode means the bot dials out to Slack, so Nerve needs no public URL.
Expand Down
6 changes: 6 additions & 0 deletions nerve/channels/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,11 @@ class InboundMessage:
turn as that person. A self-hosted channel has no mapping from a provider
user to a person, so it leaves ``actor`` empty. A hosted channel sets the
person that the channel gateway resolved.

``post_answer`` tells the router to show the turn in the channel. When it
is ``False``, the router sends nothing to the channel for the turn: no
typing indicator, no streamed or final answer, and no error message. The
answer stays in the session, and the agent can answer with a tool.
"""

channel_name: str # "telegram", "web", "discord"
Expand All @@ -64,6 +69,7 @@ class InboundMessage:
metadata: dict[str, Any] = field(default_factory=dict)
actor: Actor | None = None
conversation_title: str = "" # "general"; "" when unknown or direct
post_answer: bool = True


@dataclass
Expand Down
Loading
Loading