Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
77 changes: 73 additions & 4 deletions clients/typescript/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,19 @@ modern browsers and Node 21+ without additional runtime dependencies.

## Entry points

The package exposes three subpath exports:
The package exposes four subpath exports:

| Import path | What it gives you |
|---|---|
| `@microsoft/agent-host-protocol` | Wire types, actions, commands, reducers, version constants. No I/O. |
| `@microsoft/agent-host-protocol/client` | `AhpClient`, `Subscription`, `AhpStateMirror`, the `AhpTransport` interface, `InMemoryTransport`, and the error taxonomy. |
| `@microsoft/agent-host-protocol/hosts` | `MultiHostClient`, `HostClientHandle`, `ReconnectPolicy`, `ClientIdStore` (with `InMemoryClientIdStore`), `MultiHostStateMirror`, and the `Host*Error` family. Builds on `/client` to manage one or more host connections with reconnect, generation-checked handles, and fan-in events. |
| `@microsoft/agent-host-protocol/ws` | `WebSocketTransport` — an `AhpTransport` implementation backed by the global `WebSocket`. |

The split mirrors the Rust SDK (`ahp-types`, `ahp`, `ahp-ws`) — wire
types and reducers are decoupled from the client, which is in turn
decoupled from a specific transport.
The split mirrors the Rust SDK (`ahp-types`, `ahp`, `ahp::hosts`,
`ahp-ws`) — wire types and reducers are decoupled from the client,
which is in turn decoupled from a specific transport and from the
multi-host orchestration layer.

## Quickstart

Expand Down Expand Up @@ -135,6 +137,73 @@ A typical app-level reconnect flow is:
5. Re-fetch `listSessions` or other ephemeral data — protocol
notifications are not replayed.

