@ziggs-ai/agent-sdk 0.11.0 → 0.12.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/CHANGELOG.md +11 -0
- package/README.md +1 -1
- package/dist/AgentHost.d.ts +108 -6
- package/dist/AgentHost.js +290 -18
- package/dist/adapters/McpAdapter.js +10 -12
- package/dist/agent/ClaudeSdkAgent.d.ts +9 -0
- package/dist/agent/ClaudeSdkAgent.js +127 -26
- package/dist/agent/claudeSdkProtocol.d.ts +1 -1
- package/dist/agent/claudeSdkProtocol.js +3 -2
- package/dist/agent/claudeSdkTools.js +71 -16
- package/dist/defineAgent.js +31 -2
- package/dist/index.d.ts +7 -3
- package/dist/index.js +11 -1
- package/dist/pricing/fleetTierByAgentId.gen.d.ts +2 -2
- package/dist/pricing/fleetTierByAgentId.gen.js +9 -2
- package/dist/runtime/PromptBuilder.js +13 -1
- package/dist/server/ConnectionPool.d.ts +19 -0
- package/dist/server/ConnectionPool.js +30 -0
- package/dist/server/OutboxBuffer.d.ts +1 -1
- package/dist/server/ZiggsEffectHandler.d.ts +1 -1
- package/dist/server/ZiggsEffectHandler.js +16 -3
- package/dist/server/agreements/AgreementService.d.ts +8 -3
- package/dist/server/agreements/AgreementService.js +10 -5
- package/dist/server/inboxLoop.d.ts +6 -6
- package/dist/server/inboxLoop.js +39 -21
- package/dist/server/polls/PollRunner.d.ts +36 -1
- package/dist/server/polls/PollRunner.js +42 -2
- package/dist/server/requestMatch.d.ts +5 -0
- package/dist/server/{questMatch.js → requestMatch.js} +2 -2
- package/dist/server/runLauncher.js +24 -3
- package/dist/server/tasks/TaskService.d.ts +5 -5
- package/dist/server/tools/index.d.ts +1 -1
- package/dist/server/tools/index.js +1 -1
- package/dist/server/tools/tier1/protocolRunner.js +2 -2
- package/dist/server/tools/tier1/protocolTools.js +7 -7
- package/dist/server/tools/tier2/marketplaceTools.js +2 -2
- package/dist/server/tools/tier2/mcpConnectionTools.d.ts +33 -0
- package/dist/server/tools/tier2/mcpConnectionTools.js +139 -7
- package/dist/server/tools/tier2/paymentTools.d.ts +0 -8
- package/dist/server/tools/tier2/paymentTools.js +8 -12
- package/dist/tools/ToolManager.d.ts +1 -1
- package/dist/tools/ToolManager.js +2 -1
- package/dist/tools/defineTool.d.ts +7 -0
- package/dist/tools/defineTool.js +12 -0
- package/dist/tools/needs.d.ts +54 -0
- package/dist/tools/needs.js +84 -0
- package/dist/types.d.ts +34 -5
- package/package.json +2 -2
- package/dist/server/questMatch.d.ts +0 -5
|
@@ -281,7 +281,19 @@ ${this._renderDecisionSchemaFromActions(actions, senderMeta, ctxObj, omitMessage
|
|
|
281
281
|
const name = a.name && a.name !== id ? `${a.name} (${id})` : id;
|
|
282
282
|
agentLabels.set(id, `${name}${desc}`);
|
|
283
283
|
}
|
|
284
|
-
|
|
284
|
+
// The server declares what each participant is (`kind` on the snapshot row),
|
|
285
|
+
// so read that. Which array a row arrived in is a projection detail, and
|
|
286
|
+
// guessing from it mislabels the first participant that is neither a person
|
|
287
|
+
// nor an agent. A backend too old to send `kind` still resolves by array.
|
|
288
|
+
const declaredKinds = new Map();
|
|
289
|
+
for (const row of [...users, ...otherAgents]) {
|
|
290
|
+
const id = (row?.['userId'] ?? row?.['agentId']);
|
|
291
|
+
const kind = row?.['kind'];
|
|
292
|
+
if (id && typeof kind === 'string')
|
|
293
|
+
declaredKinds.set(id, kind);
|
|
294
|
+
}
|
|
295
|
+
const kindOf = (id) => declaredKinds.get(id) ??
|
|
296
|
+
(userIds.has(id) ? 'user' : agentIds.has(id) ? 'agent' : 'unknown');
|
|
285
297
|
const labelOf = (id) => userLabels.get(id) ?? agentLabels.get(id) ?? id;
|
|
286
298
|
const tagged = (id) => `${labelOf(id)} [${kindOf(id)}]`;
|
|
287
299
|
const agreementParties = new Map();
|
|
@@ -16,6 +16,7 @@ export interface SendToOptions {
|
|
|
16
16
|
export declare class ConnectionPool {
|
|
17
17
|
private readonly manager;
|
|
18
18
|
private readonly configs;
|
|
19
|
+
private readonly hosts;
|
|
19
20
|
private readonly responseListeners;
|
|
20
21
|
/**
|
|
21
22
|
* Register N agent configs. Nothing connects until `connectAll()`; a config
|
|
@@ -29,6 +30,24 @@ export declare class ConnectionPool {
|
|
|
29
30
|
startAgent(agentId: string): Promise<AgentHost>;
|
|
30
31
|
stopAgent(agentId: string): Promise<void>;
|
|
31
32
|
disconnectAll(): Promise<void>;
|
|
33
|
+
/**
|
|
34
|
+
* Shut the fleet down without abandoning work: stop taking new deliveries
|
|
35
|
+
* everywhere, THEN wait for in-flight turns to finish.
|
|
36
|
+
*
|
|
37
|
+
* The order is the whole point. Draining one agent while the others still
|
|
38
|
+
* long-poll leaves a window — as long as the drain — in which a host that is
|
|
39
|
+
* on its way out keeps accepting new work. On a rolling deploy the replacement
|
|
40
|
+
* is already live and polling the same inboxes, so that window is where one
|
|
41
|
+
* delivery gets handled twice: two hosts, one request, two answers.
|
|
42
|
+
*
|
|
43
|
+
* `disconnect()` only stops the inbox loop and timers; in-flight ticks live in
|
|
44
|
+
* each host's event queue, which is what `drain()` waits on. So stopping
|
|
45
|
+
* intake first costs nothing that is already running.
|
|
46
|
+
*
|
|
47
|
+
* A host that cannot finish inside `timeoutMs` is left behind rather than
|
|
48
|
+
* holding up the rest — the caller is on a shutdown clock of its own.
|
|
49
|
+
*/
|
|
50
|
+
shutdown(timeoutMs?: number): Promise<void>;
|
|
32
51
|
/**
|
|
33
52
|
* Deliver a message to the named agent and wait for its first outbound
|
|
34
53
|
* `send-message` effect — i.e. the agent's reply. Returns the reply text.
|
|
@@ -19,6 +19,10 @@ import { runtimeLog } from '../shared/runtimeLog.js';
|
|
|
19
19
|
export class ConnectionPool {
|
|
20
20
|
manager = new ConnectionManager();
|
|
21
21
|
configs = new Map();
|
|
22
|
+
// Live hosts, so shutdown can drain them. The manager hands its handles to
|
|
23
|
+
// open/close callbacks and does not expose them, and re-reading one through
|
|
24
|
+
// `startAgent` after a stop would reconnect the agent we are shutting down.
|
|
25
|
+
hosts = new Map();
|
|
22
26
|
// Listeners enqueued by requestFrom — drained on outbound send-message effects.
|
|
23
27
|
responseListeners = new Map();
|
|
24
28
|
/**
|
|
@@ -56,10 +60,12 @@ export class ConnectionPool {
|
|
|
56
60
|
},
|
|
57
61
|
});
|
|
58
62
|
await host.connectAsync();
|
|
63
|
+
this.hosts.set(agentId, host);
|
|
59
64
|
runtimeLog.debug('ConnectionPool', `connected "${agentId}"`);
|
|
60
65
|
return host;
|
|
61
66
|
}, async (host) => {
|
|
62
67
|
host.disconnect();
|
|
68
|
+
this.hosts.delete(agentId);
|
|
63
69
|
runtimeLog.debug('ConnectionPool', `disconnected "${agentId}"`);
|
|
64
70
|
}, metaByAgentId.get(agentId));
|
|
65
71
|
}
|
|
@@ -78,6 +84,30 @@ export class ConnectionPool {
|
|
|
78
84
|
async disconnectAll() {
|
|
79
85
|
return this.manager.stopAll();
|
|
80
86
|
}
|
|
87
|
+
/**
|
|
88
|
+
* Shut the fleet down without abandoning work: stop taking new deliveries
|
|
89
|
+
* everywhere, THEN wait for in-flight turns to finish.
|
|
90
|
+
*
|
|
91
|
+
* The order is the whole point. Draining one agent while the others still
|
|
92
|
+
* long-poll leaves a window — as long as the drain — in which a host that is
|
|
93
|
+
* on its way out keeps accepting new work. On a rolling deploy the replacement
|
|
94
|
+
* is already live and polling the same inboxes, so that window is where one
|
|
95
|
+
* delivery gets handled twice: two hosts, one request, two answers.
|
|
96
|
+
*
|
|
97
|
+
* `disconnect()` only stops the inbox loop and timers; in-flight ticks live in
|
|
98
|
+
* each host's event queue, which is what `drain()` waits on. So stopping
|
|
99
|
+
* intake first costs nothing that is already running.
|
|
100
|
+
*
|
|
101
|
+
* A host that cannot finish inside `timeoutMs` is left behind rather than
|
|
102
|
+
* holding up the rest — the caller is on a shutdown clock of its own.
|
|
103
|
+
*/
|
|
104
|
+
async shutdown(timeoutMs = 30_000) {
|
|
105
|
+
const hosts = [...this.hosts.values()];
|
|
106
|
+
await this.manager.stopAll();
|
|
107
|
+
await Promise.all(hosts.map((host) => host.drain(timeoutMs).catch((err) => {
|
|
108
|
+
runtimeLog.warn('ConnectionPool', `drain did not finish: ${err.message}`);
|
|
109
|
+
})));
|
|
110
|
+
}
|
|
81
111
|
/**
|
|
82
112
|
* Deliver a message to the named agent and wait for its first outbound
|
|
83
113
|
* `send-message` effect — i.e. the agent's reply. Returns the reply text.
|
|
@@ -24,7 +24,7 @@ export declare class OutboxBuffer {
|
|
|
24
24
|
constructor(options?: OutboxBufferOptions);
|
|
25
25
|
track(chatId: string, msg: {
|
|
26
26
|
text: string;
|
|
27
|
-
receiverId
|
|
27
|
+
receiverId?: string;
|
|
28
28
|
}): void;
|
|
29
29
|
merge(chatId: string, history: HistoryEntryShape[], agentId: string | null): void;
|
|
30
30
|
gc(): void;
|
|
@@ -35,7 +35,7 @@ export interface ZiggsEffectDeps {
|
|
|
35
35
|
contextReadClient: ContextReadClient;
|
|
36
36
|
taskService: TaskService;
|
|
37
37
|
agreementService: AgreementService;
|
|
38
|
-
messageSender: (text: string, receiverId: string, sessionId: string, messageId?: string) => Promise<unknown>;
|
|
38
|
+
messageSender: (text: string, receiverId: string | undefined, sessionId: string, messageId?: string) => Promise<unknown>;
|
|
39
39
|
/** Sends a streaming chunk to a client. Called per-token during stream-text effects. */
|
|
40
40
|
chunkSender?: (sessionId: string, receiverId: string, text: string, messageId: string) => void;
|
|
41
41
|
toolManager: ToolManager;
|
|
@@ -15,7 +15,7 @@ const emptySnapshot = (agentId, chatMissing = false) => ({
|
|
|
15
15
|
history: [],
|
|
16
16
|
agreements: [],
|
|
17
17
|
users: [],
|
|
18
|
-
agents: [{ agentId, role: 'self', isYou: true }],
|
|
18
|
+
agents: [{ agentId, role: 'self', isYou: true, kind: 'agent' }],
|
|
19
19
|
latestSequence: null,
|
|
20
20
|
...(chatMissing ? { chatMissing: true } : {}),
|
|
21
21
|
});
|
|
@@ -26,7 +26,11 @@ async function buildSnapshot(deps, chatId, agentId, opts = {}) {
|
|
|
26
26
|
const snap = await deps.contextReadClient.snapshot(chatId, {
|
|
27
27
|
maxMessages: opts.limit,
|
|
28
28
|
});
|
|
29
|
-
|
|
29
|
+
// Built rather than cast: the roster rows are typed on both sides now (they
|
|
30
|
+
// carry the declared participant kind), and only `history` is loose on the
|
|
31
|
+
// wire, so a whole-object cast would be asserting past the one field that
|
|
32
|
+
// actually needs it.
|
|
33
|
+
return { ...snap, history: snap.history };
|
|
30
34
|
}
|
|
31
35
|
catch (err) {
|
|
32
36
|
// Defensive: server normally signals a dead chat as 200 + chatMissing, but
|
|
@@ -331,7 +335,16 @@ export function createZiggsEffectHandler(deps) {
|
|
|
331
335
|
};
|
|
332
336
|
const handler = async (effect) => {
|
|
333
337
|
const response = await dispatch(effect);
|
|
334
|
-
|
|
338
|
+
// A tap observes; it must never decide the effect's fate. This call was bare,
|
|
339
|
+
// so a tap that threw took the effect down with it — and taps stringify
|
|
340
|
+
// arbitrary tool results, which throws on a circular value or a BigInt. The
|
|
341
|
+
// other two taps (metrics, wake) are already guarded; this is the last one.
|
|
342
|
+
try {
|
|
343
|
+
deps.effectTap?.(effect, response);
|
|
344
|
+
}
|
|
345
|
+
catch (err) {
|
|
346
|
+
runtimeLog.warn('ZiggsEffectHandler', `effectTap failed: ${err?.message || err}`);
|
|
347
|
+
}
|
|
335
348
|
return response;
|
|
336
349
|
};
|
|
337
350
|
return handler;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type Agreement, type ClaimedKind, type ProposeAgreementData, type ProposeDirectInput, type ProposeBroadcastInput, type PublishOfferPayload, type DelegateAgreementData, type CounterAgreementData, type
|
|
1
|
+
import { type Agreement, type ClaimedKind, type ProposeAgreementData, type ProposeDirectInput, type ProposeBroadcastInput, type PublishOfferPayload, type DelegateAgreementData, type CounterAgreementData, type CreateLinkBody, type ListAgreementsFilters, type GetMyAgreementsFilters, type ChatLinkType, type ArtifactLinkType, type UserRole } from '@ziggs-ai/api-client';
|
|
2
2
|
export declare class AgreementService {
|
|
3
3
|
private creds;
|
|
4
4
|
constructor(operatorKey: string, agentId: string);
|
|
@@ -20,7 +20,7 @@ export declare class AgreementService {
|
|
|
20
20
|
delegate(payload: DelegateAgreementData): Promise<Agreement>;
|
|
21
21
|
respond(agreementId: string, action: 'approve' | 'reject'): Promise<Agreement>;
|
|
22
22
|
/**
|
|
23
|
-
* claim any open broadcast (
|
|
23
|
+
* claim any open broadcast (request, standing offer, hand-off,
|
|
24
24
|
* link invite). Routing (offer vs agreement claim endpoint) is decided in
|
|
25
25
|
* the shared flow; `kind` is the api-client ClaimedKind so this signature
|
|
26
26
|
* can't drift from the flow again (a 'hand-off' addition broke the build).
|
|
@@ -45,7 +45,12 @@ export declare class AgreementService {
|
|
|
45
45
|
*/
|
|
46
46
|
mine(filters?: GetMyAgreementsFilters): Promise<Agreement[]>;
|
|
47
47
|
get(agreementId: string): Promise<Agreement | null>;
|
|
48
|
-
|
|
48
|
+
/**
|
|
49
|
+
* Create a trust link — direct to one agent, or an open invite. Work
|
|
50
|
+
* agreements are created by `propose` / the marketplace claim routes, which
|
|
51
|
+
* run the term normalisation and consent gates this rail never had.
|
|
52
|
+
*/
|
|
53
|
+
createLink(body: CreateLinkBody): Promise<{
|
|
49
54
|
ok: boolean;
|
|
50
55
|
agreement: Agreement;
|
|
51
56
|
}>;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { proposeAgreement, proposeBroadcast as proposeBroadcastApi, delegateAgreement, respondToAgreement, counterAgreement, claimOpenAgreement, getAgreementStatus, listAgreements, getMyAgreements, getAgreement, publishOffer as publishOfferApi,
|
|
1
|
+
import { proposeAgreement, proposeBroadcast as proposeBroadcastApi, delegateAgreement, respondToAgreement, counterAgreement, claimOpenAgreement, getAgreementStatus, listAgreements, getMyAgreements, getAgreement, publishOffer as publishOfferApi, createLink, revokeAgreement, fulfillAgreement, linkAgreementToChat, getChatsForAgreement, linkArtifactToAgreement, getArtifactsForAgreement, linkUserToAgreement, getUsersForAgreement, } from '@ziggs-ai/api-client';
|
|
2
2
|
export class AgreementService {
|
|
3
3
|
creds;
|
|
4
4
|
constructor(operatorKey, agentId) {
|
|
@@ -38,7 +38,7 @@ export class AgreementService {
|
|
|
38
38
|
return respondToAgreement(agreementId, action, this.creds);
|
|
39
39
|
}
|
|
40
40
|
/**
|
|
41
|
-
* claim any open broadcast (
|
|
41
|
+
* claim any open broadcast (request, standing offer, hand-off,
|
|
42
42
|
* link invite). Routing (offer vs agreement claim endpoint) is decided in
|
|
43
43
|
* the shared flow; `kind` is the api-client ClaimedKind so this signature
|
|
44
44
|
* can't drift from the flow again (a 'hand-off' addition broke the build).
|
|
@@ -51,7 +51,7 @@ export class AgreementService {
|
|
|
51
51
|
* agent; the target owner approves. Folds the old request_link tool.
|
|
52
52
|
*/
|
|
53
53
|
async requestLink(providerId, description) {
|
|
54
|
-
const { agreement } = await
|
|
54
|
+
const { agreement } = await createLink({ targetAgentId: providerId, ...(description ? { description } : {}) }, this.creds);
|
|
55
55
|
return agreement;
|
|
56
56
|
}
|
|
57
57
|
counter(agreementId, counter) {
|
|
@@ -75,8 +75,13 @@ export class AgreementService {
|
|
|
75
75
|
get(agreementId) {
|
|
76
76
|
return getAgreement(agreementId, this.creds);
|
|
77
77
|
}
|
|
78
|
-
|
|
79
|
-
|
|
78
|
+
/**
|
|
79
|
+
* Create a trust link — direct to one agent, or an open invite. Work
|
|
80
|
+
* agreements are created by `propose` / the marketplace claim routes, which
|
|
81
|
+
* run the term normalisation and consent gates this rail never had.
|
|
82
|
+
*/
|
|
83
|
+
createLink(body) {
|
|
84
|
+
return createLink(body, this.creds);
|
|
80
85
|
}
|
|
81
86
|
revoke(agreementId) {
|
|
82
87
|
return revokeAgreement(agreementId, this.creds);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type
|
|
1
|
+
import { type InboxRequestRef, type InboxTaskRef, type Creds } from '@ziggs-ai/api-client';
|
|
2
2
|
import { SeenMessages } from './SeenMessages.js';
|
|
3
3
|
/**
|
|
4
4
|
* Per-agent inbox long-poll loop (delivery law, phase 4). This is the ONLY way
|
|
@@ -27,11 +27,11 @@ export interface InboxLoopDeps {
|
|
|
27
27
|
*/
|
|
28
28
|
onTask?: (task: InboxTaskRef) => Promise<void>;
|
|
29
29
|
/**
|
|
30
|
-
* A marketplace
|
|
30
|
+
* A marketplace request doorbell. The host MUST triage with a plain
|
|
31
31
|
* string compare and return without an LLM wake on miss — otherwise every
|
|
32
|
-
* public
|
|
32
|
+
* public request burns a full Claude turn per published supplier.
|
|
33
33
|
*/
|
|
34
|
-
|
|
34
|
+
onRequest?: (request: InboxRequestRef) => Promise<void>;
|
|
35
35
|
label?: string;
|
|
36
36
|
}
|
|
37
37
|
export interface InboxPassResult {
|
|
@@ -41,8 +41,8 @@ export interface InboxPassResult {
|
|
|
41
41
|
resourceEvents: number;
|
|
42
42
|
/** Assigned open tasks delivered this pass. */
|
|
43
43
|
tasksDelivered: number;
|
|
44
|
-
/**
|
|
45
|
-
|
|
44
|
+
/** Request refs triaged this pass — includes misses that only ack. */
|
|
45
|
+
requestsTriaged: number;
|
|
46
46
|
/** True when the watermark advanced this pass. */
|
|
47
47
|
acked: boolean;
|
|
48
48
|
/** True when there was more mail than one envelope carries. */
|
package/dist/server/inboxLoop.js
CHANGED
|
@@ -83,7 +83,7 @@ export async function pollInboxOnce(deps, waitSeconds = 0, opts = {}) {
|
|
|
83
83
|
let messagesDelivered = 0;
|
|
84
84
|
let resourceEvents = 0;
|
|
85
85
|
let tasksDelivered = 0;
|
|
86
|
-
let
|
|
86
|
+
let requestsTriaged = 0;
|
|
87
87
|
let failures = 0;
|
|
88
88
|
// a handover that failed must not be acked, and must not be
|
|
89
89
|
// remembered as seen — otherwise the redelivery the held watermark buys is
|
|
@@ -107,10 +107,16 @@ export async function pollInboxOnce(deps, waitSeconds = 0, opts = {}) {
|
|
|
107
107
|
// make a badly backed-up agent replay its whole backlog forever.
|
|
108
108
|
warn(label, 'inbox deliveriesCapped (more mail than one envelope carries — the tail follows on the next pass)');
|
|
109
109
|
}
|
|
110
|
-
//
|
|
110
|
+
// The single-assign law: a row is MINE to act on iff it carries my stamp.
|
|
111
|
+
// Everything else in the merged view (a sibling carrier's mail, the owner's
|
|
112
|
+
// needs-human rows, granted ambient lanes) is context the host may read on
|
|
113
|
+
// demand — never a wake, never mine to ack as handled.
|
|
114
|
+
const mine = (d) => d.assigneeId === deps.ownAgentId;
|
|
115
|
+
// Oldest ASSIGNED delivery per chat: where to start reading that chat's
|
|
116
|
+
// messages. Ambient chat rows must not drag the read window earlier.
|
|
111
117
|
const earliestByChat = new Map();
|
|
112
118
|
for (const d of envelope.deliveries) {
|
|
113
|
-
if (!d.chatId)
|
|
119
|
+
if (!d.chatId || !mine(d))
|
|
114
120
|
continue;
|
|
115
121
|
const seen = earliestByChat.get(d.chatId);
|
|
116
122
|
if (!seen || d.ts < seen)
|
|
@@ -126,10 +132,14 @@ export async function pollInboxOnce(deps, waitSeconds = 0, opts = {}) {
|
|
|
126
132
|
// Everything that is not chat text is handed over as a resource event, from
|
|
127
133
|
// the delivery itself — the reference already carries the ids the host needs,
|
|
128
134
|
// so nothing has to be re-derived to name what changed.
|
|
129
|
-
//
|
|
135
|
+
// Requests must never take this path — even if a pre-cut server
|
|
130
136
|
// left them in `deliveries`, skip so they cannot wake the LLM before triage.
|
|
137
|
+
// Unassigned rows never take it either: waking the brain on a sibling's
|
|
138
|
+
// mail is the N-wakes-per-event cost the party-owned inbox exists to kill.
|
|
131
139
|
for (const d of envelope.deliveries) {
|
|
132
|
-
if (d.kind === 'message' || d.kind === '
|
|
140
|
+
if (d.kind === 'message' || d.kind === 'request')
|
|
141
|
+
continue;
|
|
142
|
+
if (!mine(d))
|
|
133
143
|
continue;
|
|
134
144
|
const key = `delivery:${d.kind}:${d.resourceId}:${d.ts}`;
|
|
135
145
|
if (deps.seen.has(key))
|
|
@@ -169,26 +179,26 @@ export async function pollInboxOnce(deps, waitSeconds = 0, opts = {}) {
|
|
|
169
179
|
if (envelope.truncatedTasks) {
|
|
170
180
|
warn(label, `inbox truncatedTasks=${envelope.truncatedTasks} (more assigned tasks than one pass surfaces — they appear as earlier ones close)`);
|
|
171
181
|
}
|
|
172
|
-
//
|
|
182
|
+
// request channel. Triage is the host's job (exact string compare);
|
|
173
183
|
// a miss must still count as handled so the watermark can advance with 0 tokens.
|
|
174
|
-
for (const
|
|
175
|
-
const key = `
|
|
184
|
+
for (const request of envelope.requestsAwaitingMe ?? []) {
|
|
185
|
+
const key = `request:${request.agreementId}:${request.ts}`;
|
|
176
186
|
if (deps.seen.has(key))
|
|
177
187
|
continue;
|
|
178
188
|
deps.seen.mark(key);
|
|
179
|
-
if (deps.
|
|
180
|
-
const handled = await attempt(`
|
|
189
|
+
if (deps.onRequest) {
|
|
190
|
+
const handled = await attempt(`request ${request.agreementId}`, () => deps.onRequest(request), () => deps.seen.unmark(key));
|
|
181
191
|
if (handled)
|
|
182
|
-
|
|
192
|
+
requestsTriaged++;
|
|
183
193
|
}
|
|
184
194
|
else {
|
|
185
195
|
// No handler → still count as handled (ack) so an old host cannot wedge
|
|
186
|
-
// forever on
|
|
187
|
-
|
|
196
|
+
// forever on request mail it does not understand.
|
|
197
|
+
requestsTriaged++;
|
|
188
198
|
}
|
|
189
199
|
}
|
|
190
|
-
if (envelope.
|
|
191
|
-
warn(label, `inbox
|
|
200
|
+
if (envelope.truncatedRequests) {
|
|
201
|
+
warn(label, `inbox truncatedRequests=${envelope.truncatedRequests} (more requests than one pass surfaces — the tail follows on the next pass)`);
|
|
192
202
|
}
|
|
193
203
|
// Pending decisions are standing state, not deliveries: they keep appearing
|
|
194
204
|
// until answered, so dedup them the same way tasks are deduped.
|
|
@@ -216,20 +226,23 @@ export async function pollInboxOnce(deps, waitSeconds = 0, opts = {}) {
|
|
|
216
226
|
// this window has failed too many times in a row and is now poison, in which
|
|
217
227
|
// case it is dropped loudly rather than wedging the agent forever.
|
|
218
228
|
let acked = false;
|
|
219
|
-
//
|
|
229
|
+
// A capped envelope has unlisted older deliveries behind ackTo;
|
|
220
230
|
// acking would refuse or bury them. Hold until a later uncapped pass.
|
|
221
231
|
if (envelope.deliveriesCapped && envelope.ackTo) {
|
|
222
|
-
warn(label, `holding the watermark at ${envelope.ackTo} — deliveriesCapped; ack would not cover the unlisted tail
|
|
232
|
+
warn(label, `holding the watermark at ${envelope.ackTo} — deliveriesCapped; ack would not cover the unlisted tail`);
|
|
223
233
|
}
|
|
224
234
|
else if (envelope.ackTo && (failures === 0 || opts.forceAck)) {
|
|
225
235
|
if (failures > 0) {
|
|
226
236
|
runtimeLog.error(label, `acking a window that failed ${failures} handover(s) — giving up on redelivery to keep the ` +
|
|
227
237
|
`agent alive; this mail is lost (ackTo=${envelope.ackTo})`);
|
|
228
238
|
}
|
|
239
|
+
// Assigned rows only: the server's bury-guard checks what was MINE to
|
|
240
|
+
// handle. Ambient rows slide past the cursor unlisted — they were never
|
|
241
|
+
// this agent's to act on, and the owner's seen mark tracks them.
|
|
229
242
|
const handledResourceIds = [
|
|
230
243
|
...new Set([
|
|
231
|
-
...envelope.deliveries.map((d) => d.resourceId),
|
|
232
|
-
...(envelope.
|
|
244
|
+
...envelope.deliveries.filter(mine).map((d) => d.resourceId),
|
|
245
|
+
...(envelope.requestsAwaitingMe ?? []).map((q) => q.agreementId),
|
|
233
246
|
]),
|
|
234
247
|
].filter((id) => typeof id === 'string' && id.length > 0);
|
|
235
248
|
await inbox.ack(envelope.ackTo, { handledResourceIds });
|
|
@@ -245,13 +258,18 @@ export async function pollInboxOnce(deps, waitSeconds = 0, opts = {}) {
|
|
|
245
258
|
messagesDelivered,
|
|
246
259
|
resourceEvents,
|
|
247
260
|
tasksDelivered,
|
|
248
|
-
|
|
261
|
+
requestsTriaged,
|
|
249
262
|
acked,
|
|
250
263
|
truncated: envelope.deliveriesCapped,
|
|
251
264
|
failures,
|
|
252
265
|
};
|
|
253
266
|
}
|
|
254
|
-
|
|
267
|
+
/**
|
|
268
|
+
* Default long-poll hold for hosted agents. Matches the server's own ceiling,
|
|
269
|
+
* and assumes a proxy idle timeout comfortably above it rather than the 25s
|
|
270
|
+
* some defaults ship with. Lower it if your deployment sits behind a shorter one.
|
|
271
|
+
*/
|
|
272
|
+
const DEFAULT_WAIT_SECONDS = 110;
|
|
255
273
|
const ERROR_BACKOFF_BASE_MS = 1_000;
|
|
256
274
|
const ERROR_BACKOFF_MAX_MS = 30_000;
|
|
257
275
|
/**
|
|
@@ -41,6 +41,26 @@ export interface PollRunnerOptions {
|
|
|
41
41
|
* through every single time.
|
|
42
42
|
*/
|
|
43
43
|
wakeTimeoutMs?: number;
|
|
44
|
+
/**
|
|
45
|
+
* Most actionable wakes one binding may cause per {@link wakeWindowMs}.
|
|
46
|
+
* Default 20.
|
|
47
|
+
*
|
|
48
|
+
* A poll wakes its agent whenever the source says `actionable`, and each wake
|
|
49
|
+
* is a real model run. Nothing else bounds that: the agent polls the outside
|
|
50
|
+
* world and triggers itself, so the backend never sees it and the agreement
|
|
51
|
+
* wake ceiling never gets a chance to fire. A mailbox that stops being
|
|
52
|
+
* archived keeps answering "yes, there is unhandled mail" forever, at one wake
|
|
53
|
+
* per interval, indefinitely.
|
|
54
|
+
*
|
|
55
|
+
* 20 an hour is far above any healthy agent and roughly 20 minutes of a
|
|
56
|
+
* runaway at the 60s default interval.
|
|
57
|
+
*
|
|
58
|
+
* Deliberately a runner option and NOT read from the PollKind or the binding:
|
|
59
|
+
* a bound that the bounded thing can raise is not a bound.
|
|
60
|
+
*/
|
|
61
|
+
maxWakesPerWindow?: number;
|
|
62
|
+
/** Window the wake cap is measured over. Default 1 hour. */
|
|
63
|
+
wakeWindowMs?: number;
|
|
44
64
|
label?: string;
|
|
45
65
|
}
|
|
46
66
|
export declare class PollRunner {
|
|
@@ -49,9 +69,11 @@ export declare class PollRunner {
|
|
|
49
69
|
private readonly label;
|
|
50
70
|
private readonly maxBackoffMs;
|
|
51
71
|
private readonly wakeTimeoutMs;
|
|
72
|
+
private readonly maxWakesPerWindow;
|
|
73
|
+
private readonly wakeWindowMs;
|
|
52
74
|
private readonly loops;
|
|
53
75
|
private running;
|
|
54
|
-
constructor({ pool, registry, agents, operatorKey, defaultIntervalMs, maxBackoffMs, wakeTimeoutMs, label, }: PollRunnerOptions);
|
|
76
|
+
constructor({ pool, registry, agents, operatorKey, defaultIntervalMs, maxBackoffMs, wakeTimeoutMs, maxWakesPerWindow, wakeWindowMs, label, }: PollRunnerOptions);
|
|
55
77
|
get size(): number;
|
|
56
78
|
start(): void;
|
|
57
79
|
stop(): void;
|
|
@@ -59,4 +81,17 @@ export declare class PollRunner {
|
|
|
59
81
|
/** Interval while healthy; doubling up to the ceiling while failing. */
|
|
60
82
|
private nextDelay;
|
|
61
83
|
private tick;
|
|
84
|
+
/**
|
|
85
|
+
* Take one wake from this binding's budget, or refuse.
|
|
86
|
+
*
|
|
87
|
+
* Refusing is announced once per window, at error level, and resuming is
|
|
88
|
+
* announced too. A poll that simply goes quiet is worse than one that wakes
|
|
89
|
+
* too much: from the outside it is indistinguishable from a mailbox that is
|
|
90
|
+
* genuinely empty, so nobody looks.
|
|
91
|
+
*
|
|
92
|
+
* The source keeps being polled while capped. Stopping the poll as well would
|
|
93
|
+
* mean the first tick after the window rolls is the one that discovers the
|
|
94
|
+
* backlog, and a source that recovered would go unnoticed for a whole window.
|
|
95
|
+
*/
|
|
96
|
+
private claimWake;
|
|
62
97
|
}
|
|
@@ -26,14 +26,18 @@ export class PollRunner {
|
|
|
26
26
|
label;
|
|
27
27
|
maxBackoffMs;
|
|
28
28
|
wakeTimeoutMs;
|
|
29
|
+
maxWakesPerWindow;
|
|
30
|
+
wakeWindowMs;
|
|
29
31
|
loops = [];
|
|
30
32
|
running = false;
|
|
31
|
-
constructor({ pool, registry, agents, operatorKey, defaultIntervalMs = 60_000, maxBackoffMs = 15 * 60_000, wakeTimeoutMs = Number(process.env.CLAUDE_SDK_TICK_TIMEOUT_MS ?? 300_000), label = 'PollRunner', }) {
|
|
33
|
+
constructor({ pool, registry, agents, operatorKey, defaultIntervalMs = 60_000, maxBackoffMs = 15 * 60_000, wakeTimeoutMs = Number(process.env.CLAUDE_SDK_TICK_TIMEOUT_MS ?? 300_000), maxWakesPerWindow = 20, wakeWindowMs = 60 * 60_000, label = 'PollRunner', }) {
|
|
32
34
|
this.pool = pool;
|
|
33
35
|
this.operatorKey = operatorKey;
|
|
34
36
|
this.label = label;
|
|
35
37
|
this.maxBackoffMs = maxBackoffMs;
|
|
36
38
|
this.wakeTimeoutMs = wakeTimeoutMs;
|
|
39
|
+
this.maxWakesPerWindow = maxWakesPerWindow;
|
|
40
|
+
this.wakeWindowMs = wakeWindowMs;
|
|
37
41
|
for (const config of agents) {
|
|
38
42
|
const agentId = config['agentId'];
|
|
39
43
|
if (typeof agentId !== 'string' || !agentId)
|
|
@@ -61,6 +65,9 @@ export class PollRunner {
|
|
|
61
65
|
chatId: binding.chatId,
|
|
62
66
|
timer: null,
|
|
63
67
|
failures: 0,
|
|
68
|
+
wakes: 0,
|
|
69
|
+
windowStartedAt: Date.now(),
|
|
70
|
+
capped: false,
|
|
64
71
|
});
|
|
65
72
|
}
|
|
66
73
|
}
|
|
@@ -115,7 +122,7 @@ export class PollRunner {
|
|
|
115
122
|
this.schedule(loop);
|
|
116
123
|
return;
|
|
117
124
|
}
|
|
118
|
-
if (result?.actionable) {
|
|
125
|
+
if (result?.actionable && this.claimWake(loop)) {
|
|
119
126
|
const hint = result.hint ?? `New work from ${loop.kind.id}. Check it.`;
|
|
120
127
|
try {
|
|
121
128
|
// Awaited on purpose — see the header. The next tick waits for the
|
|
@@ -135,4 +142,37 @@ export class PollRunner {
|
|
|
135
142
|
}
|
|
136
143
|
this.schedule(loop);
|
|
137
144
|
}
|
|
145
|
+
/**
|
|
146
|
+
* Take one wake from this binding's budget, or refuse.
|
|
147
|
+
*
|
|
148
|
+
* Refusing is announced once per window, at error level, and resuming is
|
|
149
|
+
* announced too. A poll that simply goes quiet is worse than one that wakes
|
|
150
|
+
* too much: from the outside it is indistinguishable from a mailbox that is
|
|
151
|
+
* genuinely empty, so nobody looks.
|
|
152
|
+
*
|
|
153
|
+
* The source keeps being polled while capped. Stopping the poll as well would
|
|
154
|
+
* mean the first tick after the window rolls is the one that discovers the
|
|
155
|
+
* backlog, and a source that recovered would go unnoticed for a whole window.
|
|
156
|
+
*/
|
|
157
|
+
claimWake(loop) {
|
|
158
|
+
const now = Date.now();
|
|
159
|
+
if (now - loop.windowStartedAt >= this.wakeWindowMs) {
|
|
160
|
+
if (loop.capped) {
|
|
161
|
+
runtimeLog.info(this.label, `${loop.agentId}:${loop.kind.id} wake budget reset — waking again (cap ${this.maxWakesPerWindow}/${this.wakeWindowMs}ms)`);
|
|
162
|
+
}
|
|
163
|
+
loop.windowStartedAt = now;
|
|
164
|
+
loop.wakes = 0;
|
|
165
|
+
loop.capped = false;
|
|
166
|
+
}
|
|
167
|
+
if (loop.wakes >= this.maxWakesPerWindow) {
|
|
168
|
+
if (!loop.capped) {
|
|
169
|
+
loop.capped = true;
|
|
170
|
+
runtimeLog.error(this.label, `${loop.agentId}:${loop.kind.id} hit the wake cap (${this.maxWakesPerWindow} in ${this.wakeWindowMs}ms) and will NOT be woken again until the window rolls. ` +
|
|
171
|
+
`The source keeps reporting actionable, so either it is genuinely that busy or something downstream stopped clearing it.`);
|
|
172
|
+
}
|
|
173
|
+
return false;
|
|
174
|
+
}
|
|
175
|
+
loop.wakes += 1;
|
|
176
|
+
return true;
|
|
177
|
+
}
|
|
138
178
|
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* exact-match triage for marketplace
|
|
2
|
+
* exact-match triage for marketplace requests.
|
|
3
3
|
* Empty match never hits (token-safe default). Comparison is exact after trim.
|
|
4
4
|
*/
|
|
5
|
-
export function
|
|
5
|
+
export function requestExactMatch(match, tags) {
|
|
6
6
|
const needle = typeof match === 'string' ? match.trim() : '';
|
|
7
7
|
if (!needle)
|
|
8
8
|
return false;
|
|
@@ -5,6 +5,14 @@ import { boundKeyFleetWarning } from '../shared/operatorKeyClaims.js';
|
|
|
5
5
|
import { runtimeLog } from '../shared/runtimeLog.js';
|
|
6
6
|
import { PollRegistry } from './polls/PollKind.js';
|
|
7
7
|
import { PollRunner } from './polls/PollRunner.js';
|
|
8
|
+
/**
|
|
9
|
+
* How long a shutdown waits for in-flight turns after it has stopped taking new
|
|
10
|
+
* work. An agent turn is an LLM call, so the old 30s was under the length of the
|
|
11
|
+
* work it was meant to protect; the container's stop timeout is what has to be
|
|
12
|
+
* larger still, or the drain is cut short by SIGKILL regardless (ECS
|
|
13
|
+
* `stopTimeout`, 120s on the fleet task definitions).
|
|
14
|
+
*/
|
|
15
|
+
const DRAIN_TIMEOUT_MS = Number(process.env.FLEET_DRAIN_TIMEOUT_MS) || 90_000;
|
|
8
16
|
/**
|
|
9
17
|
* Collect the poll kinds the agents themselves ship and start a runner for
|
|
10
18
|
* whatever they declared. This lives in the launcher, not in a caller's
|
|
@@ -57,7 +65,7 @@ async function runSingle(config, { operatorKey, label = 'agent', port = process.
|
|
|
57
65
|
runtimeLog.info(label, 'SIGTERM — shutting down');
|
|
58
66
|
pollRunner?.stop();
|
|
59
67
|
agent.disconnect();
|
|
60
|
-
await agent.drain(
|
|
68
|
+
await agent.drain(DRAIN_TIMEOUT_MS);
|
|
61
69
|
try {
|
|
62
70
|
await onShutdown?.();
|
|
63
71
|
}
|
|
@@ -122,10 +130,23 @@ async function runFleet(configs, { operatorKey, port = process.env.PORT || 8080,
|
|
|
122
130
|
});
|
|
123
131
|
process.on('SIGTERM', async () => {
|
|
124
132
|
runtimeLog.info(label, 'SIGTERM — shutting down');
|
|
133
|
+
// Stop taking new work everywhere first, then wait for what is running.
|
|
134
|
+
//
|
|
135
|
+
// This used to disconnect and drain only `primary` — which `run.ts` never
|
|
136
|
+
// sets, so in the deployed fleet the drain was a no-op on null and the
|
|
137
|
+
// process exited straight through `disconnectAll`, killing every in-flight
|
|
138
|
+
// turn. A buyer whose request landed mid-rollout got a host that stopped
|
|
139
|
+
// mid-sentence and never reported.
|
|
140
|
+
//
|
|
141
|
+
// Draining before stopping intake is the other half of the same bug: the
|
|
142
|
+
// wait is exactly the window in which a host on its way out keeps accepting
|
|
143
|
+
// deliveries the incoming host is also being handed.
|
|
125
144
|
pollRunner?.stop();
|
|
126
145
|
primary?.disconnect?.();
|
|
127
|
-
await
|
|
128
|
-
|
|
146
|
+
await Promise.all([
|
|
147
|
+
pool.shutdown(DRAIN_TIMEOUT_MS),
|
|
148
|
+
primary?.drain?.(DRAIN_TIMEOUT_MS) ?? Promise.resolve(),
|
|
149
|
+
]);
|
|
129
150
|
try {
|
|
130
151
|
await onShutdown?.();
|
|
131
152
|
}
|
|
@@ -8,10 +8,10 @@ export declare class TaskService {
|
|
|
8
8
|
getActiveTasksForChat(chatId: string): Promise<Task[]>;
|
|
9
9
|
getSubtasks(parentTaskId: string): Promise<Task[]>;
|
|
10
10
|
spawnUnderAgreement(agreementId: string, taskData: Omit<CreateTaskData, 'agreementId'>): Promise<Task>;
|
|
11
|
-
updateState(taskId: string, state: TaskState, result?: unknown, idempotencyKey?: string): Promise<
|
|
12
|
-
updatePlanStep(taskId: string, stepId: string, status: string, result?: unknown): Promise<
|
|
13
|
-
cancel(taskId: string): Promise<
|
|
14
|
-
report(taskId: string, message?: string): Promise<
|
|
15
|
-
setSatisfaction(taskId: string, satisfaction: 'positive' | 'negative'): Promise<
|
|
11
|
+
updateState(taskId: string, state: TaskState, result?: unknown, idempotencyKey?: string): Promise<import("@ziggs-ai/api-client").TaskWriteConfirm>;
|
|
12
|
+
updatePlanStep(taskId: string, stepId: string, status: string, result?: unknown): Promise<import("@ziggs-ai/api-client").TaskWriteConfirm>;
|
|
13
|
+
cancel(taskId: string): Promise<import("@ziggs-ai/api-client").TaskWriteConfirm>;
|
|
14
|
+
report(taskId: string, message?: string): Promise<import("@ziggs-ai/api-client").TaskWriteConfirm>;
|
|
15
|
+
setSatisfaction(taskId: string, satisfaction: 'positive' | 'negative'): Promise<import("@ziggs-ai/api-client").TaskWriteConfirm>;
|
|
16
16
|
count(filters?: CountTasksFilters): Promise<number>;
|
|
17
17
|
}
|
|
@@ -19,7 +19,7 @@ export { PROTOCOL_TOOLS, agreementProposeTool, agreementSubcontractTool, agreeme
|
|
|
19
19
|
export { dispatchProtocolOp } from './tier1/protocolRunner.js';
|
|
20
20
|
export { DISCOVERY_TOOLS, agentSearchTool, agentGetTool } from './tier2/discoveryTools.js';
|
|
21
21
|
export { MARKETPLACE_TOOLS, marketplaceViewTool } from './tier2/marketplaceTools.js';
|
|
22
|
-
export { PAYMENT_TOOLS, paymentBalanceTool
|
|
22
|
+
export { PAYMENT_TOOLS, paymentBalanceTool } from './tier2/paymentTools.js';
|
|
23
23
|
export { CONNECTION_TOOLS, connectionProxyTool, connectionListGrantsTool, requestConnectionTool, } from './tier2/connectionTools.js';
|
|
24
24
|
export { CONTEXT_TOOLS, contextReadTool, contextDelegateTool, contextExpandReachTool, contextDiscoverGrantableTool, } from './tier2/contextTools.js';
|
|
25
25
|
export { GRANTS_TOOLS, listGrantsTool } from './tier2/grantsTools.js';
|
|
@@ -21,7 +21,7 @@ export { dispatchProtocolOp } from './tier1/protocolRunner.js';
|
|
|
21
21
|
// ── Tier 2: capability bundles ──────────────────────────────────────────────
|
|
22
22
|
export { DISCOVERY_TOOLS, agentSearchTool, agentGetTool } from './tier2/discoveryTools.js';
|
|
23
23
|
export { MARKETPLACE_TOOLS, marketplaceViewTool } from './tier2/marketplaceTools.js';
|
|
24
|
-
export { PAYMENT_TOOLS, paymentBalanceTool
|
|
24
|
+
export { PAYMENT_TOOLS, paymentBalanceTool } from './tier2/paymentTools.js';
|
|
25
25
|
export { CONNECTION_TOOLS, connectionProxyTool, connectionListGrantsTool, requestConnectionTool, } from './tier2/connectionTools.js';
|
|
26
26
|
export { CONTEXT_TOOLS, contextReadTool, contextDelegateTool, contextExpandReachTool, contextDiscoverGrantableTool, } from './tier2/contextTools.js';
|
|
27
27
|
// one grant lister across every rail (retires context_discover +
|