@ziggs-ai/agent-sdk 0.9.0 → 0.10.1
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 +23 -0
- package/dist/AgentHost.d.ts +53 -14
- package/dist/AgentHost.js +137 -8
- package/dist/agent/ClaudeSdkAgent.d.ts +27 -2
- package/dist/agent/ClaudeSdkAgent.js +151 -78
- package/dist/agent/claudeSdkTools.js +39 -11
- package/dist/context/applyEffects.js +4 -3
- package/dist/defineAgent.js +4 -1
- package/dist/formatters/HistoryFormatter.js +11 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.js +3 -2
- package/dist/ingress/normalizeIncoming.d.ts +5 -0
- package/dist/ingress/normalizeIncoming.js +23 -1
- package/dist/pricing/fleetDefaults.d.ts +0 -3
- package/dist/pricing/fleetDefaults.js +0 -6
- package/dist/runtime/PromptBuilder.js +9 -5
- package/dist/runtime/bootstrapCtx.js +6 -8
- package/dist/runtime/buildOutcome.d.ts +9 -1
- package/dist/runtime/buildOutcome.js +29 -6
- package/dist/runtime/runTurn.js +1 -1
- package/dist/runtime/taskBookkeeping.d.ts +0 -2
- package/dist/runtime/taskBookkeeping.js +0 -6
- package/dist/runtime/taskIntake.js +6 -4
- package/dist/runtime/wakeWriteKeys.js +23 -21
- package/dist/server/ConnectionPool.d.ts +10 -52
- package/dist/server/ConnectionPool.js +26 -57
- package/dist/server/EventQueue.d.ts +6 -0
- package/dist/server/EventQueue.js +21 -6
- package/dist/server/SeenMessages.d.ts +7 -0
- package/dist/server/SeenMessages.js +15 -0
- package/dist/server/ZiggsEffectHandler.d.ts +1 -0
- package/dist/server/ZiggsEffectHandler.js +21 -11
- package/dist/server/inboxLoop.d.ts +17 -2
- package/dist/server/inboxLoop.js +92 -16
- package/dist/server/polls/PollKind.d.ts +72 -0
- package/dist/server/polls/PollKind.js +53 -0
- package/dist/server/polls/PollRunner.d.ts +62 -0
- package/dist/server/polls/PollRunner.js +138 -0
- package/dist/server/questMatch.d.ts +5 -0
- package/dist/server/questMatch.js +14 -0
- package/dist/server/runLauncher.d.ts +0 -6
- package/dist/server/runLauncher.js +54 -27
- package/dist/server/tasks/TaskService.d.ts +2 -11
- package/dist/server/tools/tier1/protocolRunner.d.ts +3 -1
- package/dist/server/tools/tier1/protocolRunner.js +40 -41
- package/dist/server/tools/tier1/protocolTools.js +23 -13
- package/dist/server/tools/tier2/connectionTools.js +12 -1
- package/dist/tasks/protocolRegistry.d.ts +27 -0
- package/dist/tasks/protocolRegistry.js +36 -0
- package/dist/tools/capabilityAdapter.js +12 -1
- package/dist/tools/recordReport.js +8 -4
- package/dist/types.d.ts +24 -11
- package/package.json +2 -2
- package/dist/server/staleBacklogGate.d.ts +0 -57
- package/dist/server/staleBacklogGate.js +0 -100
|
@@ -67,6 +67,7 @@ export declare function executeToolWithSideEffects(deps: Pick<ZiggsEffectDeps, '
|
|
|
67
67
|
}>, roster: {
|
|
68
68
|
agents: unknown[];
|
|
69
69
|
users: unknown[];
|
|
70
|
+
agreements?: unknown[];
|
|
70
71
|
}): Promise<ToolCallResult>;
|
|
71
72
|
/**
|
|
72
73
|
* Build a single shared EffectHandler. The handler is stateful — it caches
|
|
@@ -109,11 +109,14 @@ export async function executeToolWithSideEffects(deps, eff, effect, roster) {
|
|
|
109
109
|
const toolT0 = Date.now();
|
|
110
110
|
const args = effect.args || {};
|
|
111
111
|
const argsPreview = previewToolArgs(args);
|
|
112
|
-
|
|
112
|
+
// ZIG-1120 #6 — breadcrumbs are best-effort. Awaiting them bracketed every
|
|
113
|
+
// tool call; a slow/wedged artifacts backend stalled the whole wake. Match
|
|
114
|
+
// ClaudeSdkAgent thought writes (fire-and-forget).
|
|
115
|
+
void emitToolOperationArtifact(deps.artifactsClient, effect.sessionId, 'started', {
|
|
113
116
|
state: 'started',
|
|
114
117
|
tool: effect.name,
|
|
115
118
|
...(argsPreview ? { args: argsPreview } : {}),
|
|
116
|
-
}, effect.wakeKeys?.artifactKey());
|
|
119
|
+
}, effect.wakeKeys?.artifactKey({ s: 'started', t: effect.name, a: argsPreview }));
|
|
117
120
|
try {
|
|
118
121
|
const lastUser = roster.users.find((u) => u.userId || u.id);
|
|
119
122
|
// Accumulate token usage from any nested LLM call the tool makes (llmTool),
|
|
@@ -142,6 +145,9 @@ export async function executeToolWithSideEffects(deps, eff, effect, roster) {
|
|
|
142
145
|
eff,
|
|
143
146
|
agents: roster.agents,
|
|
144
147
|
users: roster.users,
|
|
148
|
+
// ZIG-1120 #8 — session snapshot already has agreements; protocolRunner
|
|
149
|
+
// used to re-fetch them over HTTP on every spawn/subcontract.
|
|
150
|
+
agreements: roster.agreements ?? [],
|
|
145
151
|
senderId: effect.senderId ?? lastUser?.userId ?? lastUser?.id,
|
|
146
152
|
senderType: effect.senderType ?? (lastUser ? 'USER' : undefined),
|
|
147
153
|
onBehalfOfAgreementId: effect.onBehalfOfAgreementId,
|
|
@@ -150,11 +156,14 @@ export async function executeToolWithSideEffects(deps, eff, effect, roster) {
|
|
|
150
156
|
reportUsage,
|
|
151
157
|
turnScratch: {},
|
|
152
158
|
});
|
|
153
|
-
|
|
159
|
+
// One clip per tool call: this is a full JSON.stringify of the untruncated
|
|
160
|
+
// result, and the artifact body and the idempotency key want the same string.
|
|
161
|
+
const clipped = clipForOperationArtifact(result);
|
|
162
|
+
void emitToolOperationArtifact(deps.artifactsClient, effect.sessionId, 'completed', {
|
|
154
163
|
state: 'completed',
|
|
155
164
|
tool: effect.name,
|
|
156
|
-
result:
|
|
157
|
-
}, effect.wakeKeys?.artifactKey());
|
|
165
|
+
result: clipped,
|
|
166
|
+
}, effect.wakeKeys?.artifactKey({ s: 'completed', t: effect.name, r: clipped }));
|
|
158
167
|
sendTelemetry(deps.telemetryClient, buildToolIngestPayload({
|
|
159
168
|
sessionId: effect.sessionId,
|
|
160
169
|
tool: effect.name,
|
|
@@ -168,11 +177,11 @@ export async function executeToolWithSideEffects(deps, eff, effect, roster) {
|
|
|
168
177
|
catch (error) {
|
|
169
178
|
const errMsg = error?.message ?? String(error);
|
|
170
179
|
runtimeLog.warn('ZiggsEffectHandler', `tool-call "${effect.name}" failed: ${errMsg}`);
|
|
171
|
-
|
|
180
|
+
void emitToolOperationArtifact(deps.artifactsClient, effect.sessionId, 'error', {
|
|
172
181
|
state: 'failed',
|
|
173
182
|
tool: effect.name,
|
|
174
183
|
error: errMsg,
|
|
175
|
-
}, effect.wakeKeys?.artifactKey());
|
|
184
|
+
}, effect.wakeKeys?.artifactKey({ s: 'failed', t: effect.name, e: errMsg }));
|
|
176
185
|
sendTelemetry(deps.telemetryClient, buildToolIngestPayload({
|
|
177
186
|
sessionId: effect.sessionId,
|
|
178
187
|
tool: effect.name,
|
|
@@ -274,14 +283,15 @@ export function createZiggsEffectHandler(deps) {
|
|
|
274
283
|
return { text, usage };
|
|
275
284
|
}
|
|
276
285
|
case 'tool-call': {
|
|
277
|
-
// Roster
|
|
278
|
-
// cognitions land here — the FSM via runTurn's
|
|
279
|
-
// Agent SDK via the toolManagerToSdkMcp bridge —
|
|
280
|
-
// emitted exactly once, in one place (ZIG-775).
|
|
286
|
+
// Roster + agreements come from the cached snapshot (populated by
|
|
287
|
+
// read-context). Both cognitions land here — the FSM via runTurn's
|
|
288
|
+
// executeTool, the Claude Agent SDK via the toolManagerToSdkMcp bridge —
|
|
289
|
+
// so tool side effects are emitted exactly once, in one place (ZIG-775).
|
|
281
290
|
const cached = cacheGet(effect.sessionId);
|
|
282
291
|
return executeToolWithSideEffects(deps, handler, effect, {
|
|
283
292
|
agents: cached?.agents || [],
|
|
284
293
|
users: cached?.users || [],
|
|
294
|
+
agreements: cached?.agreements || [],
|
|
285
295
|
});
|
|
286
296
|
}
|
|
287
297
|
case 'send-message': {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type InboxTaskRef, type Creds } from '@ziggs-ai/api-client';
|
|
1
|
+
import { type InboxQuestRef, 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
|
|
@@ -26,6 +26,12 @@ export interface InboxLoopDeps {
|
|
|
26
26
|
* for it (chat-less agreement, self-assigned task).
|
|
27
27
|
*/
|
|
28
28
|
onTask?: (task: InboxTaskRef) => Promise<void>;
|
|
29
|
+
/**
|
|
30
|
+
* A marketplace quest doorbell (ZIG-1185). The host MUST triage with a plain
|
|
31
|
+
* string compare and return without an LLM wake on miss — otherwise every
|
|
32
|
+
* public quest burns a full Claude turn per published supplier.
|
|
33
|
+
*/
|
|
34
|
+
onQuest?: (quest: InboxQuestRef) => Promise<void>;
|
|
29
35
|
label?: string;
|
|
30
36
|
}
|
|
31
37
|
export interface InboxPassResult {
|
|
@@ -35,10 +41,17 @@ export interface InboxPassResult {
|
|
|
35
41
|
resourceEvents: number;
|
|
36
42
|
/** Assigned open tasks delivered this pass (ZIG-973). */
|
|
37
43
|
tasksDelivered: number;
|
|
44
|
+
/** Quest refs triaged this pass (ZIG-1185) — includes misses that only ack. */
|
|
45
|
+
questsTriaged: number;
|
|
38
46
|
/** True when the watermark advanced this pass. */
|
|
39
47
|
acked: boolean;
|
|
40
48
|
/** True when there was more mail than one envelope carries. */
|
|
41
49
|
truncated: boolean;
|
|
50
|
+
/**
|
|
51
|
+
* Handovers into the host that failed this pass (ZIG-1191). Non-zero means the
|
|
52
|
+
* watermark was deliberately held, so the same window arrives again.
|
|
53
|
+
*/
|
|
54
|
+
failures: number;
|
|
42
55
|
}
|
|
43
56
|
/**
|
|
44
57
|
* One long-poll pass: hold up to `waitSeconds` for new inbox content, then read
|
|
@@ -46,7 +59,9 @@ export interface InboxPassResult {
|
|
|
46
59
|
* Reused each iteration of {@link runInboxLoop}; also usable as a single catch-up
|
|
47
60
|
* read (`waitSeconds: 0`) at bootstrap.
|
|
48
61
|
*/
|
|
49
|
-
export declare function pollInboxOnce(deps: InboxLoopDeps, waitSeconds?: number
|
|
62
|
+
export declare function pollInboxOnce(deps: InboxLoopDeps, waitSeconds?: number, opts?: {
|
|
63
|
+
forceAck?: boolean;
|
|
64
|
+
}): Promise<InboxPassResult>;
|
|
50
65
|
export interface InboxLoopHandle {
|
|
51
66
|
/** Stop the loop after the current pass; resolves when it has fully exited. */
|
|
52
67
|
stop(): Promise<void>;
|
package/dist/server/inboxLoop.js
CHANGED
|
@@ -15,7 +15,7 @@ function warn(label, msg) {
|
|
|
15
15
|
* reads through `/context/read`, where the agent's own grants fence what it is
|
|
16
16
|
* allowed to see. A delivery reference is an address, never an authorisation.
|
|
17
17
|
*/
|
|
18
|
-
async function deliverMessagesForChat(deps, news, earliestTs, reader) {
|
|
18
|
+
async function deliverMessagesForChat(deps, news, earliestTs, reader, attempt) {
|
|
19
19
|
let delivered = 0;
|
|
20
20
|
let remaining = news.count;
|
|
21
21
|
// `after` is exclusive, so step back a millisecond to include the oldest
|
|
@@ -55,8 +55,11 @@ async function deliverMessagesForChat(deps, news, earliestTs, reader) {
|
|
|
55
55
|
const text = typeof row.text === 'string' ? row.text : '';
|
|
56
56
|
if (!text.trim())
|
|
57
57
|
continue;
|
|
58
|
-
|
|
59
|
-
|
|
58
|
+
// The host un-marks its own `seen` entry when a message fails, so there is
|
|
59
|
+
// nothing to forget here — but the failure still holds the watermark.
|
|
60
|
+
const handled = await attempt(`chat ${news.chatId} message ${messageId}`, () => deps.onMessage(text, { ...row, chatId: news.chatId }));
|
|
61
|
+
if (handled)
|
|
62
|
+
delivered++;
|
|
60
63
|
remaining--;
|
|
61
64
|
}
|
|
62
65
|
if (!page.hasMore || !page.latestSequence)
|
|
@@ -72,7 +75,7 @@ async function deliverMessagesForChat(deps, news, earliestTs, reader) {
|
|
|
72
75
|
* Reused each iteration of {@link runInboxLoop}; also usable as a single catch-up
|
|
73
76
|
* read (`waitSeconds: 0`) at bootstrap.
|
|
74
77
|
*/
|
|
75
|
-
export async function pollInboxOnce(deps, waitSeconds = 0) {
|
|
78
|
+
export async function pollInboxOnce(deps, waitSeconds = 0, opts = {}) {
|
|
76
79
|
const label = deps.label ?? 'inboxLoop';
|
|
77
80
|
const inbox = new InboxClient(deps.creds.operatorKey, deps.creds.agentId, deps.baseUrl);
|
|
78
81
|
const reader = new ContextReadClient(deps.creds.operatorKey, deps.creds.agentId, deps.baseUrl);
|
|
@@ -80,6 +83,24 @@ export async function pollInboxOnce(deps, waitSeconds = 0) {
|
|
|
80
83
|
let messagesDelivered = 0;
|
|
81
84
|
let resourceEvents = 0;
|
|
82
85
|
let tasksDelivered = 0;
|
|
86
|
+
let questsTriaged = 0;
|
|
87
|
+
let failures = 0;
|
|
88
|
+
// ZIG-1191: a handover that failed must not be acked, and must not be
|
|
89
|
+
// remembered as seen — otherwise the redelivery the held watermark buys is
|
|
90
|
+
// dropped as a duplicate. One failure does not abandon the rest of the
|
|
91
|
+
// envelope: the others are still delivered, and the watermark waits for all.
|
|
92
|
+
const attempt = async (what, handover, forget) => {
|
|
93
|
+
try {
|
|
94
|
+
await handover();
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
catch (err) {
|
|
98
|
+
failures++;
|
|
99
|
+
forget?.();
|
|
100
|
+
warn(label, `${what} was not handled: ${err?.message ?? err} — holding the ack`);
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
};
|
|
83
104
|
if (envelope.deliveriesCapped) {
|
|
84
105
|
// Informational only — does NOT block acking what WAS handed over. The
|
|
85
106
|
// tail stays unacked and arrives on the next pass; never acking here would
|
|
@@ -97,7 +118,7 @@ export async function pollInboxOnce(deps, waitSeconds = 0) {
|
|
|
97
118
|
}
|
|
98
119
|
for (const news of envelope.chats) {
|
|
99
120
|
const earliest = earliestByChat.get(news.chatId) ?? news.latestAt;
|
|
100
|
-
const n = await deliverMessagesForChat(deps, news, earliest, reader);
|
|
121
|
+
const n = await deliverMessagesForChat(deps, news, earliest, reader, attempt);
|
|
101
122
|
messagesDelivered += n;
|
|
102
123
|
if (n > 0)
|
|
103
124
|
log(label, `chat ${news.chatId} delivered ${n} message(s)`);
|
|
@@ -105,14 +126,16 @@ export async function pollInboxOnce(deps, waitSeconds = 0) {
|
|
|
105
126
|
// Everything that is not chat text is handed over as a resource event, from
|
|
106
127
|
// the delivery itself — the reference already carries the ids the host needs,
|
|
107
128
|
// so nothing has to be re-derived to name what changed.
|
|
129
|
+
// Quests must never take this path (ZIG-1185) — even if a pre-cut server
|
|
130
|
+
// left them in `deliveries`, skip so they cannot wake the LLM before triage.
|
|
108
131
|
for (const d of envelope.deliveries) {
|
|
109
|
-
if (d.kind === 'message')
|
|
132
|
+
if (d.kind === 'message' || d.kind === 'quest')
|
|
110
133
|
continue;
|
|
111
134
|
const key = `delivery:${d.kind}:${d.resourceId}:${d.ts}`;
|
|
112
135
|
if (deps.seen.has(key))
|
|
113
136
|
continue;
|
|
114
137
|
deps.seen.mark(key);
|
|
115
|
-
await deps.onResourceEvent({
|
|
138
|
+
const handled = await attempt(`${d.kind} ${d.resourceId}`, () => deps.onResourceEvent({
|
|
116
139
|
kind: d.kind,
|
|
117
140
|
ts: d.ts,
|
|
118
141
|
resourceId: d.resourceId,
|
|
@@ -120,8 +143,9 @@ export async function pollInboxOnce(deps, waitSeconds = 0) {
|
|
|
120
143
|
...(d.agreementId ? { agreementId: d.agreementId } : {}),
|
|
121
144
|
...(d.taskId ? { taskId: d.taskId } : {}),
|
|
122
145
|
change: 'created',
|
|
123
|
-
});
|
|
124
|
-
|
|
146
|
+
}), () => deps.seen.unmark(key));
|
|
147
|
+
if (handled)
|
|
148
|
+
resourceEvents++;
|
|
125
149
|
}
|
|
126
150
|
// ZIG-973: the work channel. An open assigned task is redelivered on every
|
|
127
151
|
// pass until it closes (that IS the durable-wait contract), so dedup on
|
|
@@ -133,13 +157,35 @@ export async function pollInboxOnce(deps, waitSeconds = 0) {
|
|
|
133
157
|
continue;
|
|
134
158
|
deps.seen.mark(key);
|
|
135
159
|
if (deps.onTask) {
|
|
136
|
-
await deps.onTask(task);
|
|
137
|
-
|
|
160
|
+
const handled = await attempt(`task ${task.taskId}`, () => deps.onTask(task), () => deps.seen.unmark(key));
|
|
161
|
+
if (handled)
|
|
162
|
+
tasksDelivered++;
|
|
138
163
|
}
|
|
139
164
|
}
|
|
140
165
|
if (envelope.truncatedTasks) {
|
|
141
166
|
warn(label, `inbox truncatedTasks=${envelope.truncatedTasks} (more assigned tasks than one pass surfaces — they appear as earlier ones close)`);
|
|
142
167
|
}
|
|
168
|
+
// ZIG-1185: quest channel. Triage is the host's job (exact string compare);
|
|
169
|
+
// a miss must still count as handled so the watermark can advance with 0 tokens.
|
|
170
|
+
for (const quest of envelope.questsAwaitingMe ?? []) {
|
|
171
|
+
const key = `quest:${quest.agreementId}:${quest.ts}`;
|
|
172
|
+
if (deps.seen.has(key))
|
|
173
|
+
continue;
|
|
174
|
+
deps.seen.mark(key);
|
|
175
|
+
if (deps.onQuest) {
|
|
176
|
+
const handled = await attempt(`quest ${quest.agreementId}`, () => deps.onQuest(quest), () => deps.seen.unmark(key));
|
|
177
|
+
if (handled)
|
|
178
|
+
questsTriaged++;
|
|
179
|
+
}
|
|
180
|
+
else {
|
|
181
|
+
// No handler → still count as handled (ack) so an old host cannot wedge
|
|
182
|
+
// forever on quest mail it does not understand.
|
|
183
|
+
questsTriaged++;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
if (envelope.truncatedQuests) {
|
|
187
|
+
warn(label, `inbox truncatedQuests=${envelope.truncatedQuests} (more quests than one pass surfaces — the tail follows on the next pass)`);
|
|
188
|
+
}
|
|
143
189
|
// Pending decisions are standing state, not deliveries: they keep appearing
|
|
144
190
|
// until answered, so dedup them the same way tasks are deduped.
|
|
145
191
|
for (const proposal of envelope.proposalsAwaitingMe) {
|
|
@@ -147,36 +193,60 @@ export async function pollInboxOnce(deps, waitSeconds = 0) {
|
|
|
147
193
|
if (deps.seen.has(key))
|
|
148
194
|
continue;
|
|
149
195
|
deps.seen.mark(key);
|
|
150
|
-
await deps.onResourceEvent({
|
|
196
|
+
const handled = await attempt(`pending proposal ${proposal.agreementId}`, () => deps.onResourceEvent({
|
|
151
197
|
kind: 'agreement',
|
|
152
198
|
ts: proposal.proposedAt ?? new Date().toISOString(),
|
|
153
199
|
resourceId: proposal.agreementId,
|
|
154
200
|
agreementId: proposal.agreementId,
|
|
155
201
|
change: 'created',
|
|
156
202
|
reason: 'proposal_pending',
|
|
157
|
-
});
|
|
158
|
-
|
|
203
|
+
}), () => deps.seen.unmark(key));
|
|
204
|
+
if (handled)
|
|
205
|
+
resourceEvents++;
|
|
159
206
|
}
|
|
160
207
|
// Act, then ack: everything above has been handed to the host, so the
|
|
161
208
|
// watermark may now move past it. A crash before this point redelivers.
|
|
209
|
+
//
|
|
210
|
+
// ZIG-1191: "act" now means acted, not attempted. A pass with failures holds
|
|
211
|
+
// the watermark so the server redelivers the window — unless `forceAck` says
|
|
212
|
+
// this window has failed too many times in a row and is now poison, in which
|
|
213
|
+
// case it is dropped loudly rather than wedging the agent forever.
|
|
162
214
|
let acked = false;
|
|
163
|
-
if (envelope.ackTo) {
|
|
215
|
+
if (envelope.ackTo && (failures === 0 || opts.forceAck)) {
|
|
216
|
+
if (failures > 0) {
|
|
217
|
+
runtimeLog.error(label, `acking a window that failed ${failures} handover(s) — giving up on redelivery to keep the ` +
|
|
218
|
+
`agent alive; this mail is lost (ackTo=${envelope.ackTo})`);
|
|
219
|
+
}
|
|
164
220
|
await inbox.ack(envelope.ackTo);
|
|
165
221
|
acked = true;
|
|
166
222
|
log(label, `acked up to ${envelope.ackTo}`);
|
|
167
223
|
}
|
|
224
|
+
else if (envelope.ackTo) {
|
|
225
|
+
warn(label, `holding the watermark at ${envelope.ackTo} — ${failures} handover(s) failed this pass, so the ` +
|
|
226
|
+
'window will be redelivered');
|
|
227
|
+
}
|
|
168
228
|
return {
|
|
169
229
|
deliveries: envelope.deliveries.length,
|
|
170
230
|
messagesDelivered,
|
|
171
231
|
resourceEvents,
|
|
172
232
|
tasksDelivered,
|
|
233
|
+
questsTriaged,
|
|
173
234
|
acked,
|
|
174
235
|
truncated: envelope.deliveriesCapped,
|
|
236
|
+
failures,
|
|
175
237
|
};
|
|
176
238
|
}
|
|
177
239
|
const DEFAULT_WAIT_SECONDS = 25;
|
|
178
240
|
const ERROR_BACKOFF_BASE_MS = 1_000;
|
|
179
241
|
const ERROR_BACKOFF_MAX_MS = 30_000;
|
|
242
|
+
/**
|
|
243
|
+
* How many passes in a row may hold the watermark before the window is treated
|
|
244
|
+
* as poison and acked away (ZIG-1191). Three is enough for the failures we have
|
|
245
|
+
* actually seen — a redeployed container, a wedged backend, a transient throw —
|
|
246
|
+
* and short enough that a frame the host can never handle costs three wakes
|
|
247
|
+
* rather than an unbounded loop.
|
|
248
|
+
*/
|
|
249
|
+
const MAX_HELD_PASSES = 3;
|
|
180
250
|
function sleep(ms) {
|
|
181
251
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
182
252
|
}
|
|
@@ -197,13 +267,19 @@ export function runInboxLoop(deps, opts = {}) {
|
|
|
197
267
|
const waitSeconds = opts.waitSeconds ?? DEFAULT_WAIT_SECONDS;
|
|
198
268
|
let running = true;
|
|
199
269
|
let consecutiveErrors = 0;
|
|
270
|
+
let heldPasses = 0;
|
|
200
271
|
let first = true;
|
|
201
272
|
const done = (async () => {
|
|
202
273
|
while (running) {
|
|
203
274
|
try {
|
|
204
|
-
await pollInboxOnce(deps, first ? 0 : waitSeconds
|
|
275
|
+
const pass = await pollInboxOnce(deps, first ? 0 : waitSeconds, {
|
|
276
|
+
forceAck: heldPasses >= MAX_HELD_PASSES,
|
|
277
|
+
});
|
|
205
278
|
first = false;
|
|
206
279
|
consecutiveErrors = 0;
|
|
280
|
+
// A held pass means the same window comes back; count how many times we
|
|
281
|
+
// have refused it so a permanently unhandleable frame cannot spin here.
|
|
282
|
+
heldPasses = pass.failures > 0 && !pass.acked ? heldPasses + 1 : 0;
|
|
207
283
|
}
|
|
208
284
|
catch (err) {
|
|
209
285
|
if (!running)
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PollKind — polling as a declared, pluggable thing, the way tools are.
|
|
3
|
+
*
|
|
4
|
+
* An agent lists the polls it depends on the same way it lists tools:
|
|
5
|
+
*
|
|
6
|
+
* polls: [{ kind: 'sente-email', identityId: 'idt_...' }]
|
|
7
|
+
*
|
|
8
|
+
* It declares; it does not execute. Execution belongs to PollRunner, which the
|
|
9
|
+
* launcher runs once for the whole fleet — polling is a fleet concern the way
|
|
10
|
+
* the health server is, not a per-host behaviour: one loop per (agent, binding)
|
|
11
|
+
* with its own interval, backoff and backpressure, instead of every host
|
|
12
|
+
* hand-rolling a timer inside its message loop. Declaration travels with the
|
|
13
|
+
* agent; execution does not.
|
|
14
|
+
*
|
|
15
|
+
* A poll answers ONE question: is there work for this agent right now. It never
|
|
16
|
+
* returns the work itself. The agent reads that for itself once woken, through
|
|
17
|
+
* its own tools, so it cannot end up with a second, differently-shaped copy of
|
|
18
|
+
* its input and no way to tell which is current.
|
|
19
|
+
*
|
|
20
|
+
* WHERE THE CURSOR LIVES: in the source, whenever the source has one. Sente
|
|
21
|
+
* has `archived` on a message, so "already handled" is a fact about the
|
|
22
|
+
* mailbox, not something we mirror. A kind whose source has no such notion has
|
|
23
|
+
* to keep a cursor, and then it is the kind's business — never the agent's.
|
|
24
|
+
*/
|
|
25
|
+
/**
|
|
26
|
+
* Kind-specific config, verbatim from the agent's manifest.
|
|
27
|
+
*
|
|
28
|
+
* `chatId` is not kind-specific and is not optional in practice: a wake has to
|
|
29
|
+
* resolve to a lane or AgentHost's session resolver returns null and drops it
|
|
30
|
+
* silently. A poll has no engagement behind it to derive one from, so the
|
|
31
|
+
* binding names the chat the agent thinks in — which also puts its triage
|
|
32
|
+
* somewhere a human can watch. PollRunner refuses to register a binding without
|
|
33
|
+
* one rather than let the wake evaporate at runtime.
|
|
34
|
+
*/
|
|
35
|
+
export interface PollBinding {
|
|
36
|
+
kind: string;
|
|
37
|
+
/** Chat the poll wake is delivered into. Required; see above. */
|
|
38
|
+
chatId?: string;
|
|
39
|
+
[key: string]: unknown;
|
|
40
|
+
}
|
|
41
|
+
export interface PollContext {
|
|
42
|
+
agentId: string;
|
|
43
|
+
operatorKey?: string;
|
|
44
|
+
}
|
|
45
|
+
export interface PollResult {
|
|
46
|
+
/** Whether the agent should be woken now. */
|
|
47
|
+
actionable: boolean;
|
|
48
|
+
/**
|
|
49
|
+
* One line telling the agent why it woke. Carries no payload — see above.
|
|
50
|
+
* Omit and the runner sends the kind's default.
|
|
51
|
+
*/
|
|
52
|
+
hint?: string;
|
|
53
|
+
}
|
|
54
|
+
export interface PollKind {
|
|
55
|
+
id: string;
|
|
56
|
+
/** How often to run this kind. The runner's default applies when omitted. */
|
|
57
|
+
intervalMs?: number;
|
|
58
|
+
poll(binding: PollBinding, ctx: PollContext): Promise<PollResult>;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Kinds register here rather than being imported by the runner, so a
|
|
62
|
+
* source-specific kind ships with the agent that needs it and the SDK stays
|
|
63
|
+
* free of third-party dependencies.
|
|
64
|
+
*/
|
|
65
|
+
export declare class PollRegistry {
|
|
66
|
+
private readonly kinds;
|
|
67
|
+
register(kind: PollKind): this;
|
|
68
|
+
get(id: string): PollKind | undefined;
|
|
69
|
+
get ids(): string[];
|
|
70
|
+
}
|
|
71
|
+
/** Read an agent's declared polls off its config. Absent or malformed → none. */
|
|
72
|
+
export declare function declaredPolls(config: Record<string, unknown>): PollBinding[];
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PollKind — polling as a declared, pluggable thing, the way tools are.
|
|
3
|
+
*
|
|
4
|
+
* An agent lists the polls it depends on the same way it lists tools:
|
|
5
|
+
*
|
|
6
|
+
* polls: [{ kind: 'sente-email', identityId: 'idt_...' }]
|
|
7
|
+
*
|
|
8
|
+
* It declares; it does not execute. Execution belongs to PollRunner, which the
|
|
9
|
+
* launcher runs once for the whole fleet — polling is a fleet concern the way
|
|
10
|
+
* the health server is, not a per-host behaviour: one loop per (agent, binding)
|
|
11
|
+
* with its own interval, backoff and backpressure, instead of every host
|
|
12
|
+
* hand-rolling a timer inside its message loop. Declaration travels with the
|
|
13
|
+
* agent; execution does not.
|
|
14
|
+
*
|
|
15
|
+
* A poll answers ONE question: is there work for this agent right now. It never
|
|
16
|
+
* returns the work itself. The agent reads that for itself once woken, through
|
|
17
|
+
* its own tools, so it cannot end up with a second, differently-shaped copy of
|
|
18
|
+
* its input and no way to tell which is current.
|
|
19
|
+
*
|
|
20
|
+
* WHERE THE CURSOR LIVES: in the source, whenever the source has one. Sente
|
|
21
|
+
* has `archived` on a message, so "already handled" is a fact about the
|
|
22
|
+
* mailbox, not something we mirror. A kind whose source has no such notion has
|
|
23
|
+
* to keep a cursor, and then it is the kind's business — never the agent's.
|
|
24
|
+
*/
|
|
25
|
+
/**
|
|
26
|
+
* Kinds register here rather than being imported by the runner, so a
|
|
27
|
+
* source-specific kind ships with the agent that needs it and the SDK stays
|
|
28
|
+
* free of third-party dependencies.
|
|
29
|
+
*/
|
|
30
|
+
export class PollRegistry {
|
|
31
|
+
kinds = new Map();
|
|
32
|
+
register(kind) {
|
|
33
|
+
if (!kind?.id)
|
|
34
|
+
throw new Error('PollRegistry: a poll kind needs an id');
|
|
35
|
+
if (this.kinds.has(kind.id))
|
|
36
|
+
throw new Error(`PollRegistry: duplicate poll kind "${kind.id}"`);
|
|
37
|
+
this.kinds.set(kind.id, kind);
|
|
38
|
+
return this;
|
|
39
|
+
}
|
|
40
|
+
get(id) {
|
|
41
|
+
return this.kinds.get(id);
|
|
42
|
+
}
|
|
43
|
+
get ids() {
|
|
44
|
+
return [...this.kinds.keys()];
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/** Read an agent's declared polls off its config. Absent or malformed → none. */
|
|
48
|
+
export function declaredPolls(config) {
|
|
49
|
+
const raw = config['polls'];
|
|
50
|
+
if (!Array.isArray(raw))
|
|
51
|
+
return [];
|
|
52
|
+
return raw.filter((b) => !!b && typeof b === 'object' && typeof b.kind === 'string');
|
|
53
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PollRunner — executes declared polls for a whole fleet, outside any host.
|
|
3
|
+
*
|
|
4
|
+
* Every host already long-polls its own Ziggs inbox; this applies the same wake
|
|
5
|
+
* discipline to the sources Ziggs cannot see — mailboxes, feeds, whatever a
|
|
6
|
+
* PollKind can ask. It runs in the launcher rather than inside any AgentHost
|
|
7
|
+
* because none of it belongs to a host's message loop: a poll is per
|
|
8
|
+
* (agent, binding) and carries its own interval, backoff and backpressure.
|
|
9
|
+
*
|
|
10
|
+
* One loop per (agent, binding), and each loop AWAITS its own wake before
|
|
11
|
+
* scheduling the next tick. That is the whole backpressure story: while an
|
|
12
|
+
* agent is still dealing with what it was woken for, its next poll has not
|
|
13
|
+
* happened yet, so it cannot be woken twice for the same thing. No in-flight
|
|
14
|
+
* bookkeeping, no dedup table — the source is authoritative and a poll that
|
|
15
|
+
* has not run yet cannot report anything stale.
|
|
16
|
+
*
|
|
17
|
+
* A failing source backs off exponentially and recovers on the first success.
|
|
18
|
+
* Without it a source that is down is hit at full rate indefinitely, which is
|
|
19
|
+
* how you get rate-limited by the thing you were trying to read.
|
|
20
|
+
*/
|
|
21
|
+
import { type PollRegistry } from './PollKind.js';
|
|
22
|
+
/** The slice of ConnectionPool this needs. Kept narrow to avoid a cycle. */
|
|
23
|
+
export interface WakeTarget {
|
|
24
|
+
sendTo(agentId: string, text: string, metadata?: Record<string, unknown>, opts?: {
|
|
25
|
+
timeoutMs?: number;
|
|
26
|
+
}): Promise<unknown>;
|
|
27
|
+
}
|
|
28
|
+
export interface PollRunnerOptions {
|
|
29
|
+
pool: WakeTarget;
|
|
30
|
+
registry: PollRegistry;
|
|
31
|
+
/** Registered agent configs — anything carrying `agentId` and maybe `polls`. */
|
|
32
|
+
agents: Record<string, unknown>[];
|
|
33
|
+
operatorKey?: string;
|
|
34
|
+
defaultIntervalMs?: number;
|
|
35
|
+
/** Ceiling on the failure backoff. Default 15 minutes. */
|
|
36
|
+
maxBackoffMs?: number;
|
|
37
|
+
/**
|
|
38
|
+
* How long a woken agent may take before the wake is treated as hung.
|
|
39
|
+
* Defaults to 5 minutes, matching the Claude-brain tick timeout — the pool's
|
|
40
|
+
* own `sendTo` default is 30s, which a legitimate multi-turn wake blows
|
|
41
|
+
* through every single time.
|
|
42
|
+
*/
|
|
43
|
+
wakeTimeoutMs?: number;
|
|
44
|
+
label?: string;
|
|
45
|
+
}
|
|
46
|
+
export declare class PollRunner {
|
|
47
|
+
private readonly pool;
|
|
48
|
+
private readonly operatorKey?;
|
|
49
|
+
private readonly label;
|
|
50
|
+
private readonly maxBackoffMs;
|
|
51
|
+
private readonly wakeTimeoutMs;
|
|
52
|
+
private readonly loops;
|
|
53
|
+
private running;
|
|
54
|
+
constructor({ pool, registry, agents, operatorKey, defaultIntervalMs, maxBackoffMs, wakeTimeoutMs, label, }: PollRunnerOptions);
|
|
55
|
+
get size(): number;
|
|
56
|
+
start(): void;
|
|
57
|
+
stop(): void;
|
|
58
|
+
private schedule;
|
|
59
|
+
/** Interval while healthy; doubling up to the ceiling while failing. */
|
|
60
|
+
private nextDelay;
|
|
61
|
+
private tick;
|
|
62
|
+
}
|