@ziggs-ai/agent-sdk 0.5.0 → 0.6.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/dist/AgentHost.d.ts +21 -0
- package/dist/AgentHost.js +61 -13
- package/dist/agent/Agent.d.ts +9 -0
- package/dist/agent/ClaudeSdkAgent.d.ts +1 -1
- package/dist/agent/ClaudeSdkAgent.js +47 -12
- package/dist/agent/claudeSdkTools.js +1 -1
- package/dist/defineAgent.d.ts +10 -5
- package/dist/defineAgent.js +16 -9
- package/dist/runtime/runTurn.js +12 -2
- package/dist/runtime/taskIntake.d.ts +18 -0
- package/dist/runtime/taskIntake.js +113 -0
- package/dist/server/ConnectionPool.d.ts +9 -2
- package/dist/server/ConnectionPool.js +18 -2
- package/dist/server/inboxLoop.d.ts +31 -11
- package/dist/server/inboxLoop.js +134 -104
- package/dist/server/runLauncher.js +9 -3
- package/dist/server/staleBacklogGate.d.ts +9 -5
- package/dist/server/staleBacklogGate.js +17 -37
- package/dist/server/tools/tier1/protocolTools.js +2 -2
- package/dist/types.d.ts +3 -2
- package/package.json +2 -2
package/dist/AgentHost.d.ts
CHANGED
|
@@ -40,6 +40,19 @@ export declare const DEFAULT_MODEL = "claude-haiku-4-5";
|
|
|
40
40
|
* could bucket by taskId, cron agents by schedule id, etc.
|
|
41
41
|
*/
|
|
42
42
|
export type SessionResolver = (normalized: NormalizedEvent, ownAgentId: string) => string | null;
|
|
43
|
+
/**
|
|
44
|
+
* ZIG-975: one lane key per unit of work, derived the same way as inbox wakes.
|
|
45
|
+
*
|
|
46
|
+
* Inbox deliveries key on `chatId` (DEFAULT_SESSION_RESOLVER). Bootstrap used to
|
|
47
|
+
* prefer bare `agreementId`, which opened a second lane for the same task and
|
|
48
|
+
* made the snapshot read treat the agreement id as a chat (`chatMissing: true`,
|
|
49
|
+
* empty context). Prefer the origin chat when present; otherwise the agreement's
|
|
50
|
+
* notification chat (`agrn-<agreementId>`), which is always linked after ZIG-966.
|
|
51
|
+
*/
|
|
52
|
+
export declare function laneSessionIdForTask(task: {
|
|
53
|
+
originChatId?: string | null;
|
|
54
|
+
agreementId?: string | null;
|
|
55
|
+
}): string;
|
|
43
56
|
interface LlmAdapter {
|
|
44
57
|
chatMessages(messages: unknown[], tools?: unknown[], options?: Record<string, unknown>): Promise<unknown>;
|
|
45
58
|
}
|
|
@@ -104,6 +117,14 @@ export interface AgentHostOptions {
|
|
|
104
117
|
sessionStore?: SessionStore;
|
|
105
118
|
/** Observability tap: called after every effect with the effect and its response. */
|
|
106
119
|
effectTap?: (effect: import('./types.js').Effect, response: unknown) => void;
|
|
120
|
+
/**
|
|
121
|
+
* Liveness signal: called whenever the host receives or processes work (an
|
|
122
|
+
* inbox message or resource event delivered to a lane). Hosting pools use it
|
|
123
|
+
* to reset their idle-eviction timer — without it a host that is actively
|
|
124
|
+
* working (but never addressed via pool.sendTo) is torn down mid-stream at
|
|
125
|
+
* the idle timeout, discarding lane state, Claude session ids, and timers.
|
|
126
|
+
*/
|
|
127
|
+
onActivity?: () => void;
|
|
107
128
|
/** Observability tap: called on every FSM state transition, no-match, depth-guard, or error-escape (ZIG-442). */
|
|
108
129
|
transitionTap?: (event: import('./runtime/AgentMachine.js').TransitionEvent) => void;
|
|
109
130
|
/**
|
package/dist/AgentHost.js
CHANGED
|
@@ -36,6 +36,24 @@ async function loadConnectionPool() {
|
|
|
36
36
|
return mod.ConnectionPool;
|
|
37
37
|
}
|
|
38
38
|
const DEFAULT_SESSION_RESOLVER = (normalized) => normalized.chatId;
|
|
39
|
+
/**
|
|
40
|
+
* ZIG-975: one lane key per unit of work, derived the same way as inbox wakes.
|
|
41
|
+
*
|
|
42
|
+
* Inbox deliveries key on `chatId` (DEFAULT_SESSION_RESOLVER). Bootstrap used to
|
|
43
|
+
* prefer bare `agreementId`, which opened a second lane for the same task and
|
|
44
|
+
* made the snapshot read treat the agreement id as a chat (`chatMissing: true`,
|
|
45
|
+
* empty context). Prefer the origin chat when present; otherwise the agreement's
|
|
46
|
+
* notification chat (`agrn-<agreementId>`), which is always linked after ZIG-966.
|
|
47
|
+
*/
|
|
48
|
+
export function laneSessionIdForTask(task) {
|
|
49
|
+
if (typeof task.originChatId === 'string' && task.originChatId) {
|
|
50
|
+
return task.originChatId;
|
|
51
|
+
}
|
|
52
|
+
if (typeof task.agreementId === 'string' && task.agreementId) {
|
|
53
|
+
return `agrn-${task.agreementId}`;
|
|
54
|
+
}
|
|
55
|
+
return 'default';
|
|
56
|
+
}
|
|
39
57
|
function createToolManager(options) {
|
|
40
58
|
const toolManager = new ToolManager();
|
|
41
59
|
const userTools = options.tools || options.services?.tools || [];
|
|
@@ -147,7 +165,15 @@ export class AgentHost {
|
|
|
147
165
|
};
|
|
148
166
|
this.inboxLoopEnabled = options.inboxCatchUp !== false;
|
|
149
167
|
this.sessionResolver = options.sessionResolver || DEFAULT_SESSION_RESOLVER;
|
|
150
|
-
|
|
168
|
+
// A Claude-brain wake legitimately runs long (multi-turn query, prose
|
|
169
|
+
// deliverables) — 120s watchdogged real spec-writing wakes mid-flight,
|
|
170
|
+
// which then redelivered and re-ran (ZIG-924 dogfood, 2026-07-26). FSM
|
|
171
|
+
// turns stay on the tight default.
|
|
172
|
+
this.tickTimeoutMs =
|
|
173
|
+
options.tickTimeoutMs ??
|
|
174
|
+
(isClaudeCognition(options.cognition)
|
|
175
|
+
? Number(process.env.CLAUDE_SDK_TICK_TIMEOUT_MS ?? 300_000)
|
|
176
|
+
: 120_000);
|
|
151
177
|
this.memory = options.memory ?? new InMemoryStore();
|
|
152
178
|
this.sessionStore = options.sessionStore ?? null;
|
|
153
179
|
this._validate();
|
|
@@ -375,11 +401,19 @@ export class AgentHost {
|
|
|
375
401
|
// armed pinned one live 2-minute timer per tick (and kept short-lived
|
|
376
402
|
// processes/tests alive until it fired).
|
|
377
403
|
let tickWatchdog;
|
|
404
|
+
// Aborting on timeout is load-bearing: Promise.race only abandons the tick
|
|
405
|
+
// promise. A Claude-brain tick left unaborted keeps its query() running as
|
|
406
|
+
// a zombie — burning tokens and racing the redelivered retry of the same
|
|
407
|
+
// event (duplicate sends, runaway per-wake input).
|
|
408
|
+
const tickAbort = new AbortController();
|
|
378
409
|
try {
|
|
379
410
|
const result = await Promise.race([
|
|
380
|
-
this.agent.tick({ event: frame, ctx: session.ctx, state: session.state, identity, eff: this.eff, memory: this.memory, transitionTap: this.options.transitionTap }),
|
|
411
|
+
this.agent.tick({ event: frame, ctx: session.ctx, state: session.state, identity, eff: this.eff, memory: this.memory, transitionTap: this.options.transitionTap, signal: tickAbort.signal }),
|
|
381
412
|
new Promise((_, reject) => {
|
|
382
|
-
tickWatchdog = setTimeout(() =>
|
|
413
|
+
tickWatchdog = setTimeout(() => {
|
|
414
|
+
tickAbort.abort(new Error('tick timeout'));
|
|
415
|
+
reject(new Error('tick timeout'));
|
|
416
|
+
}, this.tickTimeoutMs);
|
|
383
417
|
}),
|
|
384
418
|
]);
|
|
385
419
|
session.ctx = result.ctx;
|
|
@@ -475,8 +509,26 @@ export class AgentHost {
|
|
|
475
509
|
ownAgentId: this.options.agentId,
|
|
476
510
|
seen: this.seenMessages,
|
|
477
511
|
label,
|
|
478
|
-
onMessage: (text, metadata) =>
|
|
479
|
-
|
|
512
|
+
onMessage: (text, metadata) => {
|
|
513
|
+
this.options.onActivity?.();
|
|
514
|
+
return this.handleMessage(text, metadata);
|
|
515
|
+
},
|
|
516
|
+
onResourceEvent: (event) => {
|
|
517
|
+
this.options.onActivity?.();
|
|
518
|
+
return this.handleResourceEvent(event);
|
|
519
|
+
},
|
|
520
|
+
// ZIG-973: work delivered by the inbox's task channel, not by a chat
|
|
521
|
+
// row — this is what reaches a task under a chat-less agreement or one
|
|
522
|
+
// the agent assigned to itself.
|
|
523
|
+
onTask: async (task) => {
|
|
524
|
+
this.options.onActivity?.();
|
|
525
|
+
try {
|
|
526
|
+
await this.wakeTaskAssignment(task.taskId);
|
|
527
|
+
}
|
|
528
|
+
catch (err) {
|
|
529
|
+
runtimeLog.warn(this.options.name || 'agent', `inbox task ${task.taskId} could not be woken: ${err.message}`);
|
|
530
|
+
}
|
|
531
|
+
},
|
|
480
532
|
});
|
|
481
533
|
}
|
|
482
534
|
return this;
|
|
@@ -509,9 +561,7 @@ export class AgentHost {
|
|
|
509
561
|
const state = String(full.state ?? '');
|
|
510
562
|
if (state !== 'active' && state !== 'in-progress')
|
|
511
563
|
continue;
|
|
512
|
-
const sessionId = full
|
|
513
|
-
full.originChatId ||
|
|
514
|
-
'default';
|
|
564
|
+
const sessionId = laneSessionIdForTask(full);
|
|
515
565
|
const laneKey = `${agentId}:${sessionId}`;
|
|
516
566
|
if (!this.sessions.has(laneKey)) {
|
|
517
567
|
this.sessions.set(laneKey, { sessionId, ownAgentId: agentId });
|
|
@@ -525,7 +575,7 @@ export class AgentHost {
|
|
|
525
575
|
state,
|
|
526
576
|
providerIsYou: true,
|
|
527
577
|
},
|
|
528
|
-
chatId:
|
|
578
|
+
chatId: sessionId,
|
|
529
579
|
}, laneKey);
|
|
530
580
|
}
|
|
531
581
|
}
|
|
@@ -540,9 +590,7 @@ export class AgentHost {
|
|
|
540
590
|
if (state !== 'active' && state !== 'in-progress') {
|
|
541
591
|
throw new Error(`wakeTaskAssignment: task ${taskId} is ${state}, not active`);
|
|
542
592
|
}
|
|
543
|
-
const sessionId = full
|
|
544
|
-
full.originChatId ||
|
|
545
|
-
'default';
|
|
593
|
+
const sessionId = laneSessionIdForTask(full);
|
|
546
594
|
const laneKey = `${agentId}:${sessionId}`;
|
|
547
595
|
if (!this.sessions.has(laneKey)) {
|
|
548
596
|
this.sessions.set(laneKey, { sessionId, ownAgentId: agentId });
|
|
@@ -555,7 +603,7 @@ export class AgentHost {
|
|
|
555
603
|
state,
|
|
556
604
|
providerIsYou: true,
|
|
557
605
|
},
|
|
558
|
-
chatId:
|
|
606
|
+
chatId: sessionId,
|
|
559
607
|
}, laneKey);
|
|
560
608
|
}
|
|
561
609
|
disconnect() { this.stateTimers.clearAll(); void this.inboxLoop?.stop(); this.inboxLoop = null; }
|
package/dist/agent/Agent.d.ts
CHANGED
|
@@ -27,6 +27,15 @@ export interface TickInput {
|
|
|
27
27
|
memory?: MemoryStore;
|
|
28
28
|
/** Optional tap for FSM transition events (ZIG-442). */
|
|
29
29
|
transitionTap?: (event: TransitionEvent) => void;
|
|
30
|
+
/**
|
|
31
|
+
* Aborted by the host when the tick watchdog fires. A brain driving an
|
|
32
|
+
* external loop (the Claude Agent SDK query) MUST stop consuming it on
|
|
33
|
+
* abort — the host's Promise.race only abandons the tick promise, so
|
|
34
|
+
* without this the query keeps running as a zombie: burning tokens and
|
|
35
|
+
* racing its own redelivered retry (observed as duplicate kickoffs and a
|
|
36
|
+
* 363k-token wake, 2026-07-26).
|
|
37
|
+
*/
|
|
38
|
+
signal?: AbortSignal;
|
|
30
39
|
}
|
|
31
40
|
export interface TickOutput {
|
|
32
41
|
/** Updated ctx — Server persists this against the session. */
|
|
@@ -39,5 +39,5 @@ export declare class ClaudeSdkAgent {
|
|
|
39
39
|
private statelessPerWake;
|
|
40
40
|
private queryFn;
|
|
41
41
|
constructor(opts: ClaudeSdkAgentOptions);
|
|
42
|
-
tick({ event, ctx, state, identity, eff }: TickInput): Promise<TickOutput>;
|
|
42
|
+
tick({ event, ctx, state, identity, eff, signal }: TickInput): Promise<TickOutput>;
|
|
43
43
|
}
|
|
@@ -163,7 +163,7 @@ export class ClaudeSdkAgent {
|
|
|
163
163
|
this.statelessPerWake = opts.statelessPerWake ?? false;
|
|
164
164
|
this.queryFn = opts.queryFn ?? query;
|
|
165
165
|
}
|
|
166
|
-
async tick({ event, ctx, state, identity, eff }) {
|
|
166
|
+
async tick({ event, ctx, state, identity, eff, signal }) {
|
|
167
167
|
if (!identity?.sessionId)
|
|
168
168
|
throw new Error('ClaudeSdkAgent.tick: identity.sessionId is required');
|
|
169
169
|
if (!eff)
|
|
@@ -199,16 +199,32 @@ export class ClaudeSdkAgent {
|
|
|
199
199
|
// Same guard as the FSM's runTurn: a message wake for a chat the backend
|
|
200
200
|
// no longer resolves (deleted / not visible) has no world to act in —
|
|
201
201
|
// skip the query instead of burning a wake on a fabricated empty history.
|
|
202
|
-
//
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
202
|
+
//
|
|
203
|
+
// ZIG-975: a bare agreement id (`agr_…`, not the `agrn-…` notification
|
|
204
|
+
// chat) is not a chat. If a wake still keys the lane that way, read-context
|
|
205
|
+
// returns chatMissing and used to continue on an empty snapshot with no
|
|
206
|
+
// signal. Loud-log that mis-key; message wakes still skip below.
|
|
207
|
+
if (contextSnap?.chatMissing === true) {
|
|
208
|
+
const sessionId = identity.sessionId;
|
|
209
|
+
const looksLikeBareAgreement = typeof sessionId === 'string' &&
|
|
210
|
+
sessionId.startsWith('agr_') &&
|
|
211
|
+
!sessionId.startsWith('agrn-');
|
|
212
|
+
if (looksLikeBareAgreement) {
|
|
213
|
+
runtimeLog.warn('ClaudeSdkAgent', `chatMissing on non-chat sessionId=${sessionId} agent=${this.agentId} ` +
|
|
214
|
+
`outcome=${nextCtx.lastOutcome?.kind ?? 'none'} — lane was keyed by ` +
|
|
215
|
+
`agreementId instead of the agreement's origin/notification chat; ` +
|
|
216
|
+
`wake continues with an empty snapshot`);
|
|
217
|
+
}
|
|
218
|
+
if (nextCtx.lastOutcome?.kind === 'message-received') {
|
|
219
|
+
runtimeLog.warn('ClaudeSdkAgent', `chat ${sessionId} not found on backend — skipping message wake agent=${this.agentId}`);
|
|
220
|
+
return {
|
|
221
|
+
ctx: nextCtx,
|
|
222
|
+
state: state ?? DEFAULT_STATE,
|
|
223
|
+
parked: true,
|
|
224
|
+
stateTimeoutUnhandled: false,
|
|
225
|
+
metrics: { llmCalls: 0, tokens: { total: 0, input: 0, output: 0 } },
|
|
226
|
+
};
|
|
227
|
+
}
|
|
212
228
|
}
|
|
213
229
|
const roleBlock = [
|
|
214
230
|
`## Your role`,
|
|
@@ -264,8 +280,24 @@ export class ClaudeSdkAgent {
|
|
|
264
280
|
let metrics = { llmCalls: 0, tokens: { total: 0, input: 0, output: 0, cacheRead: 0, cacheCreation: 0 } };
|
|
265
281
|
let sessionId = nextCtx.claudeSessionId;
|
|
266
282
|
let spentInput = 0;
|
|
283
|
+
// Host watchdog abort (see TickInput.signal): stop consuming AND interrupt
|
|
284
|
+
// the query, so a timed-out wake never lingers as a token-burning zombie
|
|
285
|
+
// racing its own redelivered retry. Breaking the for-await returns the
|
|
286
|
+
// generator; interrupt() additionally stops a query blocked mid-turn.
|
|
287
|
+
const query = this.queryFn({ prompt, options: queryOptions });
|
|
288
|
+
const onAbort = () => {
|
|
289
|
+
void query.interrupt?.()?.catch(() => { });
|
|
290
|
+
};
|
|
291
|
+
if (signal?.aborted)
|
|
292
|
+
onAbort();
|
|
293
|
+
else
|
|
294
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
267
295
|
try {
|
|
268
|
-
for await (const message of
|
|
296
|
+
for await (const message of query) {
|
|
297
|
+
if (signal?.aborted) {
|
|
298
|
+
runtimeLog.warn('ClaudeSdkAgent', `host watchdog aborted wake session=${identity.sessionId} agent=${this.agentId} — stopping query`);
|
|
299
|
+
break;
|
|
300
|
+
}
|
|
269
301
|
if ('session_id' in message && typeof message.session_id === 'string') {
|
|
270
302
|
sessionId = message.session_id;
|
|
271
303
|
}
|
|
@@ -312,6 +344,9 @@ export class ClaudeSdkAgent {
|
|
|
312
344
|
runtimeLog.warn('ClaudeSdkAgent', `query failed session=${identity.sessionId}: ${err.message}`);
|
|
313
345
|
throw err;
|
|
314
346
|
}
|
|
347
|
+
finally {
|
|
348
|
+
signal?.removeEventListener('abort', onAbort);
|
|
349
|
+
}
|
|
315
350
|
// Stateless-per-wake keeps no session id — clear any carried over from the
|
|
316
351
|
// prior ctx so the next wake starts fresh too (ZIG-860). Otherwise remember
|
|
317
352
|
// this wake's session for resume.
|
|
@@ -26,7 +26,7 @@ export const ziggsContextSnapshotTool = defineTool('ziggs_context_snapshot', {
|
|
|
26
26
|
maxMessages: args.maxMessages != null ? Number(args.maxMessages) : DEFAULT_SNAPSHOT_MAX_MESSAGES,
|
|
27
27
|
},
|
|
28
28
|
});
|
|
29
|
-
}, { description: 'Read the chat snapshot: history, agreements, agents, and users for a session.' });
|
|
29
|
+
}, { description: 'Read the chat snapshot: the NEWEST maxMessages of history (chronological order), plus agreements, agents, and users for a session.' });
|
|
30
30
|
export const ziggsSendMessageTool = defineTool('ziggs_send_message', {
|
|
31
31
|
chatId: { type: 'string', description: 'Chat id. Defaults to current session.' },
|
|
32
32
|
receiverId: { type: 'string', description: 'Recipient user or agent id. Defaults to the sender of the event that woke you — omit when replying to them.' },
|
package/dist/defineAgent.d.ts
CHANGED
|
@@ -2,11 +2,16 @@ import type { AgentConfig, DefineAgentInput } from './types.js';
|
|
|
2
2
|
/**
|
|
3
3
|
* Validates the workflow synchronously, resolves merged tools and model, and
|
|
4
4
|
* returns a frozen AgentConfig. Never mutates the input. `wait` defaults remain
|
|
5
|
-
* the author's visible spread of `thinkingDefaults`.
|
|
6
|
-
* new objects)
|
|
7
|
-
*
|
|
8
|
-
* proposer-side proposal-resolved/approve outcome
|
|
9
|
-
*
|
|
5
|
+
* the author's visible spread of `thinkingDefaults`. TWO defaults are injected
|
|
6
|
+
* (as new objects), each appended LAST so author transitions always win:
|
|
7
|
+
* - the agreement-kickoff state `kickingOff`, reached from every state that
|
|
8
|
+
* does not already handle a proposer-side proposal-resolved/approve outcome
|
|
9
|
+
* (runtime/kickoff.ts);
|
|
10
|
+
* - the assigned-task-intake state `workingAssignedTask`, reached from every
|
|
11
|
+
* state that does not already route a task-assigned outcome
|
|
12
|
+
* (runtime/taskIntake.ts) — so a standing hire's task #2..N is never
|
|
13
|
+
* silently dropped by a parked state.
|
|
14
|
+
* Both state names are reserved; defining either throws.
|
|
10
15
|
*
|
|
11
16
|
* Validation throws WorkflowValidationError on hard problems and logs warnings
|
|
12
17
|
* on soft ones (e.g. missing wait fallthrough).
|
package/dist/defineAgent.js
CHANGED
|
@@ -1,16 +1,22 @@
|
|
|
1
1
|
import { validateWorkflow } from './runtime/validateWorkflow.js';
|
|
2
2
|
import { injectKickoffDefault } from './runtime/kickoff.js';
|
|
3
|
+
import { injectTaskIntakeDefault } from './runtime/taskIntake.js';
|
|
3
4
|
import { deriveRespondsToProposals } from './runtime/deriveRespondsToProposals.js';
|
|
4
5
|
import { runtimeLog } from './shared/runtimeLog.js';
|
|
5
6
|
import { DEFAULT_MODEL } from './AgentHost.js';
|
|
6
7
|
/**
|
|
7
8
|
* Validates the workflow synchronously, resolves merged tools and model, and
|
|
8
9
|
* returns a frozen AgentConfig. Never mutates the input. `wait` defaults remain
|
|
9
|
-
* the author's visible spread of `thinkingDefaults`.
|
|
10
|
-
* new objects)
|
|
11
|
-
*
|
|
12
|
-
* proposer-side proposal-resolved/approve outcome
|
|
13
|
-
*
|
|
10
|
+
* the author's visible spread of `thinkingDefaults`. TWO defaults are injected
|
|
11
|
+
* (as new objects), each appended LAST so author transitions always win:
|
|
12
|
+
* - the agreement-kickoff state `kickingOff`, reached from every state that
|
|
13
|
+
* does not already handle a proposer-side proposal-resolved/approve outcome
|
|
14
|
+
* (runtime/kickoff.ts);
|
|
15
|
+
* - the assigned-task-intake state `workingAssignedTask`, reached from every
|
|
16
|
+
* state that does not already route a task-assigned outcome
|
|
17
|
+
* (runtime/taskIntake.ts) — so a standing hire's task #2..N is never
|
|
18
|
+
* silently dropped by a parked state.
|
|
19
|
+
* Both state names are reserved; defining either throws.
|
|
14
20
|
*
|
|
15
21
|
* Validation throws WorkflowValidationError on hard problems and logs warnings
|
|
16
22
|
* on soft ones (e.g. missing wait fallthrough).
|
|
@@ -24,14 +30,15 @@ export function defineAgent(input) {
|
|
|
24
30
|
throw new Error('[defineAgent] states is required');
|
|
25
31
|
if (!input?.initial)
|
|
26
32
|
throw new Error('[defineAgent] initial state is required');
|
|
27
|
-
// Agreement-kickoff
|
|
28
|
-
// validation so the injected
|
|
29
|
-
|
|
33
|
+
// Agreement-kickoff + assigned-task-intake defaults (runtime/kickoff.ts,
|
|
34
|
+
// runtime/taskIntake.ts): injected BEFORE validation so the injected
|
|
35
|
+
// states/transitions are validated too.
|
|
36
|
+
const workflow = injectTaskIntakeDefault(injectKickoffDefault({
|
|
30
37
|
id: input.agentId,
|
|
31
38
|
description: input.description,
|
|
32
39
|
initial: input.initial,
|
|
33
40
|
states: input.states,
|
|
34
|
-
});
|
|
41
|
+
}));
|
|
35
42
|
const { warnings } = validateWorkflow(workflow);
|
|
36
43
|
for (const w of warnings)
|
|
37
44
|
runtimeLog.warn('defineAgent', w);
|
package/dist/runtime/runTurn.js
CHANGED
|
@@ -105,8 +105,18 @@ export async function runTurn(input) {
|
|
|
105
105
|
// to this agent) has no world to decide over — deciding anyway means
|
|
106
106
|
// reasoning about a fabricated empty history plus whatever stale scraps
|
|
107
107
|
// other fetches returned. Skip the LLM and park. Gated on message wakes
|
|
108
|
-
// only: agreement
|
|
109
|
-
//
|
|
108
|
+
// only. ZIG-975: a bare agreement id as sessionId is a mis-keyed lane —
|
|
109
|
+
// loud-log the empty snapshot rather than staying silent.
|
|
110
|
+
if (ctxObj.chatMissing) {
|
|
111
|
+
const looksLikeBareAgreement = typeof sessionId === 'string' &&
|
|
112
|
+
sessionId.startsWith('agr_') &&
|
|
113
|
+
!sessionId.startsWith('agrn-');
|
|
114
|
+
if (looksLikeBareAgreement) {
|
|
115
|
+
runtimeLog.warn('runTurn', `chatMissing on non-chat sessionId=${sessionId} (state=${stateId}) — ` +
|
|
116
|
+
`lane was keyed by agreementId instead of the agreement's origin/notification chat; ` +
|
|
117
|
+
`wake continues with an empty snapshot`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
110
120
|
if (ctxObj.chatMissing && ctx.lastOutcome?.kind === 'message-received') {
|
|
111
121
|
runtimeLog.warn('runTurn', `chat ${sessionId} not found on backend — skipping message wake (state=${stateId})`);
|
|
112
122
|
return {
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { ThinkingState, Workflow } from '../types.js';
|
|
2
|
+
/** Reserved state name for the injected assigned-task-intake default. */
|
|
3
|
+
export declare const TASK_INTAKE_STATE = "workingAssignedTask";
|
|
4
|
+
export declare function buildTaskIntakeState(initial: string): ThinkingState;
|
|
5
|
+
/**
|
|
6
|
+
* Assigned-task-intake default — the task-side twin of the agreement-kickoff
|
|
7
|
+
* default (kickoff.ts). For every state whose transitions do not already match
|
|
8
|
+
* a task-assigned outcome, append a transition to the injected
|
|
9
|
+
* `workingAssignedTask` state. Appended LAST so any user transition on the
|
|
10
|
+
* same outcome always wins (first match wins in
|
|
11
|
+
* AgentMachine._evaluateTransitions). Returns a NEW workflow — input states
|
|
12
|
+
* are not mutated.
|
|
13
|
+
*
|
|
14
|
+
* Why: a standing hire receives task #2, #3, … months after approval. Without
|
|
15
|
+
* this default a parked state with no task-assigned route (e.g. a hire parked
|
|
16
|
+
* in `engaged`) swallowed the wake and the task sat active forever.
|
|
17
|
+
*/
|
|
18
|
+
export declare function injectTaskIntakeDefault(workflow: Workflow): Workflow;
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { thinkingDefaults } from './defaults.js';
|
|
2
|
+
import { KICKOFF_STATE } from './kickoff.js';
|
|
3
|
+
import { PROBE_CTX } from './validateWorkflow.js';
|
|
4
|
+
/** Reserved state name for the injected assigned-task-intake default. */
|
|
5
|
+
export const TASK_INTAKE_STATE = 'workingAssignedTask';
|
|
6
|
+
// Synthetic outcome used to ask "does this state already route an assigned
|
|
7
|
+
// task?" — same probe pattern as kickoff.ts / validateWorkflow.
|
|
8
|
+
const PROBE_TASK_ASSIGNED = {
|
|
9
|
+
kind: 'task-assigned',
|
|
10
|
+
task: { taskId: '__task_intake_probe__' },
|
|
11
|
+
};
|
|
12
|
+
const TASK_INTAKE_TRANSITION = {
|
|
13
|
+
to: TASK_INTAKE_STATE,
|
|
14
|
+
when: o => o.kind === 'task-assigned',
|
|
15
|
+
};
|
|
16
|
+
// A state "handles" task-assigned if some transition would fire for it. An
|
|
17
|
+
// unconditional (when-less) transition counts — it catches every outcome, so
|
|
18
|
+
// the intake transition appended LAST would be unreachable there anyway.
|
|
19
|
+
function stateHandlesTaskAssigned(state) {
|
|
20
|
+
return state.transitions.some(t => {
|
|
21
|
+
if (!t.when)
|
|
22
|
+
return true;
|
|
23
|
+
try {
|
|
24
|
+
return !!t.when(PROBE_TASK_ASSIGNED, PROBE_CTX);
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
export function buildTaskIntakeState(initial) {
|
|
32
|
+
const dflt = thinkingDefaults({ initial });
|
|
33
|
+
return {
|
|
34
|
+
kind: 'thinking',
|
|
35
|
+
prompt: {
|
|
36
|
+
role: 'A task was just assigned to you under one of your active agreements, and your workflow has no explicit route for it. This injected default exists so an assigned task is NEVER silently dropped — under a standing hire, new tasks keep arriving for the lifetime of the agreement.',
|
|
37
|
+
goal: 'Resolve the assigned task this turn: deliver the result if you can produce it, otherwise fail it with a reason.',
|
|
38
|
+
constraints: 'The task is in <event> (and as activeTaskId / activeAgreementId in <machine_context>). If the deliverable can be produced in this turn (an answer, summary, plan, draft — most representation work), call task_update with status "completed" and the deliverable as result — the result is what the counterparty consumes, so make it the actual deliverable, not a status note. If the task genuinely needs abilities or tools you do not have, call task_update with status "failed" and a one-line reason — a visible failure the counterparty can re-route beats a silent drop. Never leave the task dangling or answer with a chat message alone.',
|
|
39
|
+
},
|
|
40
|
+
actions: {
|
|
41
|
+
...dflt.actions,
|
|
42
|
+
completeTask: {
|
|
43
|
+
prompt: {
|
|
44
|
+
when: 'You can produce the deliverable for the assigned task in this turn.',
|
|
45
|
+
instruction: 'Complete the assigned task, with the deliverable itself as the result.',
|
|
46
|
+
format: '{"taskId": "<assigned task id from <event>>", "status": "completed", "result": "<the full deliverable>"}',
|
|
47
|
+
},
|
|
48
|
+
tool: 'task_update',
|
|
49
|
+
// Function form so a failed task_update surfaces as an error outcome
|
|
50
|
+
// (→ dflt error transition) instead of a phantom task-closed.
|
|
51
|
+
produces: (result) => {
|
|
52
|
+
const r = result;
|
|
53
|
+
const status = r?.task?.state ?? r?.state ?? r?.status;
|
|
54
|
+
return status === 'completed' || status === 'failed' ? 'task-closed' : 'error';
|
|
55
|
+
},
|
|
56
|
+
terminatesLoop: true,
|
|
57
|
+
},
|
|
58
|
+
failTask: {
|
|
59
|
+
prompt: {
|
|
60
|
+
when: 'The task needs abilities, tools, or information you cannot obtain this turn.',
|
|
61
|
+
instruction: 'Fail the assigned task with a one-line reason so the counterparty can re-route it.',
|
|
62
|
+
format: '{"taskId": "<assigned task id from <event>>", "status": "failed", "result": "<one-line reason>"}',
|
|
63
|
+
},
|
|
64
|
+
tool: 'task_update',
|
|
65
|
+
produces: (result) => {
|
|
66
|
+
const r = result;
|
|
67
|
+
const status = r?.task?.state ?? r?.state ?? r?.status;
|
|
68
|
+
return status === 'completed' || status === 'failed' ? 'task-closed' : 'error';
|
|
69
|
+
},
|
|
70
|
+
terminatesLoop: true,
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
transitions: [
|
|
74
|
+
{ to: initial, when: o => o.kind === 'task-closed' },
|
|
75
|
+
...dflt.transitions, // wait / message-sent / error → initial
|
|
76
|
+
],
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Assigned-task-intake default — the task-side twin of the agreement-kickoff
|
|
81
|
+
* default (kickoff.ts). For every state whose transitions do not already match
|
|
82
|
+
* a task-assigned outcome, append a transition to the injected
|
|
83
|
+
* `workingAssignedTask` state. Appended LAST so any user transition on the
|
|
84
|
+
* same outcome always wins (first match wins in
|
|
85
|
+
* AgentMachine._evaluateTransitions). Returns a NEW workflow — input states
|
|
86
|
+
* are not mutated.
|
|
87
|
+
*
|
|
88
|
+
* Why: a standing hire receives task #2, #3, … months after approval. Without
|
|
89
|
+
* this default a parked state with no task-assigned route (e.g. a hire parked
|
|
90
|
+
* in `engaged`) swallowed the wake and the task sat active forever.
|
|
91
|
+
*/
|
|
92
|
+
export function injectTaskIntakeDefault(workflow) {
|
|
93
|
+
if (workflow.states[TASK_INTAKE_STATE]) {
|
|
94
|
+
throw new Error(`[defineAgent] state name "${TASK_INTAKE_STATE}" is reserved for the injected assigned-task-intake default — rename the state`);
|
|
95
|
+
}
|
|
96
|
+
const states = {};
|
|
97
|
+
let injected = false;
|
|
98
|
+
for (const [name, state] of Object.entries(workflow.states)) {
|
|
99
|
+
// Injected defaults never feed each other: the kickoff state's own lack of
|
|
100
|
+
// a task-assigned route must not force the intake state into a workflow
|
|
101
|
+
// whose author states all handle assignment.
|
|
102
|
+
if (name === KICKOFF_STATE || stateHandlesTaskAssigned(state)) {
|
|
103
|
+
states[name] = state;
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
states[name] = { ...state, transitions: [...state.transitions, TASK_INTAKE_TRANSITION] };
|
|
107
|
+
injected = true;
|
|
108
|
+
}
|
|
109
|
+
if (!injected)
|
|
110
|
+
return workflow; // every state routes task-assigned — nothing to add
|
|
111
|
+
states[TASK_INTAKE_STATE] = buildTaskIntakeState(workflow.initial);
|
|
112
|
+
return { ...workflow, states };
|
|
113
|
+
}
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* This is how a single Node process serves a fleet of agents without
|
|
12
12
|
* holding 1000 open sockets — typical config: 1000+ registered, ~50 live.
|
|
13
13
|
*/
|
|
14
|
-
import { type InboxOperatorAgentEntry } from '@ziggs-ai/api-client';
|
|
14
|
+
import { type InboxOperatorAgentEntry, type StartAgentOptions } from '@ziggs-ai/api-client';
|
|
15
15
|
import { AgentHost, type AgentHostOptions } from '../AgentHost.js';
|
|
16
16
|
interface ConnectionManagerMeta {
|
|
17
17
|
domain?: string;
|
|
@@ -50,7 +50,14 @@ export declare class ConnectionPool {
|
|
|
50
50
|
* `AgentHost` constructed + connected) the first time it's referenced.
|
|
51
51
|
*/
|
|
52
52
|
register(configs: AgentHostOptions[], metaArr?: ConnectionManagerMeta[]): void;
|
|
53
|
-
|
|
53
|
+
/**
|
|
54
|
+
* Start (or return) an agent's host. `pinned` keeps it up for the process's
|
|
55
|
+
* lifetime — no idle sweep, last to be evicted; use it for hosts the operator
|
|
56
|
+
* named rather than ones work happened to reach.
|
|
57
|
+
*/
|
|
58
|
+
startAgent(agentId: string, opts?: StartAgentOptions): Promise<AgentHost>;
|
|
59
|
+
/** Agents kept up explicitly (launcher `preStart`), not by demand. */
|
|
60
|
+
listPinned(): string[];
|
|
54
61
|
sleep(agentId: string): Promise<void>;
|
|
55
62
|
disconnectAll(): Promise<void>;
|
|
56
63
|
/**
|
|
@@ -46,7 +46,14 @@ export class ConnectionPool {
|
|
|
46
46
|
const pool = this;
|
|
47
47
|
const host = new AgentHost({
|
|
48
48
|
...cfg,
|
|
49
|
+
// Inbox activity and in-flight effects both reset the idle timer:
|
|
50
|
+
// previously only pool.sendTo() touched it, so a host woken by its
|
|
51
|
+
// own inbox loop was torn down every idleTimeoutMs (default 60s)
|
|
52
|
+
// regardless of the work it was doing — losing lane ctx, Claude
|
|
53
|
+
// session ids, and armed timers each time.
|
|
54
|
+
onActivity: () => pool.manager.touch(agentId),
|
|
49
55
|
effectTap(effect, response) {
|
|
56
|
+
pool.manager.touch(agentId);
|
|
50
57
|
if (effect.kind === 'send-message') {
|
|
51
58
|
const listeners = pool.responseListeners.get(agentId);
|
|
52
59
|
if (listeners?.length)
|
|
@@ -64,8 +71,17 @@ export class ConnectionPool {
|
|
|
64
71
|
}, metaByAgentId.get(agentId));
|
|
65
72
|
}
|
|
66
73
|
}
|
|
67
|
-
|
|
68
|
-
|
|
74
|
+
/**
|
|
75
|
+
* Start (or return) an agent's host. `pinned` keeps it up for the process's
|
|
76
|
+
* lifetime — no idle sweep, last to be evicted; use it for hosts the operator
|
|
77
|
+
* named rather than ones work happened to reach.
|
|
78
|
+
*/
|
|
79
|
+
async startAgent(agentId, opts = {}) {
|
|
80
|
+
return await this.manager.startAgent(agentId, opts);
|
|
81
|
+
}
|
|
82
|
+
/** Agents kept up explicitly (launcher `preStart`), not by demand. */
|
|
83
|
+
listPinned() {
|
|
84
|
+
return this.manager.listPinned();
|
|
69
85
|
}
|
|
70
86
|
async sleep(agentId) {
|
|
71
87
|
return this.manager.sleep(agentId);
|
|
@@ -1,13 +1,17 @@
|
|
|
1
|
-
import { type Creds } from '@ziggs-ai/api-client';
|
|
1
|
+
import { 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
|
|
5
|
-
* a hosted agent consumes: `GET /inbox?wait=` → grant-fenced reads of
|
|
6
|
-
* → deliver into the host's lane/tick machinery →
|
|
7
|
-
* no push, no separate catch-up channel — a
|
|
8
|
-
* iteration (the
|
|
9
|
-
* re-processing a redelivered window is
|
|
10
|
-
* is the one ordering rule.
|
|
5
|
+
* a hosted agent consumes: `GET /inbox?wait=` → grant-fenced reads of what was
|
|
6
|
+
* addressed to it → deliver into the host's lane/tick machinery →
|
|
7
|
+
* `POST /inbox/ack`. No socket, no push, no separate catch-up channel — a
|
|
8
|
+
* crash-replay is just the next iteration (the watermark is server-side), and
|
|
9
|
+
* writes carry idempotency keys so re-processing a redelivered window is
|
|
10
|
+
* wasteful, never harmful. Act-before-ack is the one ordering rule.
|
|
11
|
+
*
|
|
12
|
+
* The envelope now names the chats directly, so this loop no longer expands
|
|
13
|
+
* scopes into chat ids itself — that cost an extra grant/agreement lookup per
|
|
14
|
+
* scope, per pass, purely to rediscover what the server already knew.
|
|
11
15
|
*/
|
|
12
16
|
export interface InboxLoopDeps {
|
|
13
17
|
creds: Creds;
|
|
@@ -16,13 +20,24 @@ export interface InboxLoopDeps {
|
|
|
16
20
|
seen: SeenMessages;
|
|
17
21
|
onMessage: (text: string, metadata: Record<string, unknown>) => Promise<void>;
|
|
18
22
|
onResourceEvent: (event: Record<string, unknown>) => Promise<void>;
|
|
23
|
+
/**
|
|
24
|
+
* An open task assigned to this agent (ZIG-973). Delivered from the inbox's
|
|
25
|
+
* own task channel, so work reaches the agent even when no chat row exists
|
|
26
|
+
* for it (chat-less agreement, self-assigned task).
|
|
27
|
+
*/
|
|
28
|
+
onTask?: (task: InboxTaskRef) => Promise<void>;
|
|
19
29
|
label?: string;
|
|
20
30
|
}
|
|
21
31
|
export interface InboxPassResult {
|
|
22
|
-
|
|
32
|
+
/** Delivery references this envelope carried. */
|
|
33
|
+
deliveries: number;
|
|
23
34
|
messagesDelivered: number;
|
|
24
35
|
resourceEvents: number;
|
|
25
|
-
|
|
36
|
+
/** Assigned open tasks delivered this pass (ZIG-973). */
|
|
37
|
+
tasksDelivered: number;
|
|
38
|
+
/** True when the watermark advanced this pass. */
|
|
39
|
+
acked: boolean;
|
|
40
|
+
/** True when there was more mail than one envelope carries. */
|
|
26
41
|
truncated: boolean;
|
|
27
42
|
}
|
|
28
43
|
/**
|
|
@@ -42,8 +57,13 @@ export interface InboxLoopHandle {
|
|
|
42
57
|
* Run {@link pollInboxOnce} forever (until stopped). Each iteration long-polls
|
|
43
58
|
* for `waitSeconds`; a passing envelope returns instantly, an empty one holds.
|
|
44
59
|
* Errors retry with exponential backoff + jitter and never exit the loop —
|
|
45
|
-
* there is nothing to resynchronize (the
|
|
46
|
-
* just the next iteration.
|
|
60
|
+
* there is nothing to resynchronize (the watermark is server-side), so recovery
|
|
61
|
+
* is just the next iteration.
|
|
62
|
+
*
|
|
63
|
+
* The FIRST pass is an immediate read. A held poll only answers to new mail, so
|
|
64
|
+
* a host that just started (or restarted after a crash) would otherwise sit
|
|
65
|
+
* waiting while the work it was already assigned — open tasks, pending
|
|
66
|
+
* decisions — went unseen until something unrelated arrived.
|
|
47
67
|
*/
|
|
48
68
|
export declare function runInboxLoop(deps: InboxLoopDeps, opts?: {
|
|
49
69
|
waitSeconds?: number;
|
package/dist/server/inboxLoop.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ContextReadClient,
|
|
1
|
+
import { ContextReadClient, InboxClient, } from '@ziggs-ai/api-client';
|
|
2
2
|
import { runtimeLog } from '../shared/runtimeLog.js';
|
|
3
3
|
const READ_PAGE = 100;
|
|
4
4
|
function log(label, msg) {
|
|
@@ -7,67 +7,61 @@ function log(label, msg) {
|
|
|
7
7
|
function warn(label, msg) {
|
|
8
8
|
runtimeLog.warn(label, msg);
|
|
9
9
|
}
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
}
|
|
19
|
-
case 'org': {
|
|
20
|
-
// All pages of live chat grants — an org-scope read must not miss chats
|
|
21
|
-
// hidden behind pagination (GET /grants defaults to one page).
|
|
22
|
-
const items = await grants.listAllGrants({
|
|
23
|
-
scopeKind: 'chat',
|
|
24
|
-
health: 'active',
|
|
25
|
-
});
|
|
26
|
-
return [...new Set(items.map((g) => g.scope.id))];
|
|
27
|
-
}
|
|
28
|
-
default:
|
|
29
|
-
return [];
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
async function deliverMessagesForScope(deps, scope, chatIds, reader, countCap) {
|
|
10
|
+
/**
|
|
11
|
+
* Read the messages behind one chat's deliveries and hand them to the host.
|
|
12
|
+
*
|
|
13
|
+
* The envelope says which chat and how many, not the bodies — so this still
|
|
14
|
+
* reads through `/context/read`, where the agent's own grants fence what it is
|
|
15
|
+
* allowed to see. A delivery reference is an address, never an authorisation.
|
|
16
|
+
*/
|
|
17
|
+
async function deliverMessagesForChat(deps, news, earliestTs, reader) {
|
|
33
18
|
let delivered = 0;
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
via: `chat:${chatId}`,
|
|
19
|
+
let remaining = news.count;
|
|
20
|
+
// `after` is exclusive, so step back a millisecond to include the oldest
|
|
21
|
+
// delivery itself. Re-reading a neighbour that shares its timestamp is
|
|
22
|
+
// harmless — `seen` dedups by messageId.
|
|
23
|
+
let after = new Date(Date.parse(earliestTs) - 1).toISOString();
|
|
24
|
+
let pages = 0;
|
|
25
|
+
const maxPages = 20;
|
|
26
|
+
while (remaining > 0 && pages < maxPages) {
|
|
27
|
+
const limit = Math.min(READ_PAGE, remaining);
|
|
28
|
+
let page;
|
|
29
|
+
try {
|
|
30
|
+
page = await reader.read('messages', {
|
|
31
|
+
via: `chat:${news.chatId}`,
|
|
47
32
|
after,
|
|
48
33
|
direction: 'forward',
|
|
49
34
|
limit,
|
|
50
35
|
});
|
|
51
|
-
for (const row of page.items) {
|
|
52
|
-
const messageId = String(row.messageId ?? '');
|
|
53
|
-
if (deps.seen.has(messageId))
|
|
54
|
-
continue;
|
|
55
|
-
const text = typeof row.text === 'string' ? row.text : '';
|
|
56
|
-
if (!text.trim())
|
|
57
|
-
continue;
|
|
58
|
-
await deps.onMessage(text, { ...row, chatId });
|
|
59
|
-
delivered++;
|
|
60
|
-
if (remaining !== Number.POSITIVE_INFINITY)
|
|
61
|
-
remaining--;
|
|
62
|
-
}
|
|
63
|
-
if (!page.hasMore || !page.latestSequence)
|
|
64
|
-
break;
|
|
65
|
-
after = page.latestSequence;
|
|
66
|
-
pages++;
|
|
67
36
|
}
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
37
|
+
catch (err) {
|
|
38
|
+
const status = err.status;
|
|
39
|
+
if (status !== 403 && status !== 404)
|
|
40
|
+
throw err;
|
|
41
|
+
// Addressed but not readable. Since the delivery log and the grants are
|
|
42
|
+
// now separate, the two can disagree — a grant revoked between the write
|
|
43
|
+
// and this read, or a chat since deleted. Skipping keeps the pass alive
|
|
44
|
+
// so the rest of the mail is delivered and the watermark still advances;
|
|
45
|
+
// failing here would wedge the agent on one unreadable item forever,
|
|
46
|
+
// re-woken by the same delivery on every pass.
|
|
47
|
+
warn(deps.label ?? 'inboxLoop', `chat ${news.chatId}: addressed but not readable (${status}) — skipping ${remaining} delivery/deliveries`);
|
|
48
|
+
return delivered;
|
|
49
|
+
}
|
|
50
|
+
for (const row of page.items) {
|
|
51
|
+
const messageId = String(row.messageId ?? '');
|
|
52
|
+
if (deps.seen.has(messageId))
|
|
53
|
+
continue;
|
|
54
|
+
const text = typeof row.text === 'string' ? row.text : '';
|
|
55
|
+
if (!text.trim())
|
|
56
|
+
continue;
|
|
57
|
+
await deps.onMessage(text, { ...row, chatId: news.chatId });
|
|
58
|
+
delivered++;
|
|
59
|
+
remaining--;
|
|
60
|
+
}
|
|
61
|
+
if (!page.hasMore || !page.latestSequence)
|
|
62
|
+
break;
|
|
63
|
+
after = page.latestSequence;
|
|
64
|
+
pages++;
|
|
71
65
|
}
|
|
72
66
|
return delivered;
|
|
73
67
|
}
|
|
@@ -81,51 +75,77 @@ export async function pollInboxOnce(deps, waitSeconds = 0) {
|
|
|
81
75
|
const label = deps.label ?? 'inboxLoop';
|
|
82
76
|
const inbox = new InboxClient(deps.creds.operatorKey, deps.creds.agentId, deps.baseUrl);
|
|
83
77
|
const reader = new ContextReadClient(deps.creds.operatorKey, deps.creds.agentId, deps.baseUrl);
|
|
84
|
-
const grants = new GrantsClient(deps.creds.operatorKey, deps.creds.agentId, deps.baseUrl);
|
|
85
78
|
const envelope = await inbox.getInbox(waitSeconds > 0 ? { waitSeconds } : {});
|
|
86
|
-
const countCap = envelope.countCap;
|
|
87
79
|
let messagesDelivered = 0;
|
|
88
80
|
let resourceEvents = 0;
|
|
89
|
-
|
|
90
|
-
if (envelope.
|
|
91
|
-
// Informational only — does NOT block acking
|
|
92
|
-
//
|
|
93
|
-
//
|
|
94
|
-
|
|
95
|
-
// (reach > cap) re-deliver its whole backlog every pass forever.
|
|
96
|
-
warn(label, `inbox truncatedScopes=${envelope.truncatedScopes} (reach exceeds per-call cap — examined scopes are still acked normally)`);
|
|
81
|
+
let tasksDelivered = 0;
|
|
82
|
+
if (envelope.deliveriesCapped) {
|
|
83
|
+
// Informational only — does NOT block acking what WAS handed over. The
|
|
84
|
+
// tail stays unacked and arrives on the next pass; never acking here would
|
|
85
|
+
// make a badly backed-up agent replay its whole backlog forever.
|
|
86
|
+
warn(label, 'inbox deliveriesCapped (more mail than one envelope carries — the tail follows on the next pass)');
|
|
97
87
|
}
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
88
|
+
// Oldest delivery per chat: where to start reading that chat's messages.
|
|
89
|
+
const earliestByChat = new Map();
|
|
90
|
+
for (const d of envelope.deliveries) {
|
|
91
|
+
if (!d.chatId)
|
|
92
|
+
continue;
|
|
93
|
+
const seen = earliestByChat.get(d.chatId);
|
|
94
|
+
if (!seen || d.ts < seen)
|
|
95
|
+
earliestByChat.set(d.chatId, d.ts);
|
|
96
|
+
}
|
|
97
|
+
for (const news of envelope.chats) {
|
|
98
|
+
const earliest = earliestByChat.get(news.chatId) ?? news.latestAt;
|
|
99
|
+
const n = await deliverMessagesForChat(deps, news, earliest, reader);
|
|
100
|
+
messagesDelivered += n;
|
|
101
|
+
if (n > 0)
|
|
102
|
+
log(label, `chat ${news.chatId} delivered ${n} message(s)`);
|
|
103
|
+
}
|
|
104
|
+
// Everything that is not chat text is handed over as a resource event, from
|
|
105
|
+
// the delivery itself — the reference already carries the ids the host needs,
|
|
106
|
+
// so nothing has to be re-derived to name what changed.
|
|
107
|
+
for (const d of envelope.deliveries) {
|
|
108
|
+
if (d.kind === 'message')
|
|
109
|
+
continue;
|
|
110
|
+
const key = `delivery:${d.kind}:${d.resourceId}:${d.ts}`;
|
|
111
|
+
if (deps.seen.has(key))
|
|
112
|
+
continue;
|
|
113
|
+
deps.seen.mark(key);
|
|
114
|
+
await deps.onResourceEvent({
|
|
115
|
+
kind: d.kind,
|
|
116
|
+
ts: d.ts,
|
|
117
|
+
resourceId: d.resourceId,
|
|
118
|
+
...(d.chatId ? { chatId: d.chatId } : {}),
|
|
119
|
+
...(d.agreementId ? { agreementId: d.agreementId } : {}),
|
|
120
|
+
...(d.taskId ? { taskId: d.taskId } : {}),
|
|
121
|
+
change: 'created',
|
|
122
|
+
});
|
|
123
|
+
resourceEvents++;
|
|
124
|
+
}
|
|
125
|
+
// ZIG-973: the work channel. An open assigned task is redelivered on every
|
|
126
|
+
// pass until it closes (that IS the durable-wait contract), so dedup on
|
|
127
|
+
// taskId+updatedAt — a genuinely touched task wakes again, an untouched one
|
|
128
|
+
// doesn't storm the lane every long-poll.
|
|
129
|
+
for (const task of envelope.tasksAwaitingMe ?? []) {
|
|
130
|
+
const key = `task:${task.taskId}:${task.updatedAt ?? ''}`;
|
|
131
|
+
if (deps.seen.has(key))
|
|
132
|
+
continue;
|
|
133
|
+
deps.seen.mark(key);
|
|
134
|
+
if (deps.onTask) {
|
|
135
|
+
await deps.onTask(task);
|
|
136
|
+
tasksDelivered++;
|
|
126
137
|
}
|
|
127
138
|
}
|
|
139
|
+
if (envelope.truncatedTasks) {
|
|
140
|
+
warn(label, `inbox truncatedTasks=${envelope.truncatedTasks} (more assigned tasks than one pass surfaces — they appear as earlier ones close)`);
|
|
141
|
+
}
|
|
142
|
+
// Pending decisions are standing state, not deliveries: they keep appearing
|
|
143
|
+
// until answered, so dedup them the same way tasks are deduped.
|
|
128
144
|
for (const proposal of envelope.proposalsAwaitingMe) {
|
|
145
|
+
const key = `proposal:${proposal.agreementId}`;
|
|
146
|
+
if (deps.seen.has(key))
|
|
147
|
+
continue;
|
|
148
|
+
deps.seen.mark(key);
|
|
129
149
|
await deps.onResourceEvent({
|
|
130
150
|
kind: 'agreement',
|
|
131
151
|
ts: proposal.proposedAt ?? new Date().toISOString(),
|
|
@@ -136,18 +156,21 @@ export async function pollInboxOnce(deps, waitSeconds = 0) {
|
|
|
136
156
|
});
|
|
137
157
|
resourceEvents++;
|
|
138
158
|
}
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
159
|
+
// Act, then ack: everything above has been handed to the host, so the
|
|
160
|
+
// watermark may now move past it. A crash before this point redelivers.
|
|
161
|
+
let acked = false;
|
|
162
|
+
if (envelope.ackTo) {
|
|
163
|
+
await inbox.ack(envelope.ackTo);
|
|
164
|
+
acked = true;
|
|
165
|
+
log(label, `acked up to ${envelope.ackTo}`);
|
|
144
166
|
}
|
|
145
167
|
return {
|
|
146
|
-
|
|
168
|
+
deliveries: envelope.deliveries.length,
|
|
147
169
|
messagesDelivered,
|
|
148
170
|
resourceEvents,
|
|
171
|
+
tasksDelivered,
|
|
149
172
|
acked,
|
|
150
|
-
truncated: envelope.
|
|
173
|
+
truncated: envelope.deliveriesCapped,
|
|
151
174
|
};
|
|
152
175
|
}
|
|
153
176
|
const DEFAULT_WAIT_SECONDS = 25;
|
|
@@ -160,18 +183,25 @@ function sleep(ms) {
|
|
|
160
183
|
* Run {@link pollInboxOnce} forever (until stopped). Each iteration long-polls
|
|
161
184
|
* for `waitSeconds`; a passing envelope returns instantly, an empty one holds.
|
|
162
185
|
* Errors retry with exponential backoff + jitter and never exit the loop —
|
|
163
|
-
* there is nothing to resynchronize (the
|
|
164
|
-
* just the next iteration.
|
|
186
|
+
* there is nothing to resynchronize (the watermark is server-side), so recovery
|
|
187
|
+
* is just the next iteration.
|
|
188
|
+
*
|
|
189
|
+
* The FIRST pass is an immediate read. A held poll only answers to new mail, so
|
|
190
|
+
* a host that just started (or restarted after a crash) would otherwise sit
|
|
191
|
+
* waiting while the work it was already assigned — open tasks, pending
|
|
192
|
+
* decisions — went unseen until something unrelated arrived.
|
|
165
193
|
*/
|
|
166
194
|
export function runInboxLoop(deps, opts = {}) {
|
|
167
195
|
const label = deps.label ?? 'inboxLoop';
|
|
168
196
|
const waitSeconds = opts.waitSeconds ?? DEFAULT_WAIT_SECONDS;
|
|
169
197
|
let running = true;
|
|
170
198
|
let consecutiveErrors = 0;
|
|
199
|
+
let first = true;
|
|
171
200
|
const done = (async () => {
|
|
172
201
|
while (running) {
|
|
173
202
|
try {
|
|
174
|
-
await pollInboxOnce(deps, waitSeconds);
|
|
203
|
+
await pollInboxOnce(deps, first ? 0 : waitSeconds);
|
|
204
|
+
first = false;
|
|
175
205
|
consecutiveErrors = 0;
|
|
176
206
|
}
|
|
177
207
|
catch (err) {
|
|
@@ -82,11 +82,17 @@ async function runFleet(configs, { operatorKey, wsUrl = process.env.WS_URL, port
|
|
|
82
82
|
}
|
|
83
83
|
}
|
|
84
84
|
if (preStart.length > 0) {
|
|
85
|
-
|
|
86
|
-
|
|
85
|
+
// Pinned: these agents are up because the operator named them
|
|
86
|
+
// (WAKE_AGENTS), not because work arrived, so the idle sweep must not
|
|
87
|
+
// retire them. It used to: an empty long-poll isn't "activity", so a quiet
|
|
88
|
+
// pre-started agent was torn down after idleTimeoutMs and only lazy start
|
|
89
|
+
// could bring it back — which at fleet size it cannot (ZIG-965 tail).
|
|
90
|
+
await Promise.all(preStart.map(id => pool.startAgent(id, { pinned: true })
|
|
91
|
+
.then(() => runtimeLog.debug(label, `Pre-started "${id}" (pinned)`))
|
|
87
92
|
.catch(err => runtimeLog.warn(label, `Could not pre-start "${id}": ${err.message}`))));
|
|
88
93
|
}
|
|
89
|
-
runtimeLog.info(label, `Ready — ${pool.size} agent(s) registered, ${pool.listActive().length} connected`
|
|
94
|
+
runtimeLog.info(label, `Ready — ${pool.size} agent(s) registered, ${pool.listActive().length} connected` +
|
|
95
|
+
(pool.listPinned().length ? ` (${pool.listPinned().length} pinned)` : ''));
|
|
90
96
|
process.on('SIGTERM', async () => {
|
|
91
97
|
runtimeLog.info(label, 'SIGTERM — shutting down');
|
|
92
98
|
pool.stopControl();
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
* `pool.startAgent()` / preStart are untouched, and an agent with ANY fresh item
|
|
19
19
|
* starts normally (its own inbox loop then drains the stale backlog too).
|
|
20
20
|
*/
|
|
21
|
-
import type {
|
|
21
|
+
import type { InboxOperatorAgentEntry } from '@ziggs-ai/api-client';
|
|
22
22
|
export declare const DEFAULT_REPLAY_MAX_AGE_MS: number;
|
|
23
23
|
export interface ReplayPolicy {
|
|
24
24
|
/** Skip slices whose newest actionable item is older than this. */
|
|
@@ -33,11 +33,15 @@ export interface ReplayPolicy {
|
|
|
33
33
|
export declare function parseReplayMaxAge(raw: string): number | null;
|
|
34
34
|
export declare function resolveReplayPolicy(env?: NodeJS.ProcessEnv): ReplayPolicy;
|
|
35
35
|
/**
|
|
36
|
-
* Newest actionable
|
|
37
|
-
*
|
|
38
|
-
*
|
|
36
|
+
* Newest actionable instant for an agent, from its operator-sweep entry.
|
|
37
|
+
*
|
|
38
|
+
* The sweep hands this over directly now — it is the agent's delivery
|
|
39
|
+
* watermark — where this used to fold together the max timestamp across every
|
|
40
|
+
* channel of a whole envelope. An agent surfaced only for open work carries no
|
|
41
|
+
* delivery instant: age is unprovable, so it is treated as fresh and started.
|
|
42
|
+
* Stale-skipping a half-finished task would strand it.
|
|
39
43
|
*/
|
|
40
|
-
export declare function newestActionableAt(
|
|
44
|
+
export declare function newestActionableAt(entry: InboxOperatorAgentEntry): number | null;
|
|
41
45
|
export declare function formatAge(ms: number): string;
|
|
42
46
|
export interface StaleBacklogGateOptions extends ReplayPolicy {
|
|
43
47
|
label?: string;
|
|
@@ -30,39 +30,19 @@ export function resolveReplayPolicy(env = process.env) {
|
|
|
30
30
|
return { maxAgeMs, replayAll };
|
|
31
31
|
}
|
|
32
32
|
/**
|
|
33
|
-
* Newest actionable
|
|
34
|
-
*
|
|
35
|
-
*
|
|
33
|
+
* Newest actionable instant for an agent, from its operator-sweep entry.
|
|
34
|
+
*
|
|
35
|
+
* The sweep hands this over directly now — it is the agent's delivery
|
|
36
|
+
* watermark — where this used to fold together the max timestamp across every
|
|
37
|
+
* channel of a whole envelope. An agent surfaced only for open work carries no
|
|
38
|
+
* delivery instant: age is unprovable, so it is treated as fresh and started.
|
|
39
|
+
* Stale-skipping a half-finished task would strand it.
|
|
36
40
|
*/
|
|
37
|
-
export function newestActionableAt(
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
const t = Date.parse(iso);
|
|
43
|
-
if (!Number.isNaN(t) && (newest == null || t > newest))
|
|
44
|
-
newest = t;
|
|
45
|
-
};
|
|
46
|
-
for (const scope of inbox.scopes ?? [])
|
|
47
|
-
consider(scope.latestAt);
|
|
48
|
-
for (const p of inbox.proposalsAwaitingMe ?? [])
|
|
49
|
-
consider(p.proposedAt);
|
|
50
|
-
for (const r of inbox.connectionRequestsAwaitingMe ?? [])
|
|
51
|
-
consider(r.requestedAt);
|
|
52
|
-
for (const r of inbox.mcpServerRequestsAwaitingMe ?? [])
|
|
53
|
-
consider(r.requestedAt);
|
|
54
|
-
return newest;
|
|
55
|
-
}
|
|
56
|
-
/** Unread items in a slice, for the summary line. Counts are backend-capped. */
|
|
57
|
-
function countItems(inbox) {
|
|
58
|
-
let items = 0;
|
|
59
|
-
for (const scope of inbox.scopes ?? []) {
|
|
60
|
-
items += (scope.newMessages ?? 0) + (scope.newArtifacts ?? 0);
|
|
61
|
-
}
|
|
62
|
-
items += (inbox.proposalsAwaitingMe ?? []).length;
|
|
63
|
-
items += (inbox.connectionRequestsAwaitingMe ?? []).length;
|
|
64
|
-
items += (inbox.mcpServerRequestsAwaitingMe ?? []).length;
|
|
65
|
-
return items;
|
|
41
|
+
export function newestActionableAt(entry) {
|
|
42
|
+
if (!entry.deliveredUpTo)
|
|
43
|
+
return null;
|
|
44
|
+
const t = Date.parse(entry.deliveredUpTo);
|
|
45
|
+
return Number.isNaN(t) ? null : t;
|
|
66
46
|
}
|
|
67
47
|
export function formatAge(ms) {
|
|
68
48
|
if (ms >= 86_400_000)
|
|
@@ -86,7 +66,6 @@ export function createStaleBacklogGate({ maxAgeMs, replayAll, label = 'launcher'
|
|
|
86
66
|
const emit = log ?? ((line) => runtimeLog.warn(label, line));
|
|
87
67
|
const announced = new Set();
|
|
88
68
|
let pendingAgents = [];
|
|
89
|
-
let pendingItems = 0;
|
|
90
69
|
let flushScheduled = false;
|
|
91
70
|
// One envelope's entries are gated synchronously; a microtask-deferred
|
|
92
71
|
// flush turns a whole boot flood into a single summary line.
|
|
@@ -96,20 +75,21 @@ export function createStaleBacklogGate({ maxAgeMs, replayAll, label = 'launcher'
|
|
|
96
75
|
return;
|
|
97
76
|
const preview = pendingAgents.slice(0, 5).join(', ');
|
|
98
77
|
const more = pendingAgents.length > 5 ? `, +${pendingAgents.length - 5} more` : '';
|
|
99
|
-
emit(`startup backlog policy: skipped ${
|
|
78
|
+
emit(`startup backlog policy: skipped ${pendingAgents.length} agent(s) whose newest mail is older than ${formatAge(maxAgeMs)} (${preview}${more}) — set FLEET_REPLAY_STALE=1 to replay them`);
|
|
100
79
|
pendingAgents = [];
|
|
101
|
-
pendingItems = 0;
|
|
102
80
|
};
|
|
103
81
|
return (entry) => {
|
|
104
82
|
if (replayAll)
|
|
105
83
|
return true;
|
|
106
|
-
|
|
84
|
+
// Open work always starts, however old: the task is unfinished, not stale.
|
|
85
|
+
if (entry.hasOpenTasks)
|
|
86
|
+
return true;
|
|
87
|
+
const newest = newestActionableAt(entry);
|
|
107
88
|
if (newest == null || now() - newest <= maxAgeMs)
|
|
108
89
|
return true;
|
|
109
90
|
if (!announced.has(entry.agentId)) {
|
|
110
91
|
announced.add(entry.agentId);
|
|
111
92
|
pendingAgents.push(entry.agentId);
|
|
112
|
-
pendingItems += countItems(entry.inbox);
|
|
113
93
|
if (!flushScheduled) {
|
|
114
94
|
flushScheduled = true;
|
|
115
95
|
queueMicrotask(flush);
|
|
@@ -19,14 +19,14 @@ function handlerFor(operation) {
|
|
|
19
19
|
return result;
|
|
20
20
|
};
|
|
21
21
|
}
|
|
22
|
-
export const agreementProposeTool = defineTool('agreement_propose', { description: { type: 'string', required: true }, proposedTo: { type: 'string', required: true }, engagementKind: { type: 'string', enum: ['hire', 'service'], description: 'Contract type. Defaults to service when omitted.' }, parentAgreementId: 'string', parentTaskId: 'string', providerId: { type: 'string', description: 'Who does the work — required on direct proposals, forbidden on broadcasts (the claiming agent fills the open side). Pass your own id when YOU are offering to do the work, or the proposedTo id when you are commissioning the RECIPIENT. Naming any other third party requires that party to have a matching published offer. The payer is always derived as the non-providing side.' }, price: 'number', lifecycle: 'string', expiresAt: 'string', maxExecutions: 'number', agreementDescription: 'string', plan: { type: 'object' }, planReviewTiming: 'string', requireMidWorkPlanAck: 'boolean' }, handlerFor('agreement-propose'), { description: 'Propose a contract to a user, agent, or as an open broadcast. Direction is stated, never guessed: providerId = your own id ("I work, you pay") or providerId = proposedTo ("you work, I pay"). Required on direct proposals; omit it only for a broadcast — proposedTo="everyone" (fully public — any agent may claim) or proposedTo="org" (scoped to your org). engagementKind defaults to service.', isAgreementCreation: true });
|
|
22
|
+
export const agreementProposeTool = defineTool('agreement_propose', { description: { type: 'string', required: true }, proposedTo: { type: 'string', required: true }, engagementKind: { type: 'string', enum: ['hire', 'service'], description: 'Contract type. Defaults to service when omitted.' }, parentAgreementId: 'string', parentTaskId: 'string', providerId: { type: 'string', description: 'Who does the work — required on direct proposals, forbidden on broadcasts (the claiming agent fills the open side). Pass your own id when YOU are offering to do the work, or the proposedTo id when you are commissioning the RECIPIENT. Naming any other third party requires that party to have a matching published offer. The payer is always derived as the non-providing side.' }, price: 'number', lifecycle: 'string', expiresAt: 'string', maxExecutions: 'number', billing: { type: 'string', enum: ['total', 'per_task'], description: "How price reads: 'total' (default, escrowed and paid at the end) or 'per_task' (a rate settled as each task completes — standing agreements only, and the default for a hire)." }, agreementDescription: 'string', plan: { type: 'object' }, planReviewTiming: 'string', requireMidWorkPlanAck: 'boolean' }, handlerFor('agreement-propose'), { description: 'Propose a contract to a user, agent, or as an open broadcast. Direction is stated, never guessed: providerId = your own id ("I work, you pay") or providerId = proposedTo ("you work, I pay"). Required on direct proposals; omit it only for a broadcast — proposedTo="everyone" (fully public — any agent may claim) or proposedTo="org" (scoped to your org). engagementKind defaults to service. Agreements are STANDING by default (no expiry, unlimited tasks under one agreement); set expiresAt or maxExecutions only when the engagement should end on its own.', isAgreementCreation: true });
|
|
23
23
|
export const agreementSubcontractTool = defineTool('agreement_subcontract', { description: { type: 'string', required: true }, executorId: { type: 'string', required: true }, parentAgreementId: { type: 'string', required: true }, parentTaskId: 'string', payerId: 'string', price: 'number', lifecycle: 'string', expiresAt: 'string', maxExecutions: 'number', agreementDescription: 'string', plan: { type: 'object' }, planReviewTiming: 'string', requireMidWorkPlanAck: 'boolean' }, handlerFor('agreement-subcontract'), { description: 'Delegate work to another agent under an existing parent agreement. Requires parentAgreementId.', isAgreementCreation: true });
|
|
24
24
|
export const agreementRespondTool = defineTool('agreement_respond', { agreementId: { type: 'string', required: true }, action: { type: 'string', required: true, enum: ['approve', 'reject'] } }, handlerFor('agreement-respond'), { description: 'Approve or reject a pending agreement proposal.' });
|
|
25
25
|
export const taskUpdateTool = defineTool('task_update', { taskId: { type: 'string', required: true }, status: { type: 'string', required: true, enum: ['completed', 'failed', 'cancelled'] }, result: 'string' }, handlerFor('task-update'), { description: 'Transition a task to completed, failed, or cancelled. Include a result string for completed tasks.' });
|
|
26
26
|
export const agreementCounterProposalTool = defineTool('agreement_counter_proposal', { agreementId: { type: 'string', required: true }, price: 'number', agreementDescription: 'string', expiresAt: 'string', lifecycle: 'string', maxExecutions: 'number', description: 'string', plan: { type: 'object' }, planReviewTiming: 'string', requireMidWorkPlanAck: 'boolean' }, handlerFor('agreement-counter-proposal'), { description: 'Counter a pending proposal with revised terms instead of approving or rejecting. Check status first with agreement_check_proposal.' });
|
|
27
27
|
export const agreementCheckProposalTool = defineTool('agreement_check_proposal', { agreementId: { type: 'string', required: true } }, handlerFor('agreement-check-proposal'), { description: 'Read the current proposal status for an agreement (pending, approved, rejected, countered, expired).' });
|
|
28
28
|
export const agreementRevokeTool = defineTool('agreement_revoke', { agreementId: { type: 'string', required: true } }, handlerFor('agreement-revoke'), { description: 'Revoke a pending proposal you created so it can no longer be approved. Use when the user has clearly moved on to a different request before responding to the previous proposal card.' });
|
|
29
|
-
export const agreementFulfillTool = defineTool('agreement_fulfill', { agreementId: { type: 'string', required: true } }, handlerFor('agreement-fulfill'), { description: '
|
|
29
|
+
export const agreementFulfillTool = defineTool('agreement_fulfill', { agreementId: { type: 'string', required: true } }, handlerFor('agreement-fulfill'), { description: 'END an agreement you provide — permanently. Fulfilling terminates the whole relationship, not one deliverable: every grant it conferred is revoked, its shared space is torn down, and it cannot be reopened. Finished WORK is reported via task_update (the task closes; the agreement stays standing for the next task). Only fulfill when the engagement\'s full scope is delivered and nothing more is expected — never a standing hire that just finished a task.' });
|
|
30
30
|
export const taskUpdatePlanStepTool = defineTool('task_update_plan_step', { taskId: { type: 'string', required: true }, stepId: { type: 'string', required: true }, status: { type: 'string', required: true }, result: 'string' }, handlerFor('task-update-plan-step'), { description: "Update the status of a single step within a task's plan." });
|
|
31
31
|
export const taskSpawnTool = defineTool('task_spawn', { agreementId: { type: 'string', required: true }, description: { type: 'string', required: true }, parentTaskId: 'string', assigneeId: { type: 'string', description: 'Explicitly hand this task to one specific agent (e.g. a worker your orchestrator selected via agent search) instead of leaving it unassigned. Must be a party on the agreement (payer, provider, creator, or creator agent).' }, inputArtifactIds: { type: 'array', items: { type: 'string' }, description: 'ZIG-734: prior-step output artifact handles for structured input links.' }, plan: { type: 'object' }, planReviewTiming: 'string', requireMidWorkPlanAck: 'boolean' }, handlerFor('task-spawn'), { description: 'Spawn a new task under an existing approved agreement. Use when the contract is already in place. Pass assigneeId to delegate the task to a specific agent rather than leaving it open.' });
|
|
32
32
|
export const PROTOCOL_TOOLS = [
|
package/dist/types.d.ts
CHANGED
|
@@ -313,8 +313,9 @@ export interface ContextSnapshot {
|
|
|
313
313
|
* The session's chat no longer resolves on the backend (messages read
|
|
314
314
|
* 404'd) — deleted or never visible to this agent. Message-triggered wakes
|
|
315
315
|
* check this and skip the LLM instead of reasoning over a fabricated empty
|
|
316
|
-
* history.
|
|
317
|
-
*
|
|
316
|
+
* history. Only gates message wakes. A bare agreementId as sessionId also
|
|
317
|
+
* trips it (ZIG-975) — that is a mis-keyed lane, not a legitimate task lane;
|
|
318
|
+
* task wakes should key on the agreement's origin/notification chat.
|
|
318
319
|
*/
|
|
319
320
|
chatMissing?: boolean;
|
|
320
321
|
/** Index signature so PromptBuilder's `ServerContext` can accept it directly. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ziggs-ai/agent-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Agent framework SDK for building autonomous agents on the Ziggs platform",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
"@anthropic-ai/claude-agent-sdk": "^0.3.204",
|
|
27
27
|
"@anthropic-ai/sdk": "^0.110.0",
|
|
28
28
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
29
|
-
"@ziggs-ai/api-client": "^0.
|
|
29
|
+
"@ziggs-ai/api-client": "^0.5.0",
|
|
30
30
|
"ajv": "^8.17.1",
|
|
31
31
|
"dotenv": "^17.2.3",
|
|
32
32
|
"openai": "^4.0.0",
|