Skip to content

Clarify: multi-container topology with separate tool-providing and message-sending clients #141

@rwoll

Description

@rwoll

Clarify: multi-container topology with separate tool-providing and message-sending clients

Summary

We want to run a split-process / split-container topology against a single AHP session:

  • Container A — VS Code (plus extensions like the Integrated Browser tool and LSP-backed tools) acting as the tool-providing client.
  • Container B — the AHP host (server), running the agent backend.
  • Container C — an evaluation harness (or any second client) that subscribes to the session, sends user messages, and observes everything that happens, but does not provide tools of its own.

This pattern shows up in evaluations, automation, headless soak tests, "watch-along" UIs, and any setup where the entity driving the conversation is not the entity providing client-side capabilities.

We want to confirm whether AHP supports this topology today, and if so document it; if not, decide what (if anything) needs to change.

Topology

flowchart LR
    subgraph CA["Container A: tool-providing client"]
        VSCode["VS Code\n(clientId: vscode-A)"]
        Browser["Integrated Browser Tool"]
        LSP["LSP extension tools"]
        VSCode -.exposes.-> Browser
        VSCode -.exposes.-> LSP
    end

    subgraph CB["Container B: AHP host"]
        Host["AHP Server\n(session: ahp-session:/abc)"]
        Agent["Agent backend\n(LLM, server tools)"]
        Host --- Agent
    end

    subgraph CC["Container C: eval / driver client"]
        Driver["Eval harness\n(clientId: eval-C)"]
    end

    VSCode <-->|"JSON-RPC over WS\nsubscribe + activeClientChanged\n(tools, customizations)"| Host
    Driver  <-->|"JSON-RPC over WS\nsubscribe + dispatch turnStarted\n(no activeClient claim)"| Host
    Agent   -.->|"toolCallStart\ntoolClientId = vscode-A"| Host
    Host    -.routed to.-> VSCode
Loading

The two key questions for AHP:

  1. Can multiple clients be subscribed to the same session URI at once, each receiving the synchronized state stream?
  2. When client C connects (or sends turnStarted), does it evict client A's tools/customizations, or do both coexist?

What the spec / types say today

The short answer based on reading the spec and the types: yes, this topology is supported, with one important constraint — only one client at a time may hold the active-client role (the slot that provides client-side tools). Plain subscribers and message-senders do not need that role.

Multi-client subscribe is a first-class concept

"A synchronized, multi-client state protocol for AI agent sessions."
README.md

"Root actions go to all clients subscribed to ahp-root://. Session actions go to all clients subscribed to that session's URI. Terminal actions go to all clients subscribed to that terminal's URI."
docs/specification/subscriptions.md

So Container A and Container C can both subscribe("ahp-session:/abc") and both will receive every action envelope (turns, deltas, tool calls, etc.) for that session. No eviction at the subscription layer.

Dispatching actions does not require being the active client

session/turnStarted, session/turnCancelled, session/pendingMessageSet, and most other interactive actions are listed as @clientDispatchable without any "must be active client" precondition. The validation table in docs/specification/session-channel.md does not gate them on active-client identity either.

So Container C can subscribe, dispatch session/turnStarted with a user message, and the host will run the turn. Container A stays untouched.

Client-provided tools live on a single activeClient slot

This is the one place that needs careful thinking. From types/channels-session/state.ts:

export interface SessionState {
  // ...
  /** The client currently providing tools and interactive capabilities to this session */
  activeClient?: SessionActiveClient;
  // ...
}

/**
 * The client currently providing tools and interactive capabilities to a session.
 *
 * Only one client may be active per session at a time. The server SHOULD
 * automatically unset the active client if that client disconnects.
 */
export interface SessionActiveClient {
  clientId: string;
  displayName?: string;
  tools: ToolDefinition[];
  customizations?: CustomizationRef[];
}

And from types/channels-session/actions.ts:

/**
 * The active client for this session has changed.
 *
 * A client dispatches this action with its own `SessionActiveClient` to claim
 * the active role, or with `null` to release it. The server SHOULD reject if
 * another client is already active. The server SHOULD automatically dispatch
 * this action with `activeClient: null` when the active client disconnects.
 */
export interface SessionActiveClientChangedAction { ... }

The pure reducer just replaces the slot (types/channels-session/reducer.ts:597):