If you'd rather not write that loop yourself, see
[`@microsoft/agent-host-protocol/hosts`](#multi-host-orchestration) —
it ships a `MultiHostClient` that owns the reconnect supervisor,
re-subscribes across reconnects, mirrors root state, and exposes
generation-checked client handles. Single-host consumers use
`MultiHostClient.single(...)`.

## Multi-host orchestration

For apps that talk to **one or more** AHP hosts at once (a local
sessions server plus a tunnel-attached remote, multiple project hosts
in a sidebar, …), the `@microsoft/agent-host-protocol/hosts` entry
point ships `MultiHostClient`:

```ts
import { ActionType } from '@microsoft/agent-host-protocol';
import { WebSocketTransport } from '@microsoft/agent-host-protocol/ws';
import {
MultiHostClient,
type HostTransportFactory,
} from '@microsoft/agent-host-protocol/hosts';

const openLocal: HostTransportFactory = async (_hostId, _signal) =>
WebSocketTransport.connect('ws://localhost:12345');

// Single-host: same API, never see "registry" concepts.
const { multi, host } = await MultiHostClient.single({
id: 'local',
label: 'Local sessions server',
transportFactory: openLocal,
});
console.log(`connected to ${host.label}: ${host.state.status}`);

// Multi-host: add as many as you need.
await multi.addHost({
id: 'tunnel',
label: 'Tunnel',
transportFactory: async (_id, _signal) =>
WebSocketTransport.connect('wss://my-tunnel.example/sessions'),
});

// Fan-in of every inbound event, tagged with host of origin.
for await (const event of multi.events()) {
console.log(`[${event.hostId}] ${event.channel}`, event.event.type);
}
```

Each host runs its own reconnect supervisor with the configured
`ReconnectPolicy` (defaults to exponential backoff from 250 ms to 30 s
with 25 % jitter), re-subscribes to known URIs after reconnects, and
mirrors root state plus a session-summary cache so `MultiHostClient
.aggregatedSessions()` and `aggregatedAgents()` are snapshot reads,
not fan-out subscriptions. Every successful (re)connect bumps a per-
host `generation` counter; `HostClientHandle`s minted at an earlier
generation throw `HostReconnectedError` instead of silently writing to
the new connection.

Persistent `clientId`s are pluggable via the `ClientIdStore`
interface. The default `InMemoryClientIdStore` is session-stable;
production apps that need cross-launch identity wrap their platform's
secure storage (`localStorage`, IndexedDB, Node `fs`,
`safeStorage`, …) in a custom `ClientIdStore`.

For multi-host state, `MultiHostStateMirror` keys per-resource state
by `(hostId, uri)` so URIs that legitimately collide across hosts
(the normal case for session URIs) don't clobber each other.

## Wire types

The wire types under `src/types/` are generated from `types/*.ts` at the
Expand Down
4 changes: 4 additions & 0 deletions clients/typescript/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@
"import": "./dist/client/index.js",
"types": "./dist/client/index.d.ts"
},
"./hosts": {
"import": "./dist/client/hosts/index.js",
"types": "./dist/client/hosts/index.d.ts"
},
"./ws": {
"import": "./dist/ws/index.js",
"types": "./dist/ws/index.d.ts"
Expand Down
60 changes: 60 additions & 0 deletions clients/typescript/src/client/hosts/client-id-store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/**
* Pluggable persistence for stable per-host `clientId`s.
*
* The AHP `reconnect` flow uses `clientId` to identify a logical client
* across reconnects. Apps that need cross-launch identity (i.e. resume
* an in-progress turn after the user kills the app) must persist the
* `clientId` somewhere durable and surface it through a {@link ClientIdStore}.
*
* The default in-memory store ({@link InMemoryClientIdStore}) keeps ids
* stable within a single process but resets on restart — fine for
* tests and ephemeral CLIs. Production apps wrap their platform's
* secure storage (Keychain, `localStorage`, IndexedDB, Node `fs`,
* Electron `safeStorage`, …) in a custom {@link ClientIdStore}.
*
* @module client/hosts/client-id-store
*/

import type { HostId } from './types.js';

/**
* Persistence hook for stable `clientId`s per host.
*
* Implementations must be safe to call concurrently for different host
* ids; the supervisor calls `load` once per `addHost` and `store` once
* per resolved id. The optional `signal` is aborted when the host is
* being removed or the multi-host client is shutting down; long-running
* stores SHOULD honour it where practical.
Comment thread
colbylwilliams marked this conversation as resolved.
*
* Errors surface as {@link ClientIdStoreError} from
* {@link MultiHostClient.addHost} so persistent stores fail loudly
* instead of silently dropping ids.
*/
export interface ClientIdStore {
/** Look up the previously stored `clientId` for `hostId`, if any. */
load(hostId: HostId, signal?: AbortSignal): Promise<string | null>;
/**
* Persist `clientId` for `hostId`. Implementations must overwrite any
* previous value.
*/
store(hostId: HostId, clientId: string, signal?: AbortSignal): Promise<void>;
}

/**
* In-process {@link ClientIdStore} backed by a `Map`.
*
* Survives reconnects within the same process but not restarts. Fine
* for tests, ephemeral CLIs, and as a starting point.
*/
export class InMemoryClientIdStore implements ClientIdStore {
private readonly inner = new Map<HostId, string>();

load(hostId: HostId): Promise<string | null> {
return Promise.resolve(this.inner.get(hostId) ?? null);
}

store(hostId: HostId, clientId: string): Promise<void> {
this.inner.set(hostId, clientId);
return Promise.resolve();
}
}
37 changes: 37 additions & 0 deletions clients/typescript/src/client/hosts/factory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/**
* Pluggable transport factory used by {@link MultiHostClient} to open a
* fresh {@link AhpTransport} for a host on every connect attempt
* (including reconnects).
*
* Consumers refresh tokens, rotate URLs, or pick different backends per
* attempt by inspecting `hostId`. The supplied `AbortSignal` is aborted
* when the host is being removed, manually reconnected, or shut down;
* factories SHOULD propagate the signal into any underlying networking
* primitives that accept one (e.g. `fetch`, `WebSocket` opens via a
* helper that bails on abort) so a slow handshake doesn't block teardown.
*
* @module client/hosts/factory
*/

import type { AhpTransport } from '../transport.js';
import type { HostId } from './types.js';

/**
* Factory that opens (or re-opens) a transport for a host.
*
* Errors are surfaced as the host's `lastError` and trigger the
* reconnect schedule (or the `failed` state if reconnects are disabled
* or attempts are exhausted).
*
* @example
* ```ts
* const factory: HostTransportFactory = async (hostId, signal) => {
* const url = lookupUrl(hostId);
* return WebSocketTransport.connect(url, { signal });
* };
* ```
*/
export type HostTransportFactory = (
hostId: HostId,
signal: AbortSignal,
) => Promise<AhpTransport>;
108 changes: 108 additions & 0 deletions clients/typescript/src/client/hosts/host-client-handle.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
/**
* Generation-checked handle to the underlying single-host {@link AhpClient}.
*
* Issued by {@link MultiHostClient.client}. Every dispatch through this
* handle verifies that the host is still on the generation the handle
* was minted at; if a reconnect has occurred, dispatching throws
* {@link HostReconnectedError} instead of silently writing to the new
* connection. Removing the host marks the handle as shut down and
* subsequent calls throw {@link HostShutDownError}.
*
* @module client/hosts/host-client-handle
*/

import type { CommandMap } from '../../types/common/messages.js';
import type { StateAction } from '../../types/common/actions.js';
import type { URI } from '../../types/common/state.js';
import type { AhpClient, DispatchHandle } from '../client.js';
import {
HostReconnectedError,
HostShutDownError,
type HostId,
} from './types.js';

/**
* Internal handle reference used by the runtime to mint
* {@link HostClientHandle}s. The runtime updates `generation`,
* `currentClient`, and `shutdownReason` as connections come and go;
* minted handles read them through this shared reference.
*
* @internal
*/
export interface HostClientHandleSource {
readonly hostId: HostId;
generation: number;
currentClient: AhpClient | null;
shutdownReason: null | 'removed' | 'shutdown';
}

/**
* Generation-checked wrapper around the per-host {@link AhpClient}.
*
* Acquired via {@link MultiHostClient.client}. Cheap to clone — the
* underlying client and shared state are reference-shared.
*/
export class HostClientHandle {
/** Host this handle was issued for. */
readonly hostId: HostId;
/** Generation this handle was minted at. */
readonly generation: number;

private readonly source: HostClientHandleSource;
private readonly client: AhpClient;

/** @internal */
constructor(source: HostClientHandleSource, generation: number, client: AhpClient) {
this.source = source;
this.hostId = source.hostId;
this.generation = generation;
this.client = client;
}

/**
* Validate this handle against the host's current generation and
* shutdown state. Throws {@link HostShutDownError} or
* {@link HostReconnectedError} on failure.
*/
checkAlive(): void {
if (this.source.shutdownReason !== null) {
throw new HostShutDownError(this.hostId);
}
if (this.source.generation !== this.generation) {
throw new HostReconnectedError(this.hostId, this.generation, this.source.generation);
}
}

/**
* Dispatch an action through this connection, refusing if the
* connection has been replaced by a reconnect or the host has been
* removed.
*/
dispatch(channel: URI, action: StateAction, clientSeq?: number): DispatchHandle {
this.checkAlive();
return this.client.dispatch(channel, action, clientSeq);
}

/**
* Issue an arbitrary typed JSON-RPC request through this connection,
* refusing if the connection has been replaced by a reconnect or the
* host has been removed.
*/
async request<M extends keyof CommandMap>(
method: M,
params: CommandMap[M]['params'],
): Promise<CommandMap[M]['result']> {
this.checkAlive();
return this.client.request(method, params);
}

/**
* Borrow the underlying {@link AhpClient} for advanced use. The
* caller is responsible for not holding it past the next reconnect —
* the returned reference can become stale at any await point.
*/
rawClient(): AhpClient {
this.checkAlive();
return this.client;
}
}
60 changes: 60 additions & 0 deletions clients/typescript/src/client/hosts/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/**
* Public surface of the `@microsoft/agent-host-protocol/hosts` entry point.
*
* The multi-host SDK builds on top of `@microsoft/agent-host-protocol/client`'s
* {@link AhpClient} to manage one or more AHP host connections at once:
* per-host reconnect supervisors, stable `clientId` persistence, a
* generation-checked client-handle escape hatch, fan-in event streams,
* aggregated views, and a host-aware reducer mirror.
*
* Mirrors the Rust `ahp::hosts` module surface.
*
* @module hosts
*/

export { MultiHostClient } from './multi.js';

export { HostClientHandle } from './host-client-handle.js';

export {
// Types
isConnected,
isFailed,
ROOT_RESOURCE_URI,
// Error classes
ClientIdStoreError,
DuplicateHostError,
HostMultiError,
HostReconnectedError,
HostShutDownError,
UnknownHostError,
} from './types.js';
export type {
HostConfig,
HostEvent,
HostHandle,
HostId,
HostState,
HostSubscriptionEvent,
HostedAgent,
HostedSessionSummary,
} from './types.js';

export type { HostTransportFactory } from './factory.js';

export {
attemptsExhausted,
backoffDelayForAttempt,
defaultReconnectPolicy,
delayWithJitter,
disabledPolicy,
exponentialPolicy,
immediateForeverPolicy,
} from './policy.js';
export type { Backoff, ReconnectPolicy } from './policy.js';

export { InMemoryClientIdStore } from './client-id-store.js';
export type { ClientIdStore } from './client-id-store.js';

export { MultiHostStateMirror, hostedResourceKey } from './state-mirror.js';
export type { HostedResourceKey } from './state-mirror.js';
Loading