@ziggs-ai/api-client 0.1.30 → 0.2.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 CHANGED
@@ -10,25 +10,11 @@ npm install @ziggs-ai/api-client
10
10
 
11
11
  ## Usage
12
12
 
13
- ### WebSocket Client
14
-
15
- Real-time bidirectional communication with the Ziggs backend:
16
-
17
- ```javascript
18
- import { WebSocketClient } from '@ziggs-ai/api-client';
19
-
20
- const client = new WebSocketClient({
21
- operatorKey: process.env.ZIGGS_OPERATOR_KEY,
22
- agentId: 'my-agent-id',
23
- });
24
-
25
- client.setMessageHandler(async (text, metadata) => {
26
- console.log('Received:', text);
27
- console.log('From:', metadata.senderId);
28
- });
29
-
30
- client.connect();
31
- ```
13
+ Delivery is a stateless HTTP pull — there is no WebSocket client. An agent
14
+ receives work by long-polling its inbox: `GET /inbox?wait=25` (held open until
15
+ something passes the caller's cursor) → act → `POST /inbox/ack`. Because the
16
+ cursor lives server-side, any HTTP client following this loop is both correct
17
+ and live. See `docs/unified-inbox-wake-queue.md` for the reference loop.
32
18
 
33
19
  ### HTTP Clients
34
20
 
@@ -1,4 +1,3 @@
1
- import { type ControlSocketOptions } from './websocket/ControlSocket.js';
2
1
  type OpenFn = () => Promise<unknown>;
3
2
  type CloseFn = (handle: unknown) => Promise<void>;
4
3
  export interface ConnectionManagerMeta {
@@ -10,7 +9,18 @@ export interface ConnectionManagerMeta {
10
9
  export interface ConnectionManagerOptions {
11
10
  maxActive?: number;
12
11
  idleTimeoutMs?: number;
13
- control?: Pick<ControlSocketOptions, 'wsUrl' | 'operatorKey'>;
12
+ /**
13
+ * Fleet control (delivery law, phase 4): the operator key whose owned agents
14
+ * this manager lazily starts. `start()` long-polls `GET /inbox/operator` with
15
+ * this key; when an agent's slice shows actionable content, it starts that
16
+ * agent's host (which then consumes via its own per-agent inbox loop). No
17
+ * socket, no wake push — lazy process management driven by the poll. `wsUrl`
18
+ * is accepted for back-compat but unused (the poll is HTTP).
19
+ */
20
+ control?: {
21
+ wsUrl?: string;
22
+ operatorKey?: string;
23
+ };
14
24
  }
15
25
  export interface QueryFilter {
16
26
  domain?: string;
@@ -20,16 +30,25 @@ export interface QueryFilter {
20
30
  export declare class ConnectionManager {
21
31
  private maxActive;
22
32
  private idleTimeoutMs;
23
- private _controlOpts;
24
- private _controlHandle;
33
+ private _operatorKey;
34
+ private _polling;
35
+ private _pollDone;
25
36
  private _entries;
26
37
  private _active;
27
38
  private _meta;
28
39
  private _waking;
29
40
  constructor({ maxActive, idleTimeoutMs, control }?: ConnectionManagerOptions);
30
41
  register(id: string, openFn: OpenFn, closeFn: CloseFn, meta?: ConnectionManagerMeta): void;
31
- start(): void;
42
+ start(operatorKey?: string): void;
32
43
  stop(): Promise<void>;
44
+ /**
45
+ * Fleet lazy-start loop: long-poll the operator inbox and start a host for any
46
+ * owned agent whose slice is actionable and not already running. The started
47
+ * host consumes + acks via its own per-agent inbox loop, so this loop never
48
+ * acks — it only decides which hosts to run. Errors retry with backoff+jitter;
49
+ * the loop never exits until `stop()` (nothing to resynchronize).
50
+ */
51
+ private _runOperatorPoll;
33
52
  wake(id: string): Promise<unknown>;
34
53
  sleep(id: string): Promise<void>;
35
54
  sleepAll(): Promise<void>;
@@ -1,10 +1,17 @@
1
- import { createControlSocket } from './websocket/ControlSocket.js';
1
+ import { InboxClient } from './http/InboxClient.js';
2
2
  import { runtimeLog } from './shared/runtimeLog.js';
3
+ const OPERATOR_POLL_WAIT_SECONDS = 25;
4
+ const POLL_BACKOFF_BASE_MS = 1_000;
5
+ const POLL_BACKOFF_MAX_MS = 30_000;
6
+ function sleep(ms) {
7
+ return new Promise((resolve) => setTimeout(resolve, ms));
8
+ }
3
9
  export class ConnectionManager {
4
10
  maxActive;
5
11
  idleTimeoutMs;
6
- _controlOpts;
7
- _controlHandle;
12
+ _operatorKey;
13
+ _polling;
14
+ _pollDone;
8
15
  _entries;
9
16
  _active;
10
17
  _meta;
@@ -12,8 +19,9 @@ export class ConnectionManager {
12
19
  constructor({ maxActive = 50, idleTimeoutMs = 60_000, control } = {}) {
13
20
  this.maxActive = maxActive;
14
21
  this.idleTimeoutMs = idleTimeoutMs;
15
- this._controlOpts = control ?? null;
16
- this._controlHandle = null;
22
+ this._operatorKey = control?.operatorKey ?? null;
23
+ this._polling = false;
24
+ this._pollDone = null;
17
25
  this._entries = new Map();
18
26
  this._active = new Map();
19
27
  this._meta = new Map();
@@ -30,20 +38,56 @@ export class ConnectionManager {
30
38
  if (meta)
31
39
  this._meta.set(id, meta);
32
40
  }
33
- start() {
34
- if (this._controlHandle || !this._controlOpts)
41
+ start(operatorKey) {
42
+ if (operatorKey)
43
+ this._operatorKey = operatorKey;
44
+ if (this._polling || !this._operatorKey)
35
45
  return;
36
- this._controlHandle = createControlSocket({
37
- ...this._controlOpts,
38
- agentIds: () => this.list(),
39
- onWake: (id) => this.wake(id).catch(err => runtimeLog.warn('ConnectionManager', `wake("${id}") failed: ${err.message}`)),
40
- });
46
+ this._polling = true;
47
+ this._pollDone = this._runOperatorPoll(this._operatorKey);
41
48
  }
42
49
  async stop() {
43
- this._controlHandle?.close();
44
- this._controlHandle = null;
50
+ this._polling = false;
51
+ if (this._pollDone)
52
+ await this._pollDone.catch(() => { });
53
+ this._pollDone = null;
45
54
  await this.sleepAll();
46
55
  }
56
+ /**
57
+ * Fleet lazy-start loop: long-poll the operator inbox and start a host for any
58
+ * owned agent whose slice is actionable and not already running. The started
59
+ * host consumes + acks via its own per-agent inbox loop, so this loop never
60
+ * acks — it only decides which hosts to run. Errors retry with backoff+jitter;
61
+ * the loop never exits until `stop()` (nothing to resynchronize).
62
+ */
63
+ async _runOperatorPoll(operatorKey) {
64
+ // No agentId → operator-level multiplex read (transport auth only).
65
+ const inbox = new InboxClient(operatorKey);
66
+ let consecutiveErrors = 0;
67
+ while (this._polling) {
68
+ try {
69
+ const env = await inbox.getOperatorInbox({ waitSeconds: OPERATOR_POLL_WAIT_SECONDS });
70
+ consecutiveErrors = 0;
71
+ for (const entry of env.agents) {
72
+ const id = entry.agentId;
73
+ // Only slices with actionable content are listed; start the host if
74
+ // it isn't already running and we know how to open it.
75
+ if (!id || this._active.has(id) || !this._entries.has(id))
76
+ continue;
77
+ this.wake(id).catch((err) => runtimeLog.warn('ConnectionManager', `lazy-start("${id}") failed: ${err.message}`));
78
+ }
79
+ }
80
+ catch (err) {
81
+ if (!this._polling)
82
+ break;
83
+ consecutiveErrors++;
84
+ const backoff = Math.min(POLL_BACKOFF_MAX_MS, POLL_BACKOFF_BASE_MS * 2 ** (consecutiveErrors - 1));
85
+ const jitter = backoff * (0.5 + (consecutiveErrors % 7) / 14);
86
+ runtimeLog.warn('ConnectionManager', `operator poll failed (#${consecutiveErrors}): ${err.message} — retrying in ~${Math.round(jitter)}ms`);
87
+ await sleep(jitter);
88
+ }
89
+ }
90
+ }
47
91
  async wake(id) {
48
92
  const existing = this._active.get(id);
49
93
  if (existing) {
@@ -23,6 +23,13 @@ export interface WriteArtifactInput {
23
23
  /** Optional task binding — creates a TaskArtifactLink alongside the primary scope link. */
24
24
  taskId?: string;
25
25
  service?: Record<string, unknown>;
26
+ /**
27
+ * Optional idempotency key. A redelivered write with the same key (same
28
+ * sender) no-ops server-side and returns the original artifact. Derive it
29
+ * deterministically from durable inputs so a stateless crash-replay
30
+ * reproduces it.
31
+ */
32
+ idempotencyKey?: string;
26
33
  }
27
34
  /**
28
35
  * Replaces `ContextReader`'s artifact reads + `ContextWriter`'s breadcrumb
@@ -47,7 +54,9 @@ export declare class ArtifactsClient {
47
54
  * endpoint is updated to accept `visibility`, this falls back to logging
48
55
  * locally (operators can still wire their own sink).
49
56
  */
50
- recordThought(chatId: string, text: string): Promise<void>;
57
+ recordThought(chatId: string, text: string, opts?: {
58
+ idempotencyKey?: string;
59
+ }): Promise<void>;
51
60
  write(input: WriteArtifactInput): Promise<void>;
52
61
  /**
53
62
  * ZIG-899 — the deliberate-record variant: a deliverable the model chose to
@@ -49,12 +49,13 @@ export class ArtifactsClient {
49
49
  * endpoint is updated to accept `visibility`, this falls back to logging
50
50
  * locally (operators can still wire their own sink).
51
51
  */
52
- async recordThought(chatId, text) {
52
+ async recordThought(chatId, text, opts = {}) {
53
53
  return this.write({
54
54
  chatId,
55
55
  text,
56
56
  content_type: 'thought',
57
57
  visibility: 'agent-private',
58
+ idempotencyKey: opts.idempotencyKey,
58
59
  });
59
60
  }
60
61
  async write(input) {
@@ -94,6 +95,7 @@ export class ArtifactsClient {
94
95
  agreementId: input.agreementId,
95
96
  taskId: input.taskId,
96
97
  service: input.service,
98
+ ...(input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : {}),
97
99
  }),
98
100
  });
99
101
  const body = await res.text().catch(() => '');
@@ -84,6 +84,32 @@ export interface InboxAckResult {
84
84
  ackedUpTo: string;
85
85
  }>;
86
86
  }
87
+ /**
88
+ * Long-poll option shared by the inbox reads. The server holds the request up
89
+ * to this many seconds (server-clamped, ~25s ceiling) and returns as soon as
90
+ * anything actionable exists. Omit for an immediate snapshot — the response
91
+ * shape is identical either way, so `wait` only changes how long an EMPTY
92
+ * answer is withheld.
93
+ */
94
+ export interface InboxReadOptions {
95
+ waitSeconds?: number;
96
+ }
97
+ /** One agent's slice of the operator-level multiplexed read. */
98
+ export interface InboxOperatorAgentEntry {
99
+ agentId: string;
100
+ /** That agent's own inbox — fenced to its grants, floored by its cursors. */
101
+ inbox: InboxEnvelope;
102
+ }
103
+ /** GET /inbox/operator: one poll across every agent the key's owner runs. */
104
+ export interface InboxOperatorEnvelope {
105
+ asOf: string;
106
+ /** Agents with actionable content only. */
107
+ agents: InboxOperatorAgentEntry[];
108
+ /** Agents examined whose inboxes were empty. */
109
+ idleAgents: number;
110
+ /** Owned agents beyond the server's per-pass cap — not examined. */
111
+ truncatedAgents: number;
112
+ }
87
113
  /**
88
114
  * The doorbell, not the door (ZIG-434): references and counts since the
89
115
  * agent's last ack — never content. Flow: inbox → read → act → ack (ZIG-446).
@@ -99,6 +125,14 @@ export declare class InboxClient {
99
125
  */
100
126
  constructor(operatorKey: string, agentId?: string, baseUrl?: string);
101
127
  private headers;
102
- getInbox(): Promise<InboxEnvelope>;
128
+ private inboxUrl;
129
+ getInbox(opts?: InboxReadOptions): Promise<InboxEnvelope>;
130
+ /**
131
+ * Operator-level multiplexed read: one poll for every agent this key's
132
+ * owner runs, each slice fenced to its own agent (an agent-scoped key
133
+ * collapses to its one agent). Ack stays per agent — use a per-agent
134
+ * client's `ack()` after acting on that agent's slice.
135
+ */
136
+ getOperatorInbox(opts?: InboxReadOptions): Promise<InboxOperatorEnvelope>;
103
137
  ack(scopes: InboxAck[]): Promise<InboxAckResult>;
104
138
  }
@@ -29,8 +29,15 @@ export class InboxClient {
29
29
  headers['X-Agent-Id'] = this.agentId;
30
30
  return headers;
31
31
  }
32
- async getInbox() {
33
- const res = await fetch(`${this.baseUrl}/inbox`, {
32
+ inboxUrl(path, opts) {
33
+ const url = new URL(`${this.baseUrl}${path}`);
34
+ if (opts.waitSeconds != null && opts.waitSeconds > 0) {
35
+ url.searchParams.set('wait', String(opts.waitSeconds));
36
+ }
37
+ return url.toString();
38
+ }
39
+ async getInbox(opts = {}) {
40
+ const res = await fetch(this.inboxUrl('/inbox', opts), {
34
41
  headers: this.headers(),
35
42
  });
36
43
  const body = await res.text().catch(() => '');
@@ -39,6 +46,22 @@ export class InboxClient {
39
46
  }
40
47
  return JSON.parse(body);
41
48
  }
49
+ /**
50
+ * Operator-level multiplexed read: one poll for every agent this key's
51
+ * owner runs, each slice fenced to its own agent (an agent-scoped key
52
+ * collapses to its one agent). Ack stays per agent — use a per-agent
53
+ * client's `ack()` after acting on that agent's slice.
54
+ */
55
+ async getOperatorInbox(opts = {}) {
56
+ const res = await fetch(this.inboxUrl('/inbox/operator', opts), {
57
+ headers: this.headers(),
58
+ });
59
+ const body = await res.text().catch(() => '');
60
+ if (!res.ok) {
61
+ throw new Error(`InboxClient.getOperatorInbox ${res.status} ${body.slice(0, 200)}`);
62
+ }
63
+ return JSON.parse(body);
64
+ }
42
65
  async ack(scopes) {
43
66
  if (!scopes.length)
44
67
  throw new Error('InboxClient.ack: scopes are required');
@@ -16,6 +16,14 @@ export declare function getTask(taskId: string, creds: Creds): Promise<Task>;
16
16
  export interface UpdateTaskStateData {
17
17
  result?: unknown;
18
18
  errorMessage?: string;
19
+ /**
20
+ * Optional idempotency key. A redelivered transition carrying the same key
21
+ * no-ops server-side (returns the current task) instead of erroring on an
22
+ * already-terminal task. Sent in the body — the backend reads
23
+ * `UpdateTaskStateDto.idempotencyKey`. Derive it deterministically so a
24
+ * stateless crash-replay reproduces it.
25
+ */
26
+ idempotencyKey?: string;
19
27
  }
20
28
  export declare function updateTaskState(taskId: string, state: TaskState, data: UpdateTaskStateData | undefined, creds: Creds): Promise<Task>;
21
29
  export declare function getActiveTasksForAgent(agentId: string, creds: Creds): Promise<Task[]>;
package/dist/index.d.ts CHANGED
@@ -1,7 +1,5 @@
1
1
  export * from './http/index.js';
2
2
  export * from './relay/provisionRelayWorkers.js';
3
- export { WebSocketClient } from './websocket/index.js';
4
- export { createControlSocket } from './websocket/ControlSocket.js';
5
3
  export { ConnectionManager } from './ConnectionManager.js';
6
4
  export { EntryTypes, ContentTypes, OPEN_AGREEMENT_TARGET, ORG_AGREEMENT_TARGET, BROADCAST_TARGETS, isBroadcastTarget, AGREEMENT_ENGAGEMENT_KIND, isValidContentType } from './types.js';
7
5
  export { getBackendUrl, getWebSocketUrl } from './utils/urlUtils.js';
package/dist/index.js CHANGED
@@ -1,7 +1,5 @@
1
1
  export * from './http/index.js';
2
2
  export * from './relay/provisionRelayWorkers.js';
3
- export { WebSocketClient } from './websocket/index.js';
4
- export { createControlSocket } from './websocket/ControlSocket.js';
5
3
  export { ConnectionManager } from './ConnectionManager.js';
6
4
  export { EntryTypes, ContentTypes, OPEN_AGREEMENT_TARGET, ORG_AGREEMENT_TARGET, BROADCAST_TARGETS, isBroadcastTarget, AGREEMENT_ENGAGEMENT_KIND, isValidContentType } from './types.js';
7
5
  export { getBackendUrl, getWebSocketUrl } from './utils/urlUtils.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ziggs-ai/api-client",
3
- "version": "0.1.30",
3
+ "version": "0.2.0",
4
4
  "description": "HTTP and WebSocket client for the Ziggs backend API",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -1,13 +0,0 @@
1
- import { type Socket } from 'socket.io-client';
2
- export interface ControlSocketOptions {
3
- wsUrl: string;
4
- operatorKey: string;
5
- agentIds: () => string[];
6
- onWake: (agentId: string) => void;
7
- }
8
- export interface ControlSocketHandle {
9
- socket: Socket;
10
- close: () => void;
11
- resync: () => void;
12
- }
13
- export declare function createControlSocket({ wsUrl, operatorKey, agentIds, onWake }: ControlSocketOptions): ControlSocketHandle;
@@ -1,37 +0,0 @@
1
- import { io } from 'socket.io-client';
2
- import { runtimeLog } from '../shared/runtimeLog.js';
3
- export function createControlSocket({ wsUrl, operatorKey, agentIds, onWake }) {
4
- if (!wsUrl || !operatorKey)
5
- throw new Error('[createControlSocket] wsUrl and operatorKey are required');
6
- if (typeof agentIds !== 'function')
7
- throw new Error('[createControlSocket] agentIds must be a function returning string[]');
8
- if (typeof onWake !== 'function')
9
- throw new Error('[createControlSocket] onWake must be a function');
10
- const socket = io(wsUrl, {
11
- auth: { token: `Bearer ${operatorKey}` },
12
- query: { token: operatorKey, role: 'launcher' },
13
- extraHeaders: { Authorization: `Bearer ${operatorKey}` },
14
- transports: ['websocket'],
15
- reconnection: true,
16
- reconnectionDelay: 1000,
17
- reconnectionDelayMax: 30000,
18
- randomizationFactor: 0.5,
19
- });
20
- socket.on('connect', () => {
21
- const ids = agentIds();
22
- runtimeLog.info('ControlSocket', `connected (${socket.id}); registering ${ids.length} agent(s)`);
23
- socket.emit('launcher:register', { agentIds: ids });
24
- });
25
- socket.on('launcher:wake', ({ agentId }) => {
26
- if (agentId)
27
- onWake(agentId);
28
- });
29
- socket.on('disconnect', (reason) => runtimeLog.debug('ControlSocket', `disconnected: ${reason}`));
30
- socket.on('connect_error', (err) => runtimeLog.warn('ControlSocket', `connect error: ${err.message}`));
31
- return {
32
- socket,
33
- close: () => socket.disconnect(),
34
- resync: () => { if (socket.connected)
35
- socket.emit('launcher:register', { agentIds: agentIds() }); },
36
- };
37
- }
@@ -1,86 +0,0 @@
1
- import 'dotenv/config';
2
- import { type MessageHandler } from '../types.js';
3
- export interface WebSocketClientOptions {
4
- wsUrl?: string;
5
- operatorKey?: string;
6
- agentId?: string;
7
- /**
8
- * Optional user JWT (from `POST /users/login`). When set, the socket
9
- * authenticates as that user — the gateway populates `socket.userId` and
10
- * routes messages to them as a human user. Mutually exclusive with
11
- * `operatorKey` + `agentId`. Used by the eval harness to seed chats as a
12
- * real user instead of impersonating an agent.
13
- */
14
- userToken?: string;
15
- label?: string;
16
- }
17
- /**
18
- * Mirrors the backend `ResourceEvent` shape. Emitted on the wire as
19
- * `resource_changed` when a resource the agent has access to mutates.
20
- */
21
- export interface ResourceEvent {
22
- kind: 'message' | 'artifact' | 'task-state' | 'agreement';
23
- ts: string;
24
- resourceId: string;
25
- chatId?: string;
26
- agreementId?: string;
27
- taskId?: string;
28
- change?: 'created' | 'updated' | 'state-changed';
29
- reason?: string;
30
- /**
31
- * Principal whose action produced the event, when the backend knows it.
32
- * Hosts drop events the receiving agent itself caused — its own writes
33
- * aren't news to it.
34
- */
35
- actorId?: string;
36
- }
37
- export type ResourceEventHandler = (event: ResourceEvent) => void | Promise<void>;
38
- export interface SendOptions {
39
- entryType?: string;
40
- content_type?: string;
41
- contentType?: string;
42
- taskId?: string;
43
- /**
44
- * When true, emits a 'chat:chunk' event instead of 'chat'.
45
- * Use for streaming partial responses; send a final message
46
- * (partial: false or omitted) to signal end of stream.
47
- * Requires backend streaming support.
48
- */
49
- partial?: boolean;
50
- /** Pre-allocated messageId for streaming continuity — links chunks to the final message. */
51
- messageId?: string;
52
- }
53
- export declare class WebSocketClient {
54
- private wsUrl;
55
- private operatorKey;
56
- private agentId;
57
- private userToken;
58
- private label;
59
- private socket;
60
- private messageHandler;
61
- private resourceEventHandler;
62
- constructor(options?: WebSocketClientOptions);
63
- setMessageHandler(handler: MessageHandler): void;
64
- /**
65
- * Subscribe to `resource_changed` notifications — the unified push channel
66
- * for non-message resource changes (artifact, task-state, agreement). The
67
- * agent decides whether to pull the corresponding primitive based on
68
- * `event.kind` + ids. Replaces cursor-based polling for delta detection.
69
- */
70
- setResourceEventHandler(handler: ResourceEventHandler): void;
71
- private connectHandlers;
72
- /**
73
- * Called on every socket connect, including socket.io reconnects.
74
- * Used for inbox catch-up (ZIG-454) so missed pushes are recovered from MongoDB.
75
- */
76
- onConnect(handler: () => void | Promise<void>): void;
77
- private _emitConnect;
78
- private _connect;
79
- connectAsync(timeout?: number): Promise<this>;
80
- disconnect(): void;
81
- send(chatId: string, receiverId: string, content: string, options?: SendOptions): void;
82
- isConnected(): boolean;
83
- handleIncomingMessage(payload: unknown): Promise<void>;
84
- private buildSocketOptions;
85
- private generateMessageId;
86
- }
@@ -1,268 +0,0 @@
1
- import { io } from 'socket.io-client';
2
- import 'dotenv/config';
3
- import { getWebSocketUrl } from '../utils/urlUtils.js';
4
- import { runtimeLog } from '../shared/runtimeLog.js';
5
- export class WebSocketClient {
6
- wsUrl;
7
- operatorKey;
8
- agentId;
9
- userToken;
10
- label;
11
- socket;
12
- messageHandler;
13
- resourceEventHandler;
14
- constructor(options = {}) {
15
- this.wsUrl = options.wsUrl || getWebSocketUrl();
16
- this.userToken = options.userToken || null;
17
- this.operatorKey = this.userToken ? null : (options.operatorKey || process.env.ZIGGS_OPERATOR_KEY || null);
18
- this.agentId = this.userToken ? null : (options.agentId || null);
19
- this.label = options.label || 'agent';
20
- this.socket = null;
21
- this.messageHandler = null;
22
- this.resourceEventHandler = null;
23
- }
24
- setMessageHandler(handler) {
25
- this.messageHandler = handler;
26
- }
27
- /**
28
- * Subscribe to `resource_changed` notifications — the unified push channel
29
- * for non-message resource changes (artifact, task-state, agreement). The
30
- * agent decides whether to pull the corresponding primitive based on
31
- * `event.kind` + ids. Replaces cursor-based polling for delta detection.
32
- */
33
- setResourceEventHandler(handler) {
34
- this.resourceEventHandler = handler;
35
- }
36
- connectHandlers = [];
37
- /**
38
- * Called on every socket connect, including socket.io reconnects.
39
- * Used for inbox catch-up (ZIG-454) so missed pushes are recovered from MongoDB.
40
- */
41
- onConnect(handler) {
42
- this.connectHandlers.push(handler);
43
- }
44
- async _emitConnect() {
45
- for (const handler of this.connectHandlers) {
46
- try {
47
- await handler();
48
- }
49
- catch (error) {
50
- const msg = error instanceof Error ? error.message : String(error);
51
- runtimeLog.warn(this.label, `connect handler error: ${msg}`);
52
- }
53
- }
54
- }
55
- _connect() {
56
- if (this.socket?.connected)
57
- return;
58
- runtimeLog.debug(this.label, `Connecting to ${this.wsUrl}`);
59
- const socketOptions = this.buildSocketOptions();
60
- this.socket = io(this.wsUrl, socketOptions);
61
- this.socket.on('connect', () => {
62
- runtimeLog.info(this.label, 'Connected');
63
- void this._emitConnect();
64
- });
65
- this.socket.on('connect_error', (error) => {
66
- runtimeLog.error(this.label, `Connection error: ${error.message}`);
67
- });
68
- this.socket.on('disconnect', (reason) => {
69
- const reasonDescriptions = {
70
- 'io server disconnect': 'Server forcefully disconnected the client',
71
- 'io client disconnect': 'Client manually disconnected',
72
- 'ping timeout': 'Server did not respond to ping (connection timeout)',
73
- 'transport close': 'Connection closed by transport layer',
74
- 'transport error': 'Transport error occurred',
75
- 'parse error': 'Error parsing server message',
76
- 'forced close': 'Connection was forcibly closed',
77
- 'forced server close': 'Server forcibly closed the connection',
78
- };
79
- const description = reasonDescriptions[reason] || 'Unknown disconnect reason';
80
- runtimeLog.debug(this.label, `Disconnected: ${reason} — ${description}`);
81
- });
82
- // ZIG-207: subscribe to the canonical event name. Backend dual-emits
83
- // `messages` + `chat:message:new` with byte-identical payloads, so
84
- // listening to ONE of the two is exactly correct — listening to both
85
- // would double-invoke handleIncomingMessage(). We pick the canonical
86
- // namespaced name so once the sunset PR drops the legacy `messages`
87
- // emit, no client change is needed.
88
- this.socket.on('chat:message:new', async (payload) => {
89
- try {
90
- await this.handleIncomingMessage(payload);
91
- }
92
- catch (error) {
93
- const msg = error instanceof Error ? error.message : String(error);
94
- runtimeLog.error(this.label, `Error handling incoming message: ${msg}`);
95
- throw error;
96
- }
97
- });
98
- this.socket.on('resource_changed', async (payload) => {
99
- if (!this.resourceEventHandler)
100
- return;
101
- try {
102
- await this.resourceEventHandler(payload);
103
- }
104
- catch (error) {
105
- const msg = error instanceof Error ? error.message : String(error);
106
- runtimeLog.error(this.label, `Error handling resource_changed: ${msg}`);
107
- }
108
- });
109
- this.socket.on('error', (error) => {
110
- runtimeLog.error(this.label, `Socket.IO error: ${String(error)}`);
111
- });
112
- // ZIG-860: the send path is fire-and-forget (no ack callback), so the
113
- // backend's structured rejection is the ONLY failure signal. Not listening
114
- // made every rejected send silent — an agent's message just vanished (the
115
- // a2a schema bug hid behind exactly this). Log loudly; delivery recovery
116
- // stays with the inbox/catch-up machinery.
117
- this.socket.on('chat:error', (payload) => {
118
- const p = (payload ?? {});
119
- runtimeLog.error(this.label, `chat:error [${p['code'] ?? 'UNKNOWN'}] chatId=${p['chatId'] ?? '-'} messageId=${p['messageId'] ?? '-'}: ${p['message'] ?? ''}`);
120
- });
121
- }
122
- connectAsync(timeout = 10_000) {
123
- if (this.socket?.connected)
124
- return Promise.resolve(this);
125
- return new Promise((resolve, reject) => {
126
- this._connect();
127
- // Only reject on timeout — not on the first connect_error. socket.io
128
- // retries automatically (reconnectionAttempts: Infinity), so a single
129
- // network hiccup or a briefly-down backend shouldn't crash the caller.
130
- const timer = setTimeout(() => reject(new Error(`[${this.label}] Connection timeout after ${timeout}ms`)), timeout);
131
- this.socket.once('connect', () => { clearTimeout(timer); resolve(this); });
132
- });
133
- }
134
- disconnect() {
135
- if (this.socket) {
136
- this.socket.disconnect();
137
- this.socket = null;
138
- }
139
- }
140
- send(chatId, receiverId, content, options = {}) {
141
- if (!chatId || typeof chatId !== 'string')
142
- throw new Error('send: chatId is required');
143
- if (!receiverId || typeof receiverId !== 'string')
144
- throw new Error('send: receiverId is required');
145
- if (typeof content !== 'string' || content.trim().length === 0)
146
- throw new Error('send: content must be a non-empty string');
147
- if (!this.socket)
148
- throw new Error('send: socket is not initialized. Call connect() first.');
149
- if (!this.socket.connected)
150
- throw new Error('send: socket is not connected. Call connect() and wait for connection.');
151
- const messageId = options.messageId ?? this.generateMessageId();
152
- if (!messageId || typeof messageId !== 'string')
153
- throw new Error('send: failed to generate valid messageId');
154
- const message = {
155
- receiverId,
156
- chatId,
157
- messageId,
158
- text: content,
159
- entryType: options.entryType || 'message',
160
- content_type: options.content_type || options.contentType || 'text',
161
- };
162
- const messagePreview = content.length > 100 ? content.substring(0, 100) + '...' : content;
163
- // ZIG-207: emit the canonical namespaced inbound event. Backend
164
- // registers `chat:message:send` as a one-line alias on `handleChat`,
165
- // so this is byte-identical to the legacy `chat` emit. Streaming
166
- // chunks keep their existing `chat:chunk` name (already namespaced).
167
- const event = options.partial ? 'chat:chunk' : 'chat:message:send';
168
- runtimeLog.debug(this.label, `Sending message (${event}) - chatId: ${chatId}, receiverId: ${receiverId}\n Message: ${messagePreview}`);
169
- this.socket.emit(event, message);
170
- }
171
- isConnected() {
172
- return this.socket?.connected || false;
173
- }
174
- async handleIncomingMessage(payload) {
175
- if (!payload || typeof payload !== 'object') {
176
- throw new Error('handleIncomingMessage: payload must be an object');
177
- }
178
- const p = payload;
179
- if (!p['text'] || typeof p['text'] !== 'string' || p['text'].trim().length === 0) {
180
- throw new Error('handleIncomingMessage: payload.text is required and must be a non-empty string');
181
- }
182
- if (!p['chatId'] || typeof p['chatId'] !== 'string') {
183
- throw new Error('handleIncomingMessage: payload.chatId is required and must be a string');
184
- }
185
- const sender = p['sender'];
186
- const senderId = sender?.['id'];
187
- const senderType = sender?.['type'];
188
- if (!senderId || typeof senderId !== 'string') {
189
- throw new Error('handleIncomingMessage: payload.sender.id is required and must be a string');
190
- }
191
- runtimeLog.debug(this.label, `Received message - chatId: ${p['chatId']}, sender: ${senderId} (${senderType})`);
192
- if (this.agentId && senderId === this.agentId)
193
- return;
194
- if (!this.messageHandler) {
195
- throw new Error('handleIncomingMessage: messageHandler is not set. Call setMessageHandler() first.');
196
- }
197
- const chatId = p['chatId'];
198
- const receiver = p['receiver'];
199
- const task = p['task'] ?? null;
200
- const agreement = p['agreement'] ?? null;
201
- const operation = p['operation'] ?? null;
202
- const agreementId = p['agreementId'] ?? null;
203
- runtimeLog.info(this.label, `[wire-debug] incoming chatId=${chatId} sender=${senderId} content_type=${p['content_type'] ?? p['contentType'] ?? '<none>'} entryType=${p['entryType'] ?? '<none>'} ts=${typeof p['timestamp']}/${typeof p['sentTimestamp']} keys=${Object.keys(p).join(',')} task?=${task ? `taskId=${task['taskId']} state=${task['state']}` : 'no'} agreement?=${agreement ? `agreementId=${agreement['agreementId']} status=${agreement['status']}` : 'no'} operation?=${operation ?? 'no'}`);
204
- const metadata = {
205
- chatId,
206
- messageId: typeof p['messageId'] === 'string' ? p['messageId'] : undefined,
207
- userId: senderId,
208
- sender: { id: senderId, type: senderType },
209
- senderId,
210
- senderType,
211
- receiver: receiver || null,
212
- receiverId: receiver?.['id'] || null,
213
- entryType: p['entryType'],
214
- content_type: (p['content_type'] ?? p['contentType']),
215
- taskId: task?.['taskId'] ?? null,
216
- task,
217
- agreement,
218
- operation,
219
- agreementId,
220
- // ZIG-860: carry the send time through — the live-path watermark ack
221
- // (AgentHost, ZIG-832 P4) reads it, and dropping it here meant the ack
222
- // never fired for any live message.
223
- timestamp: typeof p['timestamp'] === 'string' ? p['timestamp'] : undefined,
224
- sentTimestamp: typeof p['sentTimestamp'] === 'string' ? p['sentTimestamp'] : undefined,
225
- };
226
- await this.messageHandler(p['text'], metadata);
227
- }
228
- buildSocketOptions() {
229
- // `forceNew: true` is required when multiple WebSocketClient instances
230
- // hit the same URL with DIFFERENT auth. socket.io's default
231
- // `multiplex: true` shares the engine.io Manager across `io()` calls
232
- // to the same URL — and the Manager's auth is fixed by whoever opened
233
- // it first. So a second client (e.g. an eval's user-mode connection
234
- // alongside agent-host operator-key connections) would silently inherit
235
- // the first client's auth and get rejected. Forcing a fresh Manager
236
- // per client avoids that aliasing.
237
- const options = {
238
- transports: ['websocket'],
239
- reconnection: true,
240
- reconnectionAttempts: Infinity,
241
- reconnectionDelay: 1000,
242
- reconnectionDelayMax: 30000,
243
- randomizationFactor: 0.5,
244
- timeout: 20000,
245
- autoConnect: true,
246
- forceNew: true,
247
- multiplex: false,
248
- };
249
- if (this.userToken) {
250
- const bearer = `Bearer ${this.userToken}`;
251
- options.auth = { token: bearer };
252
- options.extraHeaders = { Authorization: bearer };
253
- // No agentId — the gateway sees the `userId` claim and routes as a human user.
254
- }
255
- else if (this.operatorKey) {
256
- const bearer = `Bearer ${this.operatorKey}`;
257
- options.auth = { token: bearer };
258
- options.extraHeaders = { Authorization: bearer };
259
- // agentId goes in query for fleet keys; agent-scoped keys omit it (ZIG-279).
260
- if (this.agentId)
261
- options.query = { agentId: this.agentId };
262
- }
263
- return options;
264
- }
265
- generateMessageId() {
266
- return crypto.randomUUID();
267
- }
268
- }
@@ -1 +0,0 @@
1
- export { WebSocketClient } from './WebSocketClient.js';
@@ -1 +0,0 @@
1
- export { WebSocketClient } from './WebSocketClient.js';