Skip to content

Latest commit

 

History

1 Commit

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 

Repository files navigation

AHP, two windows, one session

One agent session. Two VS Code windows. The host owns the state; the windows are just views.

This repo is the runnable companion to the idea that AHP is the layer nobody notices: not tools (that's MCP), not agent-to-agent delegation (that's A2A), but how a single agent session is served and synchronized to several clients at once. The host holds the state. The clients are views. That one sentence is the whole design, and this repo makes it something you can watch happen.

It is a teaching model, not the Microsoft reference implementation. It reproduces the four load-bearing ideas of the Agent Host Protocol in ~300 lines so the mechanics are legible:

  1. The host is the source of truth. Clients never hold authoritative state.
  2. Immutable state, pure reducers. State only advances through (state, action) => nextState functions with no side effects.
  3. Write-ahead reconciliation. Every mutation gets a monotonic sequence number; clients apply the same ordered actions and cannot diverge.
  4. URI-addressed channels, snapshot + actions. A fresh client gets a snapshot; a reconnecting one replays missed actions from its last seq.

If you've read the post this comes from — MCP, A2A y AHP: tres protocolos y por qué tu agente debería vivir en un contenedor — this is the "sesión como recurso servidor" claim, compiled and running.


What you actually see

Two clients (each standing in for a VS Code Agent Sessions window) connect to the same host and the same session id. Type a prompt in Window A; the agent's reply streams into both windows at once, token by token, identical. That's not two agents answering — it's one session, one host-resident agent, fanned out as ordered actions.

=== WindowA  |  session 4f3a...  |  seq 12 ===       === WindowB  |  session 4f3a...  |  seq 12 ===
  you/other> explain ahp                               you/other> explain ahp
  agent> AHP puts the session on the host: state …     agent> AHP puts the session on the host: state …
[agent is thinking]                                   [agent is thinking]

Put Window B offline with /offline, continue the conversation in Window A, then enter /online in Window B. It reconnects with its last seq and the host replays only the missed actions from that session's write-ahead log. Keep the client process alive during this test: sinceSeq assumes the client still has the local view produced by the earlier snapshot. A brand-new process correctly starts from a fresh snapshot instead.

Captured end-to-end evidence

This is the output of a real npm run verify:reconnect run. The verifier starts the compiled host on a free port and connects real WebSocket clients. Ports, connection ids, and session UUIDs are normalized; sequence numbers and action counts are untouched.

[1/9] HOST
  listening: true
  authoritative state: host

[2/9] SESSION + WINDOW A
  session: <shared-session-id>
  channel: ahp:/sessions/<shared-session-id>
  sync: snapshot at seq 1

[3/9] WINDOW B JOINS
  sync: snapshot at seq 1
  same session: true

[4/9] LIVE SHARING
  prompt sent by: WindowA
  prompt visible in WindowB: true
  streamed answer visible in both windows: true
  both windows at seq: 51

[5/9] WINDOW B OFFLINE
  retained local view at seq: 51
  unrelated session action added: true

[6/9] WINDOW A CONTINUES
  user> explain sync
  agent> AHP puts the session on the host: state is immutable, reducers are pure, and every client applies the same ordered actions. That's why you're seeing this text appear identically in every window connected to me.
  WindowA seq: 124

[7/9] WINDOW B RECONNECTS
  subscribe sinceSeq: 51
  replayed session actions: 72
  caught up to global seq: 124
  second snapshot received: false

[8/9] HOST EVIDENCE
  [ahp-host] wal seq=1 action=session/created session=<shared-session-id> fanout=0
  [ahp-host] session <shared-session-id> created by client <client-id>
  [ahp-host] client <client-id> subscribed channel=ahp:/sessions/<shared-session-id> subscribers=1
  [ahp-host] client <client-id> snapshotted at seq 1
  [ahp-host] client <client-id> subscribed channel=ahp:/sessions/<shared-session-id> subscribers=2
  [ahp-host] client <client-id> snapshotted at seq 1
  [ahp-host] prompt received client=<client-id> session=<shared-session-id> text="hello"
  [ahp-host] wal seq=2 action=turn/user session=<shared-session-id> fanout=2
  [ahp-host] wal seq=3 action=turn/agentStarted session=<shared-session-id> fanout=2
  [ahp-host] wal seq=51 action=turn/agentDone session=<shared-session-id> fanout=2
  [ahp-host] turn complete session=<shared-session-id> seq=2..51 deltas=47
  [ahp-host] wal seq=52 action=session/created session=<unrelated-session-id> fanout=0
  [ahp-host] prompt received client=<client-id> session=<shared-session-id> text="explain sync"
  [ahp-host] wal seq=53 action=turn/user session=<shared-session-id> fanout=1
  [ahp-host] wal seq=54 action=turn/agentStarted session=<shared-session-id> fanout=1
  [ahp-host] wal seq=124 action=turn/agentDone session=<shared-session-id> fanout=1
  [ahp-host] turn complete session=<shared-session-id> seq=53..124 deltas=69
  [ahp-host] client <client-id> resynced channel=ahp:/sessions/<shared-session-id> from seq 51 (+72 actions)