case ActionType.SessionActiveClientChanged:
  return {
    ...state,
    activeClient: action.activeClient ?? undefined,
  };

So at the state level there is exactly one active client. The "don't trample an existing claim" behavior is a SHOULD on the server, not a structural invariant.

docs/guide/customizations.md reinforces this:

"When the active client disconnects, the server SHOULD:

  1. Dispatch session/activeClientChanged with activeClient: null to clear the active client (and its tools and customizations).
  2. Allow a reasonable grace period for the client to reconnect.
  3. If the client does not reconnect, cancel any in-progress tool calls owned by that client by dispatching session/toolCallComplete with result.success = false and an appropriate error message."

How tool routing works in the split topology

session/toolCallStart carries toolClientId (actions.ts:143-153, state.ts:908-915):

"If this tool is provided by a client, the clientId of the owning client. Absent for server-side tools. When set, the identified client is responsible for executing the tool and dispatching session/toolCallComplete with the result."

In our scenario:

  1. C dispatches session/turnStarted.
  2. Server runs the agent. LLM picks one of A's tools (e.g. the browser).
  3. Server dispatches session/toolCallStart with toolClientId: "vscode-A".
  4. A sees toolClientId === own clientId, executes the tool, dispatches session/toolCallComplete.
  5. Both A and C observe the entire exchange via their subscriptions.

The server SHOULD reject session/toolCallComplete if the dispatching client is not the toolClientId owner (actions.ts:269-271), so C cannot accidentally answer A's tool calls.

So what is supported, and what is not

✅ Supported today (per spec)

  • Multiple clients subscribed to the same session URI, all receiving synchronized state.
  • Any subscribed client dispatching session/turnStarted, session/turnCancelled, session/pendingMessageSet, etc., regardless of active-client status.
  • A single client owning the activeClient slot, contributing tools/customizations, and executing tool calls scoped to its own clientId via toolClientId.
  • Connecting a second client without an activeClient claim does not evict the first client's tools. Eviction only happens if the second client dispatches session/activeClientChanged with its own info and the server's "SHOULD reject if another client is already active" policy is not enforced (or the server picks last-write-wins).

⚠️ Not supported / underspecified

  1. Two clients each contributing tools at the same time. The state shape is a single activeClient, not a list. If both Container A and Container C wanted to expose distinct toolsets to the same session, that is not expressible today.
  2. Behavior on conflicting activeClient claims. The spec says "server SHOULD reject if another client is already active", but the reducer is last-write-wins, and the rejection path (which error code, what the loser observes) is not normative. For evaluations we want deterministic behavior across hosts.
  3. What "subscribed but not active" clients are allowed to do is implicit. We rely on reading the @clientDispatchable annotations on each action plus the validation table. An explicit "client roles" section in the spec would make this topology easier to reason about.
  4. SessionState.activeClient is a synthesis of three concerns — interactive presence, tool provider, customization provider. In the split topology these can naturally belong to different clients (e.g., A provides tools, C is the interactive presence sending messages). Worth at least documenting that the three concerns are bundled today.

Ask

We are not (yet) asking for a protocol change. We are asking the maintainers to:

  1. Confirm that the split topology described above is intended to work, i.e. that "subscribed observer/driver client" and "active tool-providing client" are distinct roles and can live in different processes/containers.
  2. Document the pattern explicitly somewhere under docs/guide/, ideally with a sequence diagram covering the eval-harness case (driver + tool-provider + host).
  3. Tighten the spec for two edge cases that matter for multi-container deployments:
    • Conflict resolution when a second client dispatches session/activeClientChanged while another active client is connected (reject vs. replace, which error code, what the displaced client observes).
    • Whether reference servers reject session/toolCallComplete, session/toolCallContentChanged, and session/activeClientToolsChanged from clients other than the current activeClient / toolClientId owner.
  4. Decide whether multi-active-client (a list of activeClients, each with disjoint tool namespaces) is on or off the roadmap. If off, capture the rationale; if on, that becomes its own RFC.

If maintainers agree with the analysis above, the practical outcome of this issue is mostly a docs PR plus a small spec tightening — no wire-level changes required for the topology itself.

Metadata

Metadata

Assignees

Labels

under-discussionIssue is under discussion for relevance, priority, approach

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions