@nanobpm/urban-agent-client 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +98 -0
- package/dist/client.d.ts +211 -0
- package/dist/client.js +706 -0
- package/dist/index.d.ts +38 -0
- package/dist/index.js +37 -0
- package/dist/protocol.d.ts +16 -0
- package/dist/protocol.js +15 -0
- package/dist/ring.d.ts +103 -0
- package/dist/ring.js +166 -0
- package/dist/testkit.d.ts +53 -0
- package/dist/testkit.js +95 -0
- package/dist/transport.d.ts +62 -0
- package/dist/transport.js +80 -0
- package/package.json +54 -0
- package/src/client.test.ts +829 -0
- package/src/client.ts +829 -0
- package/src/conformance.test.ts +151 -0
- package/src/index.ts +77 -0
- package/src/protocol.ts +46 -0
- package/src/ring.test.ts +155 -0
- package/src/ring.ts +228 -0
- package/src/testkit.ts +112 -0
- package/src/transport.test.ts +77 -0
- package/src/transport.ts +123 -0
package/README.md
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# @nanobpm/urban-agent-client
|
|
2
|
+
|
|
3
|
+
Worker-side client for the **Nano agentic channel** (ADR 0056, epic
|
|
4
|
+
[nanobpm/nano-ide#124](https://github.com/nanobpm/nano-ide/issues/124), slice
|
|
5
|
+
**S9**).
|
|
6
|
+
|
|
7
|
+
A worker uses it — on a connection **separate from the C8 job protocol** — to
|
|
8
|
+
join the one app-tier agentic channel: declare a capability and receive its
|
|
9
|
+
resolved routing tokens, heartbeat/deregister for presence, and stream live
|
|
10
|
+
terminal / command-stream bytes. It speaks the
|
|
11
|
+
[`@nanobpm/agentic`](https://www.npmjs.com/package/@nanobpm/agentic)
|
|
12
|
+
wire contract (S0, via its `@nanobpm/agentic/protocol` subpath) and is held
|
|
13
|
+
to its shared conformance corpus; it never redefines the contract.
|
|
14
|
+
|
|
15
|
+
## What it does
|
|
16
|
+
|
|
17
|
+
- **`REGISTER` → `SERVE`.** Declares a capability (an _enrolment attribute_ —
|
|
18
|
+
never part of a routing token, invariant #3) and resolves the leaf tokens the
|
|
19
|
+
vocab handshake (S3) hands back.
|
|
20
|
+
- **Heartbeat / deregister.** Liveness so the registry ages the worker out on TTL
|
|
21
|
+
(S2); a clean deregister on shutdown.
|
|
22
|
+
- **Produce relay bytes.** Streams output on the `bulk` lane with a monotonic
|
|
23
|
+
per-stream byte offset so the hub-side ring can resume-from-offset (S5).
|
|
24
|
+
- **Hub-down tolerance (invariant #6).** Everything the worker produces goes
|
|
25
|
+
through a bounded, QoS-aware **outbound ring**. The worker keeps producing while
|
|
26
|
+
the hub is gone; on reconnect the buffer drains in strict lane priority
|
|
27
|
+
(`control` > `interactive` > `bulk`), so a bulk-output storm never
|
|
28
|
+
head-of-line-blocks a heartbeat (invariant #5). On overflow the ring sheds the
|
|
29
|
+
oldest **bulk** frame first and never drops a control frame to make room for a
|
|
30
|
+
relay chunk.
|
|
31
|
+
|
|
32
|
+
## Usage
|
|
33
|
+
|
|
34
|
+
```ts
|
|
35
|
+
import { connectAgenticChannel } from "@nanobpm/urban-agent-client";
|
|
36
|
+
|
|
37
|
+
const agent = connectAgenticChannel({ url: process.env.AGENTIC_CHANNEL_URL! });
|
|
38
|
+
|
|
39
|
+
// REGISTER {capability} → SERVE [leaf tokens]. Capability is an enrolment
|
|
40
|
+
// attribute, NOT part of any routing token.
|
|
41
|
+
const { serve } = await agent.register({
|
|
42
|
+
capability: { cognition: "high", weight: 3, family: "opus", host: "cli" },
|
|
43
|
+
});
|
|
44
|
+
// serve === ["planning.spar#red", …] — resolved from the vocab artifact (S3)
|
|
45
|
+
|
|
46
|
+
agent.heartbeat(); // liveness; ages out on TTL if it stops (S2)
|
|
47
|
+
agent.relay("stdout", "hello\n"); // stream terminal bytes on the relay/bulk lane
|
|
48
|
+
// …the client buffers + drains across a hub outage automatically…
|
|
49
|
+
agent.deregister("done"); // best-effort deregister, then close
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
You may `register` / `relay` **before** the channel is open — the frames buffer
|
|
53
|
+
and drain once it connects.
|
|
54
|
+
|
|
55
|
+
### Options
|
|
56
|
+
|
|
57
|
+
| option | default | meaning |
|
|
58
|
+
| --------------------- | ------------------ | -------------------------------------------------------------------- |
|
|
59
|
+
| `url` | — | the agentic channel URL (the app's own bound port) |
|
|
60
|
+
| `instance` | random UUID | stable instance id carried on every presence frame |
|
|
61
|
+
| `capability` | — | capability to (re-)register with; may also be passed to `register()` |
|
|
62
|
+
| `transport` | binary `WebSocket` | injectable `TransportFactory` (tests, custom framing) |
|
|
63
|
+
| `bufferCapacity` | `1024` | outbound ring size in frames |
|
|
64
|
+
| `heartbeatIntervalMs` | `0` (manual) | auto-heartbeat period |
|
|
65
|
+
| `serveTimeoutMs` | `30000` | how long `register()` waits for its `SERVE` |
|
|
66
|
+
| `reconnect` | enabled | backoff policy (`initialDelayMs`, `maxDelayMs`, `factor`) |
|
|
67
|
+
|
|
68
|
+
### Events
|
|
69
|
+
|
|
70
|
+
`onServe`, `onFrame`, `onOpen`, `onClose`, `onError`, `onDrain` — each returns an
|
|
71
|
+
unsubscribe function. Decode/validation/transport errors are surfaced via
|
|
72
|
+
`onError` and are **never thrown**: a malformed inbound frame can never crash the
|
|
73
|
+
worker.
|
|
74
|
+
|
|
75
|
+
## Building blocks
|
|
76
|
+
|
|
77
|
+
The package also exports its internals for reuse and testing:
|
|
78
|
+
|
|
79
|
+
- **`OutboundRing`** — the bounded, QoS-aware buffer/flush-on-reconnect ring.
|
|
80
|
+
- **`websocketTransport` / `TransportFactory`** — the transport seam; supply your
|
|
81
|
+
own to run without a global `WebSocket` or with custom framing.
|
|
82
|
+
- The S0 contract surface it consumes (`encodeFrame`, `decodeFrame`,
|
|
83
|
+
`validatePayload`, `parseToken`, `MESSAGE_FAMILIES`, `QOS_LANES`, …), re-exported
|
|
84
|
+
from `@nanobpm/agentic/protocol`.
|
|
85
|
+
|
|
86
|
+
## Conformance
|
|
87
|
+
|
|
88
|
+
`npm run test:conformance` runs this client against the **shared** adversarial
|
|
89
|
+
corpus at `@nanobpm/agentic/source/protocol/conformance` — golden frames both
|
|
90
|
+
directions, malformed byte vectors, and routing-token vectors — the same corpus
|
|
91
|
+
the S0 codec and the cross-repo c8ctl client are held to. It runs from source
|
|
92
|
+
(no build step) so the repo's `conformance` CI job exercises the real vectors.
|
|
93
|
+
|
|
94
|
+
## Runtime
|
|
95
|
+
|
|
96
|
+
Node ≥ 22.6 (which provides a global `WebSocket`). Ships as source `.ts` plus a
|
|
97
|
+
built `dist`; runs under `node --experimental-strip-types` like the rest of the
|
|
98
|
+
Urban stack.
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { isMessageFamily } from "./protocol.ts";
|
|
2
|
+
import type { Capability, Frame, ServePayload } from "./protocol.ts";
|
|
3
|
+
import type { TransportCloseInfo, TransportFactory } from "./transport.ts";
|
|
4
|
+
export interface ReconnectOptions {
|
|
5
|
+
readonly enabled?: boolean;
|
|
6
|
+
readonly initialDelayMs?: number;
|
|
7
|
+
readonly maxDelayMs?: number;
|
|
8
|
+
readonly factor?: number;
|
|
9
|
+
}
|
|
10
|
+
export interface AgenticClientOptions {
|
|
11
|
+
/**
|
|
12
|
+
* The agentic channel URL (the app's own bound port). Always passed through
|
|
13
|
+
* to the transport factory as its first argument; only the default WebSocket
|
|
14
|
+
* transport requires it, so a custom `transport` may ignore it.
|
|
15
|
+
*/
|
|
16
|
+
readonly url: string;
|
|
17
|
+
/** Stable instance id, carried on every presence frame. Defaults to a random UUID. */
|
|
18
|
+
readonly instance?: string;
|
|
19
|
+
/**
|
|
20
|
+
* Capability declared at REGISTER. Stored so a reconnect re-registers
|
|
21
|
+
* automatically. May be supplied here or later via {@link AgenticClient.register}.
|
|
22
|
+
*/
|
|
23
|
+
readonly capability?: Capability;
|
|
24
|
+
/** Transport factory; defaults to a binary WebSocket. Injected in tests. */
|
|
25
|
+
readonly transport?: TransportFactory;
|
|
26
|
+
/** Outbound buffer size in frames (hub-down tolerance). Default 1024. */
|
|
27
|
+
readonly bufferCapacity?: number;
|
|
28
|
+
/** Auto-heartbeat period in ms; 0/undefined disables the timer (call {@link AgenticClient.heartbeat} manually). */
|
|
29
|
+
readonly heartbeatIntervalMs?: number;
|
|
30
|
+
/** How long {@link AgenticClient.register} waits for its SERVE before rejecting. Default 30s. */
|
|
31
|
+
readonly serveTimeoutMs?: number;
|
|
32
|
+
/** Reconnect/backoff policy. Enabled by default. */
|
|
33
|
+
readonly reconnect?: ReconnectOptions;
|
|
34
|
+
/** Injectable scheduler for reconnect backoff (tests). Defaults to setTimeout. */
|
|
35
|
+
readonly schedule?: (fn: () => void, ms: number) => void;
|
|
36
|
+
}
|
|
37
|
+
export interface RegisterResult {
|
|
38
|
+
/** The resolved leaf routing tokens from the vocab handshake (S3). */
|
|
39
|
+
readonly serve: readonly string[];
|
|
40
|
+
}
|
|
41
|
+
export type AgenticClientState = "idle" | "connecting" | "open" | "closed";
|
|
42
|
+
type Listener<T> = (value: T) => void;
|
|
43
|
+
/** Listener for value-less events (channel open, buffer drained). */
|
|
44
|
+
type VoidListener = () => void;
|
|
45
|
+
/**
|
|
46
|
+
* Worker-side client for the Nano agentic channel (S9).
|
|
47
|
+
*
|
|
48
|
+
* Speaks the S0 wire contract on a connection SEPARATE from the C8 job protocol
|
|
49
|
+
* (invariants #1/#2): it registers a capability, receives its resolved `SERVE`
|
|
50
|
+
* tokens, heartbeats/deregisters, and produces relay bytes. Everything the
|
|
51
|
+
* worker produces goes through a bounded {@link OutboundRing}, so the worker
|
|
52
|
+
* keeps producing across a hub outage and drains — in strict QoS order — on
|
|
53
|
+
* reconnect (invariants #5/#6). Capability is an enrolment attribute, never a
|
|
54
|
+
* routing token (invariant #3).
|
|
55
|
+
*/
|
|
56
|
+
export declare class AgenticClient {
|
|
57
|
+
readonly instance: string;
|
|
58
|
+
private readonly url;
|
|
59
|
+
private readonly transportFactory;
|
|
60
|
+
private readonly ring;
|
|
61
|
+
private readonly heartbeatIntervalMs;
|
|
62
|
+
private readonly serveTimeoutMs;
|
|
63
|
+
private readonly reconnectPolicy;
|
|
64
|
+
private readonly schedule;
|
|
65
|
+
private capability;
|
|
66
|
+
private transport;
|
|
67
|
+
private state;
|
|
68
|
+
private seq;
|
|
69
|
+
private reconnectDelay;
|
|
70
|
+
private reconnecting;
|
|
71
|
+
private closedByCaller;
|
|
72
|
+
private closeHandled;
|
|
73
|
+
private heartbeatTimer;
|
|
74
|
+
private pendingServe;
|
|
75
|
+
private lastServe;
|
|
76
|
+
private readonly relayOffsets;
|
|
77
|
+
private readonly serveListeners;
|
|
78
|
+
private readonly frameListeners;
|
|
79
|
+
private readonly openListeners;
|
|
80
|
+
private readonly closeListeners;
|
|
81
|
+
private readonly errorListeners;
|
|
82
|
+
private readonly drainListeners;
|
|
83
|
+
constructor(options: AgenticClientOptions);
|
|
84
|
+
/** Current connection state. */
|
|
85
|
+
get connectionState(): AgenticClientState;
|
|
86
|
+
/** True when the transport is open and draining live. */
|
|
87
|
+
get connected(): boolean;
|
|
88
|
+
/** Number of frames currently buffered awaiting a live channel. */
|
|
89
|
+
get buffered(): number;
|
|
90
|
+
/** The most recently resolved SERVE token set (empty until the first SERVE). */
|
|
91
|
+
get serve(): readonly string[];
|
|
92
|
+
/** Open the transport. Safe to call once; reconnects are automatic. A no-op after close() (terminal). */
|
|
93
|
+
connect(): void;
|
|
94
|
+
/**
|
|
95
|
+
* Declare a capability and await the resolved SERVE tokens.
|
|
96
|
+
*
|
|
97
|
+
* The REGISTER frame is buffered like any other outbound frame, so calling
|
|
98
|
+
* `register` while the hub is down does not fail — it enqueues and resolves
|
|
99
|
+
* once the channel comes back and the hub answers with SERVE. Capability is an
|
|
100
|
+
* enrolment attribute; it never becomes part of a routing token (invariant #3).
|
|
101
|
+
*/
|
|
102
|
+
register(input?: {
|
|
103
|
+
capability?: Capability;
|
|
104
|
+
}): Promise<RegisterResult>;
|
|
105
|
+
/** Produce a single liveness heartbeat (control lane). Ages out on TTL if it stops (S2). */
|
|
106
|
+
heartbeat(): void;
|
|
107
|
+
/**
|
|
108
|
+
* Produce relay bytes on the bulk lane. `chunk` is the terminal/command output
|
|
109
|
+
* for `stream`; the client tracks a monotonic per-stream byte offset so the
|
|
110
|
+
* hub-side ring can resume-from-offset after a consumer reconnect (S5). Bytes
|
|
111
|
+
* are UTF-8-encoded on the wire as the payload's `chunk` string.
|
|
112
|
+
*/
|
|
113
|
+
relay(stream: string, chunk: string): void;
|
|
114
|
+
/**
|
|
115
|
+
* Deregister and close. Sends a deregister frame best-effort — only when the
|
|
116
|
+
* channel is currently open. `close()` is terminal and releases the buffer, so
|
|
117
|
+
* a deregister enqueued while disconnected could never drain; enqueuing it then
|
|
118
|
+
* would just pin an unsendable frame until close() drops it. When the channel
|
|
119
|
+
* is down we therefore skip the frame and tear down directly, without
|
|
120
|
+
* reconnecting.
|
|
121
|
+
*/
|
|
122
|
+
deregister(reason?: string): void;
|
|
123
|
+
/** Tear down the client: stop the heartbeat, close the transport, stop reconnecting. */
|
|
124
|
+
close(): void;
|
|
125
|
+
/** Subscribe to resolved SERVE tokens (fires on every SERVE, including reconnects). */
|
|
126
|
+
onServe(listener: Listener<ServePayload>): () => void;
|
|
127
|
+
/** Subscribe to every validated inbound frame. */
|
|
128
|
+
onFrame(listener: Listener<Frame>): () => void;
|
|
129
|
+
/** Subscribe to channel-open events (fires on first connect and each reconnect). */
|
|
130
|
+
onOpen(listener: VoidListener): () => void;
|
|
131
|
+
/** Subscribe to channel-close events. */
|
|
132
|
+
onClose(listener: Listener<TransportCloseInfo>): () => void;
|
|
133
|
+
/** Subscribe to transport / decode / validation errors (never thrown; always non-fatal). */
|
|
134
|
+
onError(listener: Listener<Error>): () => void;
|
|
135
|
+
/** Subscribe to buffer-drained events (fires when the outbound ring empties after sending). */
|
|
136
|
+
onDrain(listener: VoidListener): () => void;
|
|
137
|
+
private openTransport;
|
|
138
|
+
private handleOpen;
|
|
139
|
+
private handleClose;
|
|
140
|
+
private scheduleReconnect;
|
|
141
|
+
private handleFrame;
|
|
142
|
+
private handleServe;
|
|
143
|
+
private enqueueRegister;
|
|
144
|
+
private enqueue;
|
|
145
|
+
/** True once close() has been called — the terminal state (see {@link close}). */
|
|
146
|
+
private get isClosed();
|
|
147
|
+
/**
|
|
148
|
+
* Categorical guard for every outbound-producing surface: once the client is
|
|
149
|
+
* closed (terminal), the transport can never reopen and the buffer has been
|
|
150
|
+
* released, so any frame produced here can never drain. Rather than silently
|
|
151
|
+
* re-grow the ring close() emptied — and mislead the caller — refuse and
|
|
152
|
+
* surface the misuse via onError. Returns true when the call was refused.
|
|
153
|
+
*/
|
|
154
|
+
private refuseWhenClosed;
|
|
155
|
+
/**
|
|
156
|
+
* Validate an outbound payload against the S0 contract before it is buffered.
|
|
157
|
+
* An unsendable frame is never enqueued (so it can't silently occupy buffer
|
|
158
|
+
* space and then be dropped at encode time); the error is surfaced and, when
|
|
159
|
+
* it is the REGISTER we are awaiting a SERVE for, the register promise is
|
|
160
|
+
* failed fast instead of hanging until the serve timeout (or forever when the
|
|
161
|
+
* timeout is disabled). Returns true when the payload was rejected.
|
|
162
|
+
*/
|
|
163
|
+
private rejectInvalidOutbound;
|
|
164
|
+
/** Drain the ring to the transport in strict QoS order until it empties or a send fails. */
|
|
165
|
+
private pump;
|
|
166
|
+
/**
|
|
167
|
+
* Drop every buffered frame of `family` from the ring, returning them. The
|
|
168
|
+
* single source of truth for outbound coalescing: register coalescing (a
|
|
169
|
+
* superseding `register()` and a reconnect's `handleOpen()`) and heartbeat
|
|
170
|
+
* coalescing both use it so the drain never emits a stale or duplicate frame.
|
|
171
|
+
*/
|
|
172
|
+
private removeBuffered;
|
|
173
|
+
/**
|
|
174
|
+
* Tear down the current transport and route through the normal close/reconnect
|
|
175
|
+
* path when a send throws but the transport does not (or has not yet) fired its
|
|
176
|
+
* own onClose. `handleClose` is idempotent for this connection attempt, so if
|
|
177
|
+
* the transport DOES also emit onClose the second pass is a no-op.
|
|
178
|
+
*/
|
|
179
|
+
private forceReconnect;
|
|
180
|
+
private nextSeq;
|
|
181
|
+
private startHeartbeatTimer;
|
|
182
|
+
private stopHeartbeatTimer;
|
|
183
|
+
private rejectPendingServe;
|
|
184
|
+
/**
|
|
185
|
+
* Fan a value out to a set of subscribers, isolating each one's failures.
|
|
186
|
+
*
|
|
187
|
+
* A subscriber that throws must never break dispatch to the remaining
|
|
188
|
+
* subscribers, and — critically — must never propagate out of internal
|
|
189
|
+
* plumbing that emits events. `emitError` in particular runs inside error
|
|
190
|
+
* handling (e.g. a malformed inbound frame), so an `onError` subscriber that
|
|
191
|
+
* throws would otherwise escape that handler and can take the worker down.
|
|
192
|
+
* Containing throws here is the single canonical dispatch contract for every
|
|
193
|
+
* `emit*` below, so no individual emitter can reintroduce that failure mode.
|
|
194
|
+
*/
|
|
195
|
+
private dispatch;
|
|
196
|
+
private emitServe;
|
|
197
|
+
private emitFrame;
|
|
198
|
+
private emitOpen;
|
|
199
|
+
private emitClose;
|
|
200
|
+
private emitError;
|
|
201
|
+
private emitDrain;
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Connect a worker to the Nano agentic channel and return the client. The
|
|
205
|
+
* transport begins connecting immediately; because everything the worker
|
|
206
|
+
* produces is buffered, callers may `register`/`relay` straight away even before
|
|
207
|
+
* the channel is open (invariant #6).
|
|
208
|
+
*/
|
|
209
|
+
export declare function connectAgenticChannel(options: AgenticClientOptions): AgenticClient;
|
|
210
|
+
/** Re-exported so `isMessageFamily` is available to callers narrowing frames. */
|
|
211
|
+
export { isMessageFamily };
|