@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 +5 -19
- package/dist/ConnectionManager.d.ts +24 -5
- package/dist/ConnectionManager.js +58 -14
- package/dist/http/ArtifactsClient.d.ts +10 -1
- package/dist/http/ArtifactsClient.js +3 -1
- package/dist/http/InboxClient.d.ts +35 -1
- package/dist/http/InboxClient.js +25 -2
- package/dist/http/TaskClient.d.ts +8 -0
- package/dist/index.d.ts +0 -2
- package/dist/index.js +0 -2
- package/package.json +1 -1
- package/dist/websocket/ControlSocket.d.ts +0 -13
- package/dist/websocket/ControlSocket.js +0 -37
- package/dist/websocket/WebSocketClient.d.ts +0 -86
- package/dist/websocket/WebSocketClient.js +0 -268
- package/dist/websocket/index.d.ts +0 -1
- package/dist/websocket/index.js +0 -1
package/README.md
CHANGED
|
@@ -10,25 +10,11 @@ npm install @ziggs-ai/api-client
|
|
|
10
10
|
|
|
11
11
|
## Usage
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
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
|
|
24
|
-
private
|
|
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 {
|
|
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
|
-
|
|
7
|
-
|
|
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.
|
|
16
|
-
this.
|
|
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 (
|
|
41
|
+
start(operatorKey) {
|
|
42
|
+
if (operatorKey)
|
|
43
|
+
this._operatorKey = operatorKey;
|
|
44
|
+
if (this._polling || !this._operatorKey)
|
|
35
45
|
return;
|
|
36
|
-
this.
|
|
37
|
-
|
|
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.
|
|
44
|
-
this.
|
|
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
|
|
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
|
-
|
|
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
|
}
|
package/dist/http/InboxClient.js
CHANGED
|
@@ -29,8 +29,15 @@ export class InboxClient {
|
|
|
29
29
|
headers['X-Agent-Id'] = this.agentId;
|
|
30
30
|
return headers;
|
|
31
31
|
}
|
|
32
|
-
|
|
33
|
-
const
|
|
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,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';
|
package/dist/websocket/index.js
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export { WebSocketClient } from './WebSocketClient.js';
|