@ziggs-ai/agent-sdk 0.2.2 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/AgentHost.d.ts +12 -20
- package/dist/AgentHost.js +58 -136
- package/dist/agent/Agent.js +4 -0
- package/dist/agent/ClaudeSdkAgent.js +13 -4
- package/dist/agent/claudeSdkProtocol.d.ts +8 -7
- package/dist/agent/claudeSdkProtocol.js +8 -7
- package/dist/agent/claudeSdkTools.d.ts +4 -2
- package/dist/agent/claudeSdkTools.js +9 -4
- package/dist/agent/toolManagerToSdkMcp.js +1 -0
- package/dist/index.d.ts +3 -4
- package/dist/index.js +2 -2
- package/dist/ingress/normalizeIncoming.d.ts +17 -3
- package/dist/ingress/normalizeIncoming.js +11 -26
- package/dist/runtime/AgentMachine.d.ts +5 -2
- package/dist/runtime/AgentMachine.js +8 -2
- package/dist/runtime/runMechanical.d.ts +3 -1
- package/dist/runtime/runMechanical.js +2 -2
- package/dist/runtime/runTurn.d.ts +3 -1
- package/dist/runtime/runTurn.js +25 -7
- package/dist/runtime/wakeWriteKeys.d.ts +25 -0
- package/dist/runtime/wakeWriteKeys.js +134 -0
- package/dist/server/ConnectionPool.d.ts +7 -4
- package/dist/server/ConnectionPool.js +11 -14
- package/dist/server/ZiggsEffectHandler.js +17 -12
- package/dist/server/inboxLoop.d.ts +50 -0
- package/dist/server/{InboxCatchUp.js → inboxLoop.js} +63 -52
- package/dist/server/runLauncher.js +1 -1
- package/dist/server/tasks/TaskService.d.ts +2 -1
- package/dist/server/tasks/TaskService.js +5 -2
- package/dist/server/tools/tier1/protocolRunner.d.ts +2 -1
- package/dist/server/tools/tier1/protocolRunner.js +43 -9
- package/dist/server/tools/tier1/protocolTools.js +1 -1
- package/dist/types.d.ts +40 -0
- package/package.json +2 -2
- package/dist/server/InboxCatchUp.d.ts +0 -24
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { type Creds } from '@ziggs-ai/api-client';
|
|
2
|
+
import { SeenMessages } from './SeenMessages.js';
|
|
3
|
+
/**
|
|
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 the delta
|
|
6
|
+
* → deliver into the host's lane/tick machinery → `POST /inbox/ack`. No socket,
|
|
7
|
+
* no push, no separate catch-up channel — a crash-replay is just the next
|
|
8
|
+
* iteration (the cursor is server-side), and writes carry idempotency keys so
|
|
9
|
+
* re-processing a redelivered window is wasteful, never harmful. Act-before-ack
|
|
10
|
+
* is the one ordering rule.
|
|
11
|
+
*/
|
|
12
|
+
export interface InboxLoopDeps {
|
|
13
|
+
creds: Creds;
|
|
14
|
+
ownAgentId: string;
|
|
15
|
+
baseUrl?: string;
|
|
16
|
+
seen: SeenMessages;
|
|
17
|
+
onMessage: (text: string, metadata: Record<string, unknown>) => Promise<void>;
|
|
18
|
+
onResourceEvent: (event: Record<string, unknown>) => Promise<void>;
|
|
19
|
+
label?: string;
|
|
20
|
+
}
|
|
21
|
+
export interface InboxPassResult {
|
|
22
|
+
scopes: number;
|
|
23
|
+
messagesDelivered: number;
|
|
24
|
+
resourceEvents: number;
|
|
25
|
+
acked: number;
|
|
26
|
+
truncated: boolean;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* One long-poll pass: hold up to `waitSeconds` for new inbox content, then read
|
|
30
|
+
* the deltas, deliver them into the host, and ack. Returns what it delivered.
|
|
31
|
+
* Reused each iteration of {@link runInboxLoop}; also usable as a single catch-up
|
|
32
|
+
* read (`waitSeconds: 0`) at bootstrap.
|
|
33
|
+
*/
|
|
34
|
+
export declare function pollInboxOnce(deps: InboxLoopDeps, waitSeconds?: number): Promise<InboxPassResult>;
|
|
35
|
+
export interface InboxLoopHandle {
|
|
36
|
+
/** Stop the loop after the current pass; resolves when it has fully exited. */
|
|
37
|
+
stop(): Promise<void>;
|
|
38
|
+
/** True while the loop is running. */
|
|
39
|
+
readonly running: boolean;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Run {@link pollInboxOnce} forever (until stopped). Each iteration long-polls
|
|
43
|
+
* for `waitSeconds`; a passing envelope returns instantly, an empty one holds.
|
|
44
|
+
* Errors retry with exponential backoff + jitter and never exit the loop —
|
|
45
|
+
* there is nothing to resynchronize (the cursor is server-side), so recovery is
|
|
46
|
+
* just the next iteration.
|
|
47
|
+
*/
|
|
48
|
+
export declare function runInboxLoop(deps: InboxLoopDeps, opts?: {
|
|
49
|
+
waitSeconds?: number;
|
|
50
|
+
}): InboxLoopHandle;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ContextReadClient, GrantsClient, InboxClient, getChatsForAgreement,
|
|
1
|
+
import { ContextReadClient, GrantsClient, InboxClient, getChatsForAgreement, } 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,19 +7,6 @@ function log(label, msg) {
|
|
|
7
7
|
function warn(label, msg) {
|
|
8
8
|
runtimeLog.warn(label, msg);
|
|
9
9
|
}
|
|
10
|
-
const TERMINAL_AGREEMENT_STATUSES = new Set(['fulfilled', 'cancelled', 'refunded']);
|
|
11
|
-
/** ZIG-832 P4: is this agreement finished (so its backlog must not be re-run)? */
|
|
12
|
-
async function isTerminalAgreement(agreementId, creds) {
|
|
13
|
-
try {
|
|
14
|
-
const res = (await getAgreementStatus(agreementId, creds));
|
|
15
|
-
const status = res?.status ?? res?.agreement?.status;
|
|
16
|
-
return status != null && TERMINAL_AGREEMENT_STATUSES.has(status);
|
|
17
|
-
}
|
|
18
|
-
catch {
|
|
19
|
-
// On lookup failure, don't skip — fall through to normal delivery.
|
|
20
|
-
return false;
|
|
21
|
-
}
|
|
22
|
-
}
|
|
23
10
|
/** Map Mongo message row → wire metadata for normalizeIncomingEvent. */
|
|
24
11
|
function rowToMetadata(row, chatId) {
|
|
25
12
|
const senderRaw = row.sender;
|
|
@@ -47,7 +34,7 @@ function rowToMetadata(row, chatId) {
|
|
|
47
34
|
text: row.text,
|
|
48
35
|
};
|
|
49
36
|
}
|
|
50
|
-
async function chatIdsForScope(scope, creds,
|
|
37
|
+
async function chatIdsForScope(scope, creds, grants) {
|
|
51
38
|
const { kind, id } = scope.scope;
|
|
52
39
|
switch (kind) {
|
|
53
40
|
case 'chat':
|
|
@@ -57,8 +44,8 @@ async function chatIdsForScope(scope, creds, baseUrl, grants) {
|
|
|
57
44
|
return [...new Set(links.map((l) => l.chatId).filter(Boolean))];
|
|
58
45
|
}
|
|
59
46
|
case 'org': {
|
|
60
|
-
// All pages of live chat grants — an org-scope
|
|
61
|
-
//
|
|
47
|
+
// All pages of live chat grants — an org-scope read must not miss chats
|
|
48
|
+
// hidden behind pagination (GET /grants defaults to one page).
|
|
62
49
|
const items = await grants.listAllGrants({
|
|
63
50
|
scopeKind: 'chat',
|
|
64
51
|
health: 'active',
|
|
@@ -71,7 +58,7 @@ async function chatIdsForScope(scope, creds, baseUrl, grants) {
|
|
|
71
58
|
}
|
|
72
59
|
async function deliverMessagesForScope(deps, scope, chatIds, reader, countCap) {
|
|
73
60
|
let delivered = 0;
|
|
74
|
-
const label = deps.label ?? '
|
|
61
|
+
const label = deps.label ?? 'inboxLoop';
|
|
75
62
|
const expected = scope.newMessages;
|
|
76
63
|
let remaining = expected >= countCap ? Number.POSITIVE_INFINITY : expected;
|
|
77
64
|
for (const chatId of chatIds) {
|
|
@@ -113,49 +100,31 @@ async function deliverMessagesForScope(deps, scope, chatIds, reader, countCap) {
|
|
|
113
100
|
return delivered;
|
|
114
101
|
}
|
|
115
102
|
/**
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
103
|
+
* One long-poll pass: hold up to `waitSeconds` for new inbox content, then read
|
|
104
|
+
* the deltas, deliver them into the host, and ack. Returns what it delivered.
|
|
105
|
+
* Reused each iteration of {@link runInboxLoop}; also usable as a single catch-up
|
|
106
|
+
* read (`waitSeconds: 0`) at bootstrap.
|
|
119
107
|
*/
|
|
120
|
-
export async function
|
|
121
|
-
const label = deps.label ?? '
|
|
108
|
+
export async function pollInboxOnce(deps, waitSeconds = 0) {
|
|
109
|
+
const label = deps.label ?? 'inboxLoop';
|
|
122
110
|
const inbox = new InboxClient(deps.creds.operatorKey, deps.creds.agentId, deps.baseUrl);
|
|
123
111
|
const reader = new ContextReadClient(deps.creds.operatorKey, deps.creds.agentId, deps.baseUrl);
|
|
124
112
|
const grants = new GrantsClient(deps.creds.operatorKey, deps.creds.agentId, deps.baseUrl);
|
|
125
|
-
const envelope = await inbox.getInbox();
|
|
113
|
+
const envelope = await inbox.getInbox(waitSeconds > 0 ? { waitSeconds } : {});
|
|
126
114
|
const countCap = envelope.countCap;
|
|
127
115
|
let messagesDelivered = 0;
|
|
128
116
|
let resourceEvents = 0;
|
|
129
117
|
const toAck = [];
|
|
130
118
|
if (envelope.truncatedScopes > 0) {
|
|
131
|
-
// Informational only — does NOT block acking the scopes
|
|
132
|
-
//
|
|
133
|
-
//
|
|
134
|
-
//
|
|
135
|
-
//
|
|
136
|
-
// acking here meant a busy long-lived agent (reach > cap) would never ack
|
|
137
|
-
// anything and re-deliver its entire backlog on every reconnect forever.
|
|
119
|
+
// Informational only — does NOT block acking the scopes returned below.
|
|
120
|
+
// truncatedScopes just means the caller's total reach exceeded the server's
|
|
121
|
+
// per-call scope cap; each returned scope is still an independently accurate,
|
|
122
|
+
// safe-to-ack unit of news. Never acking here would make a busy agent
|
|
123
|
+
// (reach > cap) re-deliver its whole backlog every pass forever.
|
|
138
124
|
warn(label, `inbox truncatedScopes=${envelope.truncatedScopes} (reach exceeds per-call cap — examined scopes are still acked normally)`);
|
|
139
125
|
}
|
|
140
126
|
for (const scope of envelope.scopes) {
|
|
141
|
-
|
|
142
|
-
// re-deliver an already-finished agreement's backlog, re-running a completed
|
|
143
|
-
// pipeline and burning tokens. If this is an agreement scope that has reached
|
|
144
|
-
// a terminal status, ack it (so it drops out of future catch-ups) and skip
|
|
145
|
-
// re-delivery. Only agreement scopes — chat scopes may carry genuinely new
|
|
146
|
-
// conversation, so those are never skipped on status. (The deeper fix, acking
|
|
147
|
-
// the watermark on the live-push path so nothing re-delivers, is tracked
|
|
148
|
-
// separately.)
|
|
149
|
-
if (scope.scope.kind === 'agreement' &&
|
|
150
|
-
(scope.newMessages > 0 || scope.newArtifacts > 0) &&
|
|
151
|
-
(await isTerminalAgreement(scope.scope.id, deps.creds))) {
|
|
152
|
-
if (scope.latestAt) {
|
|
153
|
-
toAck.push({ kind: scope.scope.kind, id: scope.scope.id, upTo: scope.latestAt });
|
|
154
|
-
}
|
|
155
|
-
log(label, `scope agreement:${scope.scope.id} terminal — acked, not re-delivered`);
|
|
156
|
-
continue;
|
|
157
|
-
}
|
|
158
|
-
const chatIds = await chatIdsForScope(scope, deps.creds, deps.baseUrl, grants);
|
|
127
|
+
const chatIds = await chatIdsForScope(scope, deps.creds, grants);
|
|
159
128
|
if (!chatIds.length && (scope.newMessages > 0 || scope.newArtifacts > 0)) {
|
|
160
129
|
warn(label, `no chat ids for scope ${scope.scope.kind}:${scope.scope.id} (${scope.newMessages} msgs)`);
|
|
161
130
|
}
|
|
@@ -201,9 +170,6 @@ export async function runInboxCatchUp(deps) {
|
|
|
201
170
|
acked = toAck.length;
|
|
202
171
|
log(label, `acked ${acked} scope(s)`);
|
|
203
172
|
}
|
|
204
|
-
if (messagesDelivered > 0 || resourceEvents > 0) {
|
|
205
|
-
log(label, `catch-up complete: ${messagesDelivered} message(s), ${resourceEvents} resource hint(s)`);
|
|
206
|
-
}
|
|
207
173
|
return {
|
|
208
174
|
scopes: envelope.scopes.length,
|
|
209
175
|
messagesDelivered,
|
|
@@ -212,3 +178,48 @@ export async function runInboxCatchUp(deps) {
|
|
|
212
178
|
truncated: envelope.truncatedScopes > 0,
|
|
213
179
|
};
|
|
214
180
|
}
|
|
181
|
+
const DEFAULT_WAIT_SECONDS = 25;
|
|
182
|
+
const ERROR_BACKOFF_BASE_MS = 1_000;
|
|
183
|
+
const ERROR_BACKOFF_MAX_MS = 30_000;
|
|
184
|
+
function sleep(ms) {
|
|
185
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Run {@link pollInboxOnce} forever (until stopped). Each iteration long-polls
|
|
189
|
+
* for `waitSeconds`; a passing envelope returns instantly, an empty one holds.
|
|
190
|
+
* Errors retry with exponential backoff + jitter and never exit the loop —
|
|
191
|
+
* there is nothing to resynchronize (the cursor is server-side), so recovery is
|
|
192
|
+
* just the next iteration.
|
|
193
|
+
*/
|
|
194
|
+
export function runInboxLoop(deps, opts = {}) {
|
|
195
|
+
const label = deps.label ?? 'inboxLoop';
|
|
196
|
+
const waitSeconds = opts.waitSeconds ?? DEFAULT_WAIT_SECONDS;
|
|
197
|
+
let running = true;
|
|
198
|
+
let consecutiveErrors = 0;
|
|
199
|
+
const done = (async () => {
|
|
200
|
+
while (running) {
|
|
201
|
+
try {
|
|
202
|
+
await pollInboxOnce(deps, waitSeconds);
|
|
203
|
+
consecutiveErrors = 0;
|
|
204
|
+
}
|
|
205
|
+
catch (err) {
|
|
206
|
+
if (!running)
|
|
207
|
+
break;
|
|
208
|
+
consecutiveErrors++;
|
|
209
|
+
const backoff = Math.min(ERROR_BACKOFF_MAX_MS, ERROR_BACKOFF_BASE_MS * 2 ** (consecutiveErrors - 1));
|
|
210
|
+
const jitter = backoff * (0.5 + (consecutiveErrors % 7) / 14); // deterministic-ish, no Math.random
|
|
211
|
+
warn(label, `inbox loop pass failed (#${consecutiveErrors}): ${err?.message ?? err} — retrying in ~${Math.round(jitter)}ms`);
|
|
212
|
+
await sleep(jitter);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
})();
|
|
216
|
+
return {
|
|
217
|
+
get running() {
|
|
218
|
+
return running;
|
|
219
|
+
},
|
|
220
|
+
async stop() {
|
|
221
|
+
running = false;
|
|
222
|
+
await done.catch(() => { });
|
|
223
|
+
},
|
|
224
|
+
};
|
|
225
|
+
}
|
|
@@ -53,7 +53,7 @@ async function runFleet(configs, { operatorKey, wsUrl = process.env.WS_URL, port
|
|
|
53
53
|
pool.startControl({ wsUrl, operatorKey: controlKey });
|
|
54
54
|
}
|
|
55
55
|
else {
|
|
56
|
-
runtimeLog.warn(label, 'No operatorKey provided — skipping
|
|
56
|
+
runtimeLog.warn(label, 'No operatorKey provided — skipping fleet inbox poll (no agents will be lazily started)');
|
|
57
57
|
}
|
|
58
58
|
let primary = null;
|
|
59
59
|
if (resolvedPrimaryConfig) {
|
|
@@ -15,8 +15,9 @@ export declare class TaskService {
|
|
|
15
15
|
requireMidWorkPlanAck?: boolean;
|
|
16
16
|
assigneeId?: string;
|
|
17
17
|
inputArtifactIds?: string[];
|
|
18
|
+
idempotencyKey?: string;
|
|
18
19
|
}): Promise<Task>;
|
|
19
|
-
updateState(taskId: string, state: TaskState, result?: unknown): Promise<Task>;
|
|
20
|
+
updateState(taskId: string, state: TaskState, result?: unknown, idempotencyKey?: string): Promise<Task>;
|
|
20
21
|
updatePlanStep(taskId: string, stepId: string, status: string, result?: unknown): Promise<Task>;
|
|
21
22
|
cancel(taskId: string): Promise<Task>;
|
|
22
23
|
report(taskId: string, message?: string): Promise<Task>;
|
|
@@ -57,9 +57,12 @@ export class TaskService {
|
|
|
57
57
|
throw error;
|
|
58
58
|
}
|
|
59
59
|
}
|
|
60
|
-
async updateState(taskId, state, result) {
|
|
60
|
+
async updateState(taskId, state, result, idempotencyKey) {
|
|
61
61
|
try {
|
|
62
|
-
const data =
|
|
62
|
+
const data = {
|
|
63
|
+
...(result !== undefined ? { result: safeJsonStringify(result) } : {}),
|
|
64
|
+
...(idempotencyKey ? { idempotencyKey } : {}),
|
|
65
|
+
};
|
|
63
66
|
const updated = await updateTaskState(taskId, state, data, this.creds);
|
|
64
67
|
runtimeLog.info('TaskService', `Task state: ${taskId} → ${state}`);
|
|
65
68
|
return updated;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { AgreementService } from '../../agreements/AgreementService.js';
|
|
2
2
|
import type { TaskService } from '../../tasks/TaskService.js';
|
|
3
|
+
import type { WakeWriteKeys } from '../../../types.js';
|
|
3
4
|
import type { ContextReadClient } from '@ziggs-ai/api-client';
|
|
4
5
|
interface Services {
|
|
5
6
|
agreementService: AgreementService;
|
|
@@ -35,5 +36,5 @@ interface ProtocolPayload {
|
|
|
35
36
|
})[];
|
|
36
37
|
[key: string]: unknown;
|
|
37
38
|
}
|
|
38
|
-
export declare function dispatchProtocolOp(services: Services, payload: ProtocolPayload, chatId: string | null, agents?: unknown[], actingAgentId?: string | null): Promise<unknown>;
|
|
39
|
+
export declare function dispatchProtocolOp(services: Services, payload: ProtocolPayload, chatId: string | null, agents?: unknown[], actingAgentId?: string | null, wakeKeys?: WakeWriteKeys | null): Promise<unknown>;
|
|
39
40
|
export {};
|
|
@@ -34,7 +34,7 @@ function normalizePlanInPlace(payload) {
|
|
|
34
34
|
payload.planReviewTiming = 'with_proposal';
|
|
35
35
|
}
|
|
36
36
|
}
|
|
37
|
-
export async function dispatchProtocolOp(services, payload, chatId, agents = [], actingAgentId = null) {
|
|
37
|
+
export async function dispatchProtocolOp(services, payload, chatId, agents = [], actingAgentId = null, wakeKeys = null) {
|
|
38
38
|
if (!payload?.operation)
|
|
39
39
|
return null;
|
|
40
40
|
normalizePlanInPlace(payload);
|
|
@@ -43,9 +43,9 @@ export async function dispatchProtocolOp(services, payload, chatId, agents = [],
|
|
|
43
43
|
const actorId = actingAgentId?.trim() || null;
|
|
44
44
|
switch (operation) {
|
|
45
45
|
case 'agreement-propose':
|
|
46
|
-
return proposeAgreement(services, payload, chatId, agents, actorId);
|
|
46
|
+
return proposeAgreement(services, payload, chatId, agents, actorId, wakeKeys);
|
|
47
47
|
case 'agreement-subcontract':
|
|
48
|
-
return subcontractAgreement(services, payload, chatId, agents, actorId);
|
|
48
|
+
return subcontractAgreement(services, payload, chatId, agents, actorId, wakeKeys);
|
|
49
49
|
case 'agreement-respond':
|
|
50
50
|
if (!payload.agreementId || !payload.action)
|
|
51
51
|
return null;
|
|
@@ -146,12 +146,27 @@ export async function dispatchProtocolOp(services, payload, chatId, agents = [],
|
|
|
146
146
|
inputArtifactIds: Array.isArray(payload.inputArtifactIds)
|
|
147
147
|
? payload.inputArtifactIds
|
|
148
148
|
: undefined,
|
|
149
|
+
// Wake-derived idempotency key (delivery law): a crash-replay re-issuing
|
|
150
|
+
// this spawn resolves to the already-created task server-side instead of
|
|
151
|
+
// creating a duplicate. Keyed on what is actually written (the resolved
|
|
152
|
+
// agreement, not the LLM-cited one), so multiple spawns in one wake stay
|
|
153
|
+
// distinct.
|
|
154
|
+
idempotencyKey: wakeKeys?.taskSpawnKey({
|
|
155
|
+
agreementId: spawnAgreementId,
|
|
156
|
+
description: payload.description,
|
|
157
|
+
assigneeId: payload.assigneeId,
|
|
158
|
+
parentTaskId: payload.parentTaskId,
|
|
159
|
+
}),
|
|
149
160
|
});
|
|
150
161
|
}
|
|
151
|
-
case 'task-update':
|
|
162
|
+
case 'task-update': {
|
|
152
163
|
if (!payload.taskId || !payload.status)
|
|
153
164
|
return null;
|
|
154
|
-
|
|
165
|
+
const state = normalizeState(payload.status);
|
|
166
|
+
// Wake-derived idempotency key: a crash-replay re-issuing this transition
|
|
167
|
+
// no-ops server-side instead of erroring on the already-terminal task.
|
|
168
|
+
return taskService.updateState(payload.taskId, state, payload.result, wakeKeys?.taskStateKey(payload.taskId, state));
|
|
169
|
+
}
|
|
155
170
|
case 'task-update-plan-step':
|
|
156
171
|
if (!payload.taskId)
|
|
157
172
|
throw new Error('task-update-plan-step requires `taskId`');
|
|
@@ -173,7 +188,7 @@ export async function dispatchProtocolOp(services, payload, chatId, agents = [],
|
|
|
173
188
|
throw new Error(`Unknown protocol operation: ${operation}`);
|
|
174
189
|
}
|
|
175
190
|
}
|
|
176
|
-
async function proposeAgreement(services, payload, chatId, _agents, actingAgentId) {
|
|
191
|
+
async function proposeAgreement(services, payload, chatId, _agents, actingAgentId, wakeKeys = null) {
|
|
177
192
|
if (!payload.description)
|
|
178
193
|
throw new Error('agreement-propose requires `description`');
|
|
179
194
|
if (!payload.proposedTo)
|
|
@@ -187,7 +202,17 @@ async function proposeAgreement(services, payload, chatId, _agents, actingAgentI
|
|
|
187
202
|
}
|
|
188
203
|
const priced = applyFleetPricingForProtocol('agreement-propose', payload, actingAgentId);
|
|
189
204
|
const { operation: _op, ...fields } = priced;
|
|
190
|
-
|
|
205
|
+
// Wake-derived idempotency key (delivery law): a crash-replay re-issuing this
|
|
206
|
+
// propose resolves to the already-created agreement server-side instead of
|
|
207
|
+
// minting a duplicate proposal. Keyed on what the proposal writes, so
|
|
208
|
+
// multiple proposals in one wake stay distinct.
|
|
209
|
+
const idempotencyKey = wakeKeys?.agreementProposeKey({
|
|
210
|
+
proposedTo: payload.proposedTo,
|
|
211
|
+
providerId: payload.providerId,
|
|
212
|
+
description: payload.description,
|
|
213
|
+
parentAgreementId: payload.parentAgreementId,
|
|
214
|
+
});
|
|
215
|
+
return services.agreementService.propose({ ...fields, chatId, idempotencyKey });
|
|
191
216
|
}
|
|
192
217
|
// ── ZIG-832: deterministic sub-agreement resolution ─────────────────────────
|
|
193
218
|
// The orchestrator delegates one task per worker, but the LLM inconsistently
|
|
@@ -228,7 +253,7 @@ function agActive(a) {
|
|
|
228
253
|
function agIsActiveStatus(a) {
|
|
229
254
|
return String(a.status ?? '') === 'active';
|
|
230
255
|
}
|
|
231
|
-
async function subcontractAgreement(services, payload, chatId, _agents, actingAgentId) {
|
|
256
|
+
async function subcontractAgreement(services, payload, chatId, _agents, actingAgentId, wakeKeys = null) {
|
|
232
257
|
if (!payload.description || !payload.executorId)
|
|
233
258
|
return null;
|
|
234
259
|
// b1 (ZIG-832): idempotent subcontract — if a non-terminal sub-agreement for
|
|
@@ -284,5 +309,14 @@ async function subcontractAgreement(services, payload, chatId, _agents, actingAg
|
|
|
284
309
|
f.lifecycle = 'count-bound';
|
|
285
310
|
f.maxExecutions = 1;
|
|
286
311
|
}
|
|
287
|
-
|
|
312
|
+
// Wake-derived idempotency key (delivery law): a crash-replay that slips past
|
|
313
|
+
// the b1 reuse check above (e.g. no chatId, or the sub not yet visible in the
|
|
314
|
+
// snapshot) resolves to the already-created sub-agreement server-side instead
|
|
315
|
+
// of minting a duplicate.
|
|
316
|
+
const idempotencyKey = wakeKeys?.agreementSubcontractKey({
|
|
317
|
+
executorId: payload.executorId,
|
|
318
|
+
description: payload.description,
|
|
319
|
+
parentAgreementId: payload.parentAgreementId ?? '',
|
|
320
|
+
});
|
|
321
|
+
return services.agreementService.delegate({ ...fields, chatId, idempotencyKey });
|
|
288
322
|
}
|
|
@@ -13,7 +13,7 @@ function handlerFor(operation) {
|
|
|
13
13
|
taskService: c.taskService,
|
|
14
14
|
agreementService: c.agreementService,
|
|
15
15
|
contextReadClient: c.contextReadClient,
|
|
16
|
-
}, { operation, ...args }, c.chatId ?? null, c.agents ?? [], typeof c.agentId === 'string' ? c.agentId : null);
|
|
16
|
+
}, { operation, ...args }, c.chatId ?? null, c.agents ?? [], typeof c.agentId === 'string' ? c.agentId : null, c.wakeKeys ?? null);
|
|
17
17
|
if (result == null)
|
|
18
18
|
throw new Error(`Operation ${operation} returned no result`);
|
|
19
19
|
return result;
|
package/dist/types.d.ts
CHANGED
|
@@ -346,6 +346,43 @@ export interface RecordedEntry {
|
|
|
346
346
|
text?: string;
|
|
347
347
|
[k: string]: unknown;
|
|
348
348
|
}
|
|
349
|
+
/**
|
|
350
|
+
* Deterministic idempotency keys for one wake's writes (delivery law, item 2).
|
|
351
|
+
* Created once per tick from (laneKey, triggering event) — see
|
|
352
|
+
* `createWakeWriteKeys` — and threaded to every artifact / task-state write the
|
|
353
|
+
* wake performs, so a crash-replay of the same wake reproduces the same keys
|
|
354
|
+
* and the backend drops the duplicates. Never LLM-supplied, never random.
|
|
355
|
+
*/
|
|
356
|
+
export interface WakeWriteKeys {
|
|
357
|
+
/** Stable id of this wake, or null when its trigger has no durable delivery identity (timer/synthetic frames) — null means writes go keyless, as before. */
|
|
358
|
+
readonly wakeKey: string | null;
|
|
359
|
+
/** Key for the next artifact write of this wake. Draws a per-wake ordinal — call exactly once per write, at the call site. */
|
|
360
|
+
artifactKey(): string | undefined;
|
|
361
|
+
/** Key for a task state transition. Derived from (taskId, state), so it is stable regardless of tool-call order within the wake. */
|
|
362
|
+
taskStateKey(taskId: string, state: string): string | undefined;
|
|
363
|
+
/** Default messageId for the next chat-message send of this wake — the backend dedups on (chatId, messageId). Draws the same per-wake ordinal as artifactKey — call exactly once per send, at the call site. */
|
|
364
|
+
messageKey(): string | undefined;
|
|
365
|
+
/** Key for a task spawn. Derived from what the spawn writes (agreement, description, assignee, parent), so it is stable regardless of tool-call order within the wake. */
|
|
366
|
+
taskSpawnKey(fields: {
|
|
367
|
+
agreementId: string;
|
|
368
|
+
description: string;
|
|
369
|
+
assigneeId?: string;
|
|
370
|
+
parentTaskId?: string;
|
|
371
|
+
}): string | undefined;
|
|
372
|
+
/** Key for an agreement proposal (`agreement_propose`). Derived from what the proposal writes (proposedTo, provider, description, parent), so a crash-replay dedups the row instead of minting a duplicate proposal — and multiple proposals in one wake stay distinct. */
|
|
373
|
+
agreementProposeKey(fields: {
|
|
374
|
+
proposedTo: string;
|
|
375
|
+
providerId?: string;
|
|
376
|
+
description: string;
|
|
377
|
+
parentAgreementId?: string;
|
|
378
|
+
}): string | undefined;
|
|
379
|
+
/** Key for a sub-agreement (`agreement_subcontract`). Derived from what the subcontract writes (executor, description, parent), so a replay dedups the row. */
|
|
380
|
+
agreementSubcontractKey(fields: {
|
|
381
|
+
executorId: string;
|
|
382
|
+
description: string;
|
|
383
|
+
parentAgreementId: string;
|
|
384
|
+
}): string | undefined;
|
|
385
|
+
}
|
|
349
386
|
import type { MemoryStore } from './memory/MemoryStore.js';
|
|
350
387
|
export type EffectKind = 'read-context' | 'llm-call' | 'stream-text' | 'tool-call' | 'send-message' | 'record-event';
|
|
351
388
|
export type Effect = {
|
|
@@ -388,6 +425,8 @@ export type Effect = {
|
|
|
388
425
|
runId?: string;
|
|
389
426
|
/** FSM state name (ZIG-440). */
|
|
390
427
|
stateId?: string;
|
|
428
|
+
/** Replay-stable write keys for this wake — write-capable tools derive their idempotency key from here, never from the LLM. */
|
|
429
|
+
wakeKeys?: WakeWriteKeys;
|
|
391
430
|
} | {
|
|
392
431
|
kind: 'send-message';
|
|
393
432
|
sessionId: string;
|
|
@@ -398,6 +437,7 @@ export type Effect = {
|
|
|
398
437
|
kind: 'record-event';
|
|
399
438
|
sessionId: string;
|
|
400
439
|
entry: RecordedEntry;
|
|
440
|
+
idempotencyKey?: string;
|
|
401
441
|
};
|
|
402
442
|
/**
|
|
403
443
|
* Typed result map. With this shape, `await eff({ kind: 'read-context', ... })`
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ziggs-ai/agent-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.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.2.0",
|
|
30
30
|
"ajv": "^8.17.1",
|
|
31
31
|
"dotenv": "^17.2.3",
|
|
32
32
|
"openai": "^4.0.0",
|
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
import { type Creds } from '@ziggs-ai/api-client';
|
|
2
|
-
import { SeenMessages } from './SeenMessages.js';
|
|
3
|
-
export interface InboxCatchUpDeps {
|
|
4
|
-
creds: Creds;
|
|
5
|
-
ownAgentId: string;
|
|
6
|
-
baseUrl?: string;
|
|
7
|
-
seen: SeenMessages;
|
|
8
|
-
onMessage: (text: string, metadata: Record<string, unknown>) => Promise<void>;
|
|
9
|
-
onResourceEvent: (event: Record<string, unknown>) => Promise<void>;
|
|
10
|
-
label?: string;
|
|
11
|
-
}
|
|
12
|
-
export interface InboxCatchUpResult {
|
|
13
|
-
scopes: number;
|
|
14
|
-
messagesDelivered: number;
|
|
15
|
-
resourceEvents: number;
|
|
16
|
-
acked: number;
|
|
17
|
-
truncated: boolean;
|
|
18
|
-
}
|
|
19
|
-
/**
|
|
20
|
-
* Pull-based recovery after reconnect (ZIG-434 / ZIG-446 / ZIG-454).
|
|
21
|
-
* Inbox → read message deltas → deliver → ack. Push remains a hint;
|
|
22
|
-
* this path survives backend restarts that drop in-memory pending queues.
|
|
23
|
-
*/
|
|
24
|
-
export declare function runInboxCatchUp(deps: InboxCatchUpDeps): Promise<InboxCatchUpResult>;
|