-
Notifications
You must be signed in to change notification settings - Fork 21
Add multi-host SDK to the TypeScript client #158
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
connor4312
merged 6 commits into
main
from
colbylwilliams/colbylwilliams-typescript-hosts-module
May 28, 2026
Merged
Changes from 1 commit
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
c9c2b91
Port ahp::hosts module to the TypeScript client
colbylwilliams d071471
Address PR #158 review feedback
colbylwilliams f789db2
Address second round of PR #158 review feedback
colbylwilliams d4a1a6d
Merge remote-tracking branch 'origin/main' into colbylwilliams/colbyl…
colbylwilliams 639866f
Address third round of PR #158 review feedback
colbylwilliams 247e4e5
Merge remote-tracking branch 'origin/main' into colbylwilliams/colbyl…
colbylwilliams File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. | ||
| * | ||
| * 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(); | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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
108
clients/typescript/src/client/hosts/host-client-handle.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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'; |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.