@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 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.
@@ -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 };