[9/9] CONSISTENCY CHECKS
  other-session actions filtered: true
  shared session byte-identical: true
  final WindowA seq: 124
  final WindowB seq: 124

POC result: PASS

The host evidence makes the ownership boundary visible. It receives each prompt, reduces it into sequenced WAL actions, and reports fanout=2 while both views are subscribed. After Window B disconnects, the next turn reports fanout=1; B later asks for sinceSeq: 51 and receives 72 matching session actions instead of another snapshot.

The global WAL also contains the unrelated session/created action at seq 52. It advances the final global sequence but is excluded from B's channel replay, which is why the consistency checks can prove both channel isolation and a byte-identical final shared session.

Complete sequence

sequenceDiagram
    autonumber
    actor User
    participant A as Window A<br/>thin client
    participant H as AHP Host<br/>source of truth
    participant W as In-memory WAL
    participant G as Host-resident agent
    participant B as Window B<br/>thin client

    User->>A: Start without a session id
    A->>H: createSession(title)
    H->>H: rootReducer(session/created)
    H->>W: append seq 1 + session/created
    H-->>A: sessionCreated(sessionId)

    A->>H: subscribe(ahp:/sessions/id)
    H-->>A: snapshot(state, seq 1)
    H-->>A: syncComplete(snapshot, seq 1)
    A->>A: Store local read-only view

    B->>H: subscribe(same session URI)
    H-->>B: snapshot(state, seq 1)
    H-->>B: syncComplete(snapshot, seq 1)
    B->>B: Store local read-only view

    User->>A: explica como funciona sinceSeq en AHP
    A->>H: prompt(sessionId, text)
    H->>H: Reduce user turn + agentStarted
    H->>W: Append ordered actions
    H->>G: runAgentTurn(text)

    loop Every streamed agent token
        G-->>H: delta
        H->>H: rootReducer(turn/agentDelta)
        H->>W: append next seq + action
        H-->>A: action(seq, channel, delta)
        H-->>B: action(seq, channel, delta)
        A->>A: rootReducer(local view, action)
        B->>B: rootReducer(local view, action)
    end

    Note over A,B: Both views contain identical state and seq

    B-xH: /offline closes WebSocket
    Note over B: Keep local view and lastSeq = k
    User->>A: Send another prompt while B is offline
    A->>H: prompt(sessionId, text)
    H->>W: Append user, started, delta, done actions
    H-->>A: Stream ordered actions

    B->>H: /online reconnects
    B->>H: subscribe(channel, sinceSeq: k)
    H->>W: Read entries where seq > k AND channel matches
    loop Each missed action only
        W-->>H: seq + action
        H-->>B: action(seq, channel, action)
        B->>B: rootReducer(local view, action)
    end
    H-->>B: syncComplete(replay, currentSeq, replayed)
    Note over A,B: Window B catches up without another snapshot
Loading

The sequence has four guarantees: the host is authoritative, mutations enter the WAL before fan-out, all online views reduce the same ordered actions, and a reconnecting view replays only actions newer than its sinceSeq on the subscribed session channel.


The three configurations (and which one this is)

Microsoft's own self-hosting matrix has three shapes. This repo covers the first two locally and the third via the VM:

Config Where the host runs This repo
VS Code + local agent host same machine as the editor npm run host on your box
Agents app + local agent host same machine, headless second client on same box
Agents app / VS Code + remote agent host (SSH / dev tunnel) another machine the Bicep VM in infra/

The remote config is the interesting one, and it's where your IP argument lands: the agent loop and the workspace live on the VM; the client is a thin view reaching in over a tunnel. File edits and the agent's reasoning happen server-side. Nothing about how the agent works materializes on the client.


Preview vs stable: the "two versions" play

A concrete reason to care about "one session, many clients" is running two different VS Code builds against the same session — say Insiders (preview) and Stable — to compare behavior without forking the session state:

  • Point both builds' chat.agentHost.enabled at the same host ("chat.agentHost.enabled": true, then connect to the remote host over a dev tunnel).
  • Because the host owns state and both clients are pure-reducer views, the session is identical in both windows. Any difference you observe is the client, not the conversation — which is exactly what you want when A/B-testing a preview build.
  • In this demo you fake the two builds with two ahp-client processes; swap them for real Insiders + Stable connecting to the VM host and the story is the same.

This is the payoff of moving the session off the window: the session stops being "a transient thing tied to a particular editor window" and becomes a resource two editors can share.


Run it locally

cd ahp-two-window-demo/ahp-demo
npm ci
npm run build

# Terminal 1 — the host (owns all state)
npm run host

# Terminal 2 — Window A (creates a session, prints its id)
npm run client -- --name WindowA

# Terminal 3 — Window B (joins the SAME session)
npm run client -- --name WindowB --session <id-printed-by-WindowA>

Type in either window. Watch both. Try ahp, sync, or ip as prompts — the host-resident agent has canned answers so the demo runs fully offline.

Watch sinceSeq replay missed context

With both windows connected to the same session:

# In Window B: disconnect the transport but keep its local view.
/offline

# In Window A: send one or more prompts while B is offline.
explain sync

# In Window B: reconnect from its last sequence number.
/online

Window B catches up without receiving another snapshot and reports the result in its status line:

[online | replayed <count> actions, caught up to seq <current> | ready — type a prompt]

The host logs the same reconciliation (resynced channel=ahp:/sessions/<id> from seq <last-seq> (+<count> actions)). Replay is scoped to the subscribed session channel, so activity in other sessions is not applied to this view.

Prove the sync invariant without any deps

npm run verify

This reduces one action stream two ways — a fresh client and a late joiner that snapshots mid-stream then replays — and asserts they end byte-identical. That equality is the consistency guarantee AHP gives you.

To exercise the actual WebSocket server, including disconnect, sinceSeq replay, and channel isolation:

npm run verify:reconnect

Run the host on an Azure VM

The "remote agent host" config. The Bicep in infra/ stands up one Ubuntu 24.04 VM, installs Node via cloud-init, and runs the host as a systemd service on port 4611.

az group create -n rg-ahp-demo -l westeurope

az deployment group create \
  -g rg-ahp-demo \
  -f infra/main.bicep \
  -p adminUsername=azureuser \
     adminPublicKey="$(cat ~/.ssh/id_rsa.pub)"

Outputs give you ahpEndpoint (ws://<ip>:4611) and the SSH command. Point your clients at it:

npm run client -- --name WindowA --url ws://<ip>:4611
npm run client -- --name WindowB --url ws://<ip>:4611 --session <id>

Do not ship port 4611 open to the internet. The demo NSG opens it for convenience. In practice, keep the host private and reach it through a VS Code dev tunnel or an SSH forward (ssh -L 4611:localhost:4611 azureuser@<ip>). That's not just hygiene — it's the whole IP argument: if the host is private and the client only holds a view, the client never crosses into your infrastructure.


Where the IP argument comes from

The reason "the host owns state, the client is a view" matters beyond neat sync: the agent loop, the prompt, the tool wiring — the parts that are actually your product — never leave the host. In this demo that's src/agent/agent.ts running inside the host process. Move that host into a Foundry-style VM-isolated sandbox with a private registry and a dedicated identity, and the editor renders turns while the "how" stays in your tenant.

That's the hardened version, and it's a separate repo: foundry-hosted-agent-ip-poc (signed container in a private ACR, VM-isolated sandbox, dedicated Entra identity). This repo is the protocol mechanics; that one is the governance envelope. AHP is the honest hypothesis in between: if the session is a server resource, then serving it from Azure and treating VS Code as a thin AHP client is the natural place for confidentiality to live.

Honesty, same as the post: AHP is designed to host coding-agent harnesses, not to make a Foundry endpoint act as an AHP server. Bridging the two needs a shim (or Microsoft pointing the agent host at a Foundry harness). It's not a supported path today — it's a direction the architecture makes plausible.


Layout

ahp-demo/
├─ src/
│  ├─ host/
│  │  ├─ ahp-host.ts    # the server: owns state, sequences actions, fans out
│  │  ├─ protocol.ts    # shared types (state, actions, wire messages)
│  │  └─ reducers.ts    # pure (state, action) => state — shared with clients
│  └─ agent/
│     └─ agent.ts       # host-resident agent adapter (swap for a real model)
├─ clients/
│  └─ ahp-client.ts     # thin client == one VS Code window (a VIEW)
├─ infra/
│  ├─ main.bicep        # one VM = the remote agent host
│  └─ cloud-init.yaml   # installs Node, runs the host as a systemd service
└─ README.md

Mapping to the real thing

This demo Real AHP
JSON over WebSocket JSON-RPC (WebSocket in practice)
rootReducer / reduceSession reducers copied verbatim from VS Code (MIT), pure functions
seq + in-memory WAL write-ahead reconciliation, ordered actions, resync-or-snapshot
subscribe to ahp:/sessions/<id> URI-addressed channels (sessions, chats, terminals, changesets)
two ahp-client processes two VS Code Agent Sessions windows / AHPX CLI / Agents app
agent.ts in-process first-party adapters (Copilot, Claude Agent SDK, Codex) in the host
the Bicep VM remote agent host over SSH / dev tunnel

Spec and SDKs: https://github.com/microsoft/agent-host-protocol · Concepts: https://code.visualstudio.com/docs/agents/concepts/agent-host

About

Runnable teaching model of AHP: one host owns the state, clients are just views. Immutable state, pure reducers, write-ahead reconciliation, sinceSeq replay.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages