@ziggs-ai/agent-sdk 0.7.0 → 0.7.2

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.
@@ -65,7 +65,15 @@ export async function runTurn(input) {
65
65
  return eff({ ...effect, runId, stateId, wakeKeys });
66
66
  }
67
67
  if (effect.kind === 'send-message' && !effect.messageId) {
68
- return eff({ ...effect, messageId: wakeKeys?.messageKey() });
68
+ const send = effect;
69
+ return eff({
70
+ ...effect,
71
+ messageId: wakeKeys?.messageKey({
72
+ text: send.text,
73
+ sessionId: send.sessionId,
74
+ receiverId: send.receiverId,
75
+ }),
76
+ });
69
77
  }
70
78
  return eff(effect);
71
79
  };
@@ -316,11 +324,12 @@ export async function runTurn(input) {
316
324
  const inboundFromAgent = String(incomingEvent?.senderType || '').toUpperCase() === 'AGENT';
317
325
  runtimeLog.info('runTurn', `streaming check: enabled=${streamRespondActions} receiverId=${decision.receiverId ?? 'none'} tool=${actionDef?.tool ?? 'none'} action=${chosenAction} inboundFromAgent=${inboundFromAgent}`);
318
326
  if (streamRespondActions && !inboundFromAgent && decision.receiverId && !actionDef?.tool && chosenAction !== 'wait') {
319
- // Wake-derived when the wake has a durable identity: a crash-replay
320
- // streams and persists under the SAME id, so the backend's
321
- // (chatId, messageId) dedup drops the duplicate reply (ZIG-718).
322
- const streamMessageId = wakeKeys?.messageKey() ?? crypto.randomUUID();
323
- runtimeLog.info('runTurn', `stream-text start action=${chosenAction} receiverId=${decision.receiverId} msgId=${streamMessageId}`);
327
+ // Live chunks use a provisional id (text not known yet). The persisted
328
+ // send below switches to a content-derived wake key (ZIG-1045) once the
329
+ // reply text exists — a crash-replay of the same text reuses that key
330
+ // (ZIG-718), and a re-think with different text does not collide.
331
+ const chunkMessageId = crypto.randomUUID();
332
+ runtimeLog.info('runTurn', `stream-text start action=${chosenAction} receiverId=${decision.receiverId} msgId=${chunkMessageId}`);
324
333
  const streamMessages = [
325
334
  ...messages,
326
335
  {
@@ -334,7 +343,7 @@ export async function runTurn(input) {
334
343
  ];
335
344
  let replyText = '';
336
345
  try {
337
- const streamResult = await eff({ kind: 'stream-text', sessionId, receiverId: decision.receiverId, messageId: streamMessageId, messages: streamMessages });
346
+ const streamResult = await eff({ kind: 'stream-text', sessionId, receiverId: decision.receiverId, messageId: chunkMessageId, messages: streamMessages });
338
347
  replyText = streamResult.text ?? '';
339
348
  // Count the streamed reply only when it actually produced the text we
340
349
  // send (an empty resolve falls through to the llm-call fallback below,
@@ -356,6 +365,7 @@ export async function runTurn(input) {
356
365
  // llm-call + send-message below so the user still gets a reply.
357
366
  runtimeLog.error('runTurn', `stream-text failed: ${err.message} — falling back to non-streaming send`);
358
367
  }
368
+ let persistMessageId = chunkMessageId;
359
369
  try {
360
370
  if (!replyText) {
361
371
  const fallbackResponse = await effWithTrace({ kind: 'llm-call', messages: streamMessages, tools: [], sessionId });
@@ -369,16 +379,20 @@ export async function runTurn(input) {
369
379
  throw new Error('fallback llm-call returned empty reply text');
370
380
  }
371
381
  const safeText = truncateMsg(replyText);
372
- // Reuse streamMessageId so any partial chunks already streamed to
373
- // the client reconcile against the final persisted message.
374
- await eff({ kind: 'send-message', sessionId, receiverId: decision.receiverId, text: safeText, messageId: streamMessageId });
382
+ persistMessageId =
383
+ wakeKeys?.messageKey({
384
+ text: safeText,
385
+ sessionId,
386
+ receiverId: decision.receiverId,
387
+ }) ?? chunkMessageId;
388
+ await eff({ kind: 'send-message', sessionId, receiverId: decision.receiverId, text: safeText, messageId: persistMessageId });
375
389
  allEmittedEvents.push({ type: 'message_sent', receiverId: decision.receiverId, chatId: sessionId, message: safeText, senderId: 'system' });
376
390
  lastAction = chosenAction;
377
391
  lastActionDef = actionDef;
378
392
  }
379
393
  catch (err) {
380
394
  if (isReplySlotTaken(err)) {
381
- runtimeLog.warn('runTurn', `reply already persisted under ${streamMessageId} (idempotency 409) — a replay or second host answered this wake first; dropping duplicate`);
395
+ runtimeLog.warn('runTurn', `reply already persisted under ${persistMessageId} (idempotency 409) — a replay or second host answered this wake first; dropping duplicate`);
382
396
  allEmittedEvents.push({ type: 'message_duplicate_skipped', receiverId: decision.receiverId, chatId: sessionId, senderId: 'system' });
383
397
  lastAction = chosenAction;
384
398
  lastActionDef = actionDef;
@@ -0,0 +1,35 @@
1
+ import type { Ctx, TaskBookkeeping } from '../types.js';
2
+ /**
3
+ * Reserved key for coordinator-side pending/collected state when the lane has
4
+ * an active agreement but no assigned parent task yet (kickoff → spawn).
5
+ * Never deleted by task-closed of a real taskId (ZIG-977).
6
+ */
7
+ export declare const LANE_BOOK_KEY = "__lane__";
8
+ export declare function emptyTaskBook(agreementId?: string | null): TaskBookkeeping;
9
+ export declare function ensureTaskBook(ctx: Ctx, taskId: string, agreementId?: string | null): TaskBookkeeping;
10
+ /** Focus task's book, or null when nothing is focused / missing. */
11
+ export declare function focusTaskBook(ctx: Ctx): TaskBookkeeping | null;
12
+ /**
13
+ * Book that receives task-delegated / subtask-finished when we know the parent
14
+ * engagement: focus task if assigned, else the coordinator lane bucket.
15
+ */
16
+ export declare function bookForDelegation(ctx: Ctx): TaskBookkeeping;
17
+ /** Find which engagement is awaiting this child taskId. */
18
+ export declare function findBookHoldingPending(ctx: Ctx, childTaskId: string): TaskBookkeeping | null;
19
+ export declare function allCollectedResults(ctx: Ctx): TaskBookkeeping['collectedResults'];
20
+ export declare function allPendingSubtasks(ctx: Ctx): Record<string, TaskBookkeeping['pendingSubtasks'][string]>;
21
+ export declare function allDelegatedAgreementIds(ctx: Ctx): string[];
22
+ export declare function hasOpenTaskForAgreement(ctx: Ctx, agreementId: string): boolean;
23
+ export declare function hasDelegatedAgreement(ctx: Ctx, agreementId: string): boolean;
24
+ /**
25
+ * Remove one task's bookkeeping and retarget focus if we just closed the
26
+ * focused task. Leaves sibling entries (and the coordinator lane bucket) alone.
27
+ */
28
+ export declare function removeTaskBook(ctx: Ctx, taskId: string): void;
29
+ /** Refresh deprecated aggregate views from the per-task map. */
30
+ export declare function syncAggregateTaskViews(ctx: Ctx): void;
31
+ /**
32
+ * Migrate a pre-ZIG-977 persisted ctx (lane-level pendingSubtasks /
33
+ * collectedResults / delegatedAgreementIds) into the tasks map.
34
+ */
35
+ export declare function migrateLegacyTaskFields(existing: Record<string, unknown>): Record<string, TaskBookkeeping>;
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Reserved key for coordinator-side pending/collected state when the lane has
3
+ * an active agreement but no assigned parent task yet (kickoff → spawn).
4
+ * Never deleted by task-closed of a real taskId (ZIG-977).
5
+ */
6
+ export const LANE_BOOK_KEY = '__lane__';
7
+ export function emptyTaskBook(agreementId = null) {
8
+ return {
9
+ agreementId,
10
+ pendingSubtasks: {},
11
+ collectedResults: [],
12
+ delegatedAgreementIds: [],
13
+ };
14
+ }
15
+ export function ensureTaskBook(ctx, taskId, agreementId) {
16
+ if (!ctx.tasks)
17
+ ctx.tasks = {};
18
+ let book = ctx.tasks[taskId];
19
+ if (!book) {
20
+ book = emptyTaskBook(agreementId ?? null);
21
+ ctx.tasks[taskId] = book;
22
+ }
23
+ else if (agreementId && !book.agreementId) {
24
+ book.agreementId = agreementId;
25
+ }
26
+ return book;
27
+ }
28
+ /** Focus task's book, or null when nothing is focused / missing. */
29
+ export function focusTaskBook(ctx) {
30
+ if (!ctx.activeTaskId)
31
+ return null;
32
+ return ctx.tasks?.[ctx.activeTaskId] ?? null;
33
+ }
34
+ /**
35
+ * Book that receives task-delegated / subtask-finished when we know the parent
36
+ * engagement: focus task if assigned, else the coordinator lane bucket.
37
+ */
38
+ export function bookForDelegation(ctx) {
39
+ if (ctx.activeTaskId) {
40
+ return ensureTaskBook(ctx, ctx.activeTaskId, ctx.activeAgreementId);
41
+ }
42
+ const book = ensureTaskBook(ctx, LANE_BOOK_KEY, ctx.activeAgreementId);
43
+ if (ctx.activeAgreementId && !book.agreementId)
44
+ book.agreementId = ctx.activeAgreementId;
45
+ return book;
46
+ }
47
+ /** Find which engagement is awaiting this child taskId. */
48
+ export function findBookHoldingPending(ctx, childTaskId) {
49
+ if (!ctx.tasks)
50
+ return null;
51
+ for (const book of Object.values(ctx.tasks)) {
52
+ if (childTaskId in book.pendingSubtasks)
53
+ return book;
54
+ }
55
+ return null;
56
+ }
57
+ export function allCollectedResults(ctx) {
58
+ if (!ctx.tasks)
59
+ return [];
60
+ const out = [];
61
+ for (const book of Object.values(ctx.tasks)) {
62
+ out.push(...book.collectedResults);
63
+ }
64
+ return out;
65
+ }
66
+ export function allPendingSubtasks(ctx) {
67
+ if (!ctx.tasks)
68
+ return {};
69
+ const out = {};
70
+ for (const book of Object.values(ctx.tasks)) {
71
+ Object.assign(out, book.pendingSubtasks);
72
+ }
73
+ return out;
74
+ }
75
+ export function allDelegatedAgreementIds(ctx) {
76
+ if (!ctx.tasks)
77
+ return [];
78
+ const ids = [];
79
+ for (const book of Object.values(ctx.tasks)) {
80
+ for (const id of book.delegatedAgreementIds) {
81
+ if (!ids.includes(id))
82
+ ids.push(id);
83
+ }
84
+ }
85
+ return ids;
86
+ }
87
+ export function hasOpenTaskForAgreement(ctx, agreementId) {
88
+ if (!agreementId || !ctx.tasks)
89
+ return false;
90
+ return Object.entries(ctx.tasks).some(([key, book]) => key !== LANE_BOOK_KEY && book.agreementId === agreementId);
91
+ }
92
+ export function hasDelegatedAgreement(ctx, agreementId) {
93
+ return allDelegatedAgreementIds(ctx).includes(agreementId);
94
+ }
95
+ /**
96
+ * Remove one task's bookkeeping and retarget focus if we just closed the
97
+ * focused task. Leaves sibling entries (and the coordinator lane bucket) alone.
98
+ */
99
+ export function removeTaskBook(ctx, taskId) {
100
+ if (!taskId || !ctx.tasks)
101
+ return;
102
+ delete ctx.tasks[taskId];
103
+ if (ctx.activeTaskId === taskId) {
104
+ const nextId = Object.keys(ctx.tasks).find((id) => id !== LANE_BOOK_KEY) ?? null;
105
+ ctx.activeTaskId = nextId;
106
+ ctx.activeAgreementId = nextId ? (ctx.tasks[nextId]?.agreementId ?? null) : null;
107
+ }
108
+ }
109
+ /** Refresh deprecated aggregate views from the per-task map. */
110
+ export function syncAggregateTaskViews(ctx) {
111
+ ctx.pendingSubtasks = allPendingSubtasks(ctx);
112
+ ctx.collectedResults = allCollectedResults(ctx);
113
+ ctx.delegatedAgreementIds = allDelegatedAgreementIds(ctx);
114
+ }
115
+ /**
116
+ * Migrate a pre-ZIG-977 persisted ctx (lane-level pendingSubtasks /
117
+ * collectedResults / delegatedAgreementIds) into the tasks map.
118
+ */
119
+ export function migrateLegacyTaskFields(existing) {
120
+ if (existing.tasks && typeof existing.tasks === 'object') {
121
+ return existing.tasks;
122
+ }
123
+ const tasks = {};
124
+ const activeTaskId = existing.activeTaskId ?? null;
125
+ const activeAgreementId = existing.activeAgreementId ?? null;
126
+ const pendingSubtasks = existing.pendingSubtasks ?? {};
127
+ const collectedResults = existing.collectedResults ?? [];
128
+ const delegatedAgreementIds = existing.delegatedAgreementIds ?? [];
129
+ const hasLegacy = Object.keys(pendingSubtasks).length > 0 ||
130
+ collectedResults.length > 0 ||
131
+ delegatedAgreementIds.length > 0;
132
+ if (activeTaskId) {
133
+ tasks[activeTaskId] = {
134
+ agreementId: activeAgreementId,
135
+ pendingSubtasks,
136
+ collectedResults,
137
+ delegatedAgreementIds: [...delegatedAgreementIds],
138
+ };
139
+ }
140
+ else if (hasLegacy) {
141
+ tasks[LANE_BOOK_KEY] = {
142
+ agreementId: activeAgreementId,
143
+ pendingSubtasks,
144
+ collectedResults,
145
+ delegatedAgreementIds: [...delegatedAgreementIds],
146
+ };
147
+ }
148
+ return tasks;
149
+ }
@@ -35,7 +35,7 @@ export function buildTaskIntakeState(initial) {
35
35
  prompt: {
36
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
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_set_result 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_set_result 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.',
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_set_result with state "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_set_result with state "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
39
  },
40
40
  actions: {
41
41
  ...dflt.actions,
@@ -43,7 +43,7 @@ export function buildTaskIntakeState(initial) {
43
43
  prompt: {
44
44
  when: 'You can produce the deliverable for the assigned task in this turn.',
45
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>"}',
46
+ format: '{"taskId": "<assigned task id from <event>>", "state": "completed", "result": "<the full deliverable>"}',
47
47
  },
48
48
  tool: 'task_set_result',
49
49
  // Function form so a failed task_set_result surfaces as an error outcome
@@ -59,7 +59,7 @@ export function buildTaskIntakeState(initial) {
59
59
  prompt: {
60
60
  when: 'The task needs abilities, tools, or information you cannot obtain this turn.',
61
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>"}',
62
+ format: '{"taskId": "<assigned task id from <event>>", "state": "failed", "result": "<one-line reason>"}',
63
63
  },
64
64
  tool: 'task_set_result',
65
65
  produces: (result) => {
@@ -9,11 +9,13 @@ import type { WakeWriteKeys } from '../types.js';
9
9
  * - task transitions: `wk_<wake-hash>_t_<hash(taskId, state)>` — derived from
10
10
  * the transition itself, so it dedups regardless of the order in which the
11
11
  * replayed wake happens to re-issue its tool calls.
12
- * - chat messages: `sdk_idem_wk_<wake-hash>_m<ordinal>` — used directly as
13
- * the messageId (the backend dedups on (chatId, messageId), ZIG-718), drawn
14
- * from the same ordinal stream as artifacts so one counter keys the wake's
15
- * whole write sequence. The prefix matches the tool-layer convention for
16
- * deterministic ids (`sdk_idem_<llm-key>`).
12
+ * - chat messages: `sdk_idem_wk_<wake-hash>_m_<hash(chat, receiver, text)>`
13
+ * — used directly as the messageId (the backend dedups on (chatId, messageId),
14
+ * ZIG-718). Content-derived (ZIG-1045), not ordinal: a wake may emit several
15
+ * distinct messages without colliding, and a crash-replay that re-sends the
16
+ * same text reuses the key; a re-think with different text gets a new key
17
+ * instead of 409-dropping on the old ordinal slot. The prefix matches the
18
+ * tool-layer convention for deterministic ids (`sdk_idem_<llm-key>`).
17
19
  * - task spawns: `wk_<wake-hash>_s_<hash(agreement, description, …)>` —
18
20
  * derived from what the spawn writes, so it dedups regardless of re-issue
19
21
  * order while still distinguishing multiple spawns in one wake.
@@ -78,11 +78,13 @@ function sha(input) {
78
78
  * - task transitions: `wk_<wake-hash>_t_<hash(taskId, state)>` — derived from
79
79
  * the transition itself, so it dedups regardless of the order in which the
80
80
  * replayed wake happens to re-issue its tool calls.
81
- * - chat messages: `sdk_idem_wk_<wake-hash>_m<ordinal>` — used directly as
82
- * the messageId (the backend dedups on (chatId, messageId), ZIG-718), drawn
83
- * from the same ordinal stream as artifacts so one counter keys the wake's
84
- * whole write sequence. The prefix matches the tool-layer convention for
85
- * deterministic ids (`sdk_idem_<llm-key>`).
81
+ * - chat messages: `sdk_idem_wk_<wake-hash>_m_<hash(chat, receiver, text)>`
82
+ * — used directly as the messageId (the backend dedups on (chatId, messageId),
83
+ * ZIG-718). Content-derived (ZIG-1045), not ordinal: a wake may emit several
84
+ * distinct messages without colliding, and a crash-replay that re-sends the
85
+ * same text reuses the key; a re-think with different text gets a new key
86
+ * instead of 409-dropping on the old ordinal slot. The prefix matches the
87
+ * tool-layer convention for deterministic ids (`sdk_idem_<llm-key>`).
86
88
  * - task spawns: `wk_<wake-hash>_s_<hash(agreement, description, …)>` —
87
89
  * derived from what the spawn writes, so it dedups regardless of re-issue
88
90
  * order while still distinguishing multiple spawns in one wake.
@@ -107,10 +109,11 @@ export function createWakeWriteKeys(laneKey, event) {
107
109
  return undefined;
108
110
  return `wk_${wakeHash}_t_${sha(`${taskId}\n${state}`).slice(0, 16)}`;
109
111
  },
110
- messageKey() {
112
+ messageKey(fields) {
111
113
  if (wakeHash === null)
112
114
  return undefined;
113
- return `sdk_idem_wk_${wakeHash}_m${ordinal++}`;
115
+ const msgHash = sha(`${fields.sessionId ?? ''}\n${fields.receiverId ?? ''}\n${fields.text}`).slice(0, 16);
116
+ return `sdk_idem_wk_${wakeHash}_m_${msgHash}`;
114
117
  },
115
118
  taskSpawnKey(fields) {
116
119
  if (wakeHash === null)
@@ -1,5 +1,6 @@
1
1
  import { OutboxBuffer } from './OutboxBuffer.js';
2
2
  import { runtimeLog } from '../shared/runtimeLog.js';
3
+ import { artifactScopeForSession } from '@ziggs-ai/api-client';
3
4
  import { buildLlmIngestPayload, buildToolIngestPayload, isTelemetryIngestEnabled, sendTelemetry, } from './telemetryIngest.js';
4
5
  const CONTEXT_CACHE_MAX = 256;
5
6
  /**
@@ -69,12 +70,15 @@ function previewToolArgs(args) {
69
70
  * Chat timeline: `Message.tsx` parses `entryType: artifact`, `content_type: operation` with
70
71
  * `operation_*:{json}` where json includes `state` and `tool`.
71
72
  */
72
- async function emitToolOperationArtifact(client, chatId, phase, payload, idempotencyKey) {
73
+ async function emitToolOperationArtifact(client, sessionId, phase, payload, idempotencyKey) {
73
74
  const prefix = phase === 'started' ? 'operation_started' : phase === 'completed' ? 'operation_completed' : 'operation_error';
74
75
  const text = `${prefix}:${JSON.stringify(payload)}`;
75
76
  try {
76
77
  await client.write({
77
- chatId,
78
+ // ZIG-1032: an agreement lane (`agrn-…`) is a routing key, not a chat —
79
+ // passing it as chatId asked the backend to write into a chat that does
80
+ // not exist and 403'd every breadcrumb on agreement-lane work.
81
+ ...artifactScopeForSession(sessionId),
78
82
  text,
79
83
  content_type: 'operation',
80
84
  visibility: 'agent-private',
@@ -296,7 +300,7 @@ export function createZiggsEffectHandler(deps) {
296
300
  }
297
301
  else {
298
302
  await deps.artifactsClient.write({
299
- chatId: effect.sessionId,
303
+ ...artifactScopeForSession(effect.sessionId),
300
304
  text: JSON.stringify(effect.entry),
301
305
  content_type: String(effect.entry.kind ?? 'event'),
302
306
  visibility: 'agent-private',
@@ -1,7 +1,7 @@
1
1
  import type { AgreementService } from '../../agreements/AgreementService.js';
2
2
  import type { TaskService } from '../../tasks/TaskService.js';
3
3
  import type { WakeWriteKeys } from '../../../types.js';
4
- import type { ContextReadClient } from '@ziggs-ai/api-client';
4
+ import { type ContextReadClient } from '@ziggs-ai/api-client';
5
5
  interface Services {
6
6
  agreementService: AgreementService;
7
7
  taskService: TaskService;
@@ -1,4 +1,17 @@
1
+ import { linkSummary, } from '@ziggs-ai/api-client';
1
2
  import { applyFleetCounterPricing, applyFleetPricingForProtocol, } from '../../../pricing/fleetDefaults.js';
3
+ /**
4
+ * ZIG-957 — same summary shape as MCP link mutations / link_* tools.
5
+ * Raw Mongo money/approvals noise must not reach the LLM on a trust link.
6
+ */
7
+ function shapeAgreementResult(agreement) {
8
+ if (!agreement)
9
+ return agreement;
10
+ return agreement.engagementKind === 'link' ? linkSummary(agreement) : agreement;
11
+ }
12
+ function shapeRevokeResult(result) {
13
+ return { ...result, agreement: shapeAgreementResult(result.agreement) };
14
+ }
2
15
  // Map legacy task-state aliases onto the canonical set.
3
16
  function normalizeState(state) {
4
17
  if (state === 'done')
@@ -49,12 +62,17 @@ export async function dispatchProtocolOp(services, payload, chatId, agents = [],
49
62
  case 'agreement-respond':
50
63
  if (!payload.agreementId || !payload.action)
51
64
  return null;
52
- return agreementService.respond(payload.agreementId, payload.action);
53
- case 'agreement-claim':
65
+ return shapeAgreementResult(await agreementService.respond(payload.agreementId, payload.action));
66
+ case 'agreement-claim': {
54
67
  // ZIG-1021: the one claim verb — quest, standing offer, or link invite.
55
68
  if (!payload.agreementId)
56
69
  throw new Error('agreement-claim requires `agreementId`');
57
- return agreementService.claim(payload.agreementId);
70
+ const claimed = await agreementService.claim(payload.agreementId);
71
+ return {
72
+ ...claimed,
73
+ agreement: shapeAgreementResult(claimed.agreement),
74
+ };
75
+ }
58
76
  case 'agreement-counter-proposal':
59
77
  if (!payload.agreementId)
60
78
  return null;
@@ -81,11 +99,11 @@ export async function dispatchProtocolOp(services, payload, chatId, agents = [],
81
99
  case 'agreement-revoke':
82
100
  if (!payload.agreementId)
83
101
  return null;
84
- return agreementService.revoke(payload.agreementId);
102
+ return shapeRevokeResult(await agreementService.revoke(payload.agreementId));
85
103
  case 'agreement-fulfill':
86
104
  if (!payload.agreementId)
87
105
  return null;
88
- return agreementService.fulfill(payload.agreementId);
106
+ return shapeRevokeResult(await agreementService.fulfill(payload.agreementId));
89
107
  case 'task-spawn': {
90
108
  if (!payload.agreementId || !payload.description)
91
109
  return null;
@@ -165,9 +183,14 @@ export async function dispatchProtocolOp(services, payload, chatId, agents = [],
165
183
  });
166
184
  }
167
185
  case 'task-update': {
168
- if (!payload.taskId || !payload.status)
186
+ // ZIG-957: canonical tool param is `state` (MCP + REST). Accept legacy
187
+ // `status` so fleet prompts that still emit it keep working.
188
+ const terminalRaw = (typeof payload.state === 'string' && payload.state) ||
189
+ (typeof payload.status === 'string' && payload.status) ||
190
+ '';
191
+ if (!payload.taskId || !terminalRaw)
169
192
  return null;
170
- const state = normalizeState(payload.status);
193
+ const state = normalizeState(terminalRaw);
171
194
  // Wake-derived idempotency key: a crash-replay re-issuing this transition
172
195
  // no-ops server-side instead of erroring on the already-terminal task.
173
196
  return taskService.updateState(payload.taskId, state, payload.result, wakeKeys?.taskStateKey(payload.taskId, state));
@@ -205,7 +228,7 @@ async function proposeAgreement(services, payload, chatId, _agents, actingAgentI
205
228
  if (isBroadcast) {
206
229
  throw new Error('A broadcast link is an invite — use link_create_invite instead; propose links to one agent id.');
207
230
  }
208
- return services.agreementService.requestLink(payload.proposedTo, payload.description);
231
+ return shapeAgreementResult(await services.agreementService.requestLink(payload.proposedTo, payload.description));
209
232
  }
210
233
  if (!isBroadcast && !payload.providerId) {
211
234
  throw new Error('agreement-propose requires `providerId` — name who does the work: your own id (you are offering to work) or the proposedTo id (you are commissioning the recipient).');
@@ -1,5 +1,18 @@
1
1
  import { defineTool } from '../../../tools/defineTool.js';
2
2
  import { dispatchProtocolOp } from './protocolRunner.js';
3
+ /** ZIG-957 — keep these enums in lockstep with MCP ziggs_agreement_* / ziggs_task_create. */
4
+ const LIFECYCLE_ENUM = ['open', 'time-bound', 'count-bound'];
5
+ const PLAN_REVIEW_TIMING_ENUM = ['with_proposal', 'before_execution'];
6
+ const lifecycleParam = {
7
+ type: 'string',
8
+ enum: [...LIFECYCLE_ENUM],
9
+ description: "Usually inferred: expiresAt → 'time-bound', maxExecutions → 'count-bound', neither → 'open' (standing).",
10
+ };
11
+ const planReviewTimingParam = {
12
+ type: 'string',
13
+ enum: [...PLAN_REVIEW_TIMING_ENUM],
14
+ description: 'When the buyer reviews the plan: with_proposal | before_execution',
15
+ };
3
16
  function handlerFor(operation) {
4
17
  return async (args, ctx) => {
5
18
  const c = ctx;
@@ -19,17 +32,26 @@ function handlerFor(operation) {
19
32
  return result;
20
33
  };
21
34
  }
22
- export const agreementProposeTool = defineTool('agreement_propose', { description: { type: 'string', required: true }, proposedTo: { type: 'string', required: true }, engagementKind: { type: 'string', enum: ['hire', 'service', 'link'], description: "Contract type. Defaults to service. 'link' proposes a bilateral trust link (no work, no money)." }, parentAgreementId: 'string', parentTaskId: 'string', providerId: { type: 'string', description: 'Who does the work — required on direct proposals. Pass your own id when YOU are offering to do the work, or the proposedTo id when you are commissioning the RECIPIENT (any other third party needs a matching published offer). On a broadcast: omit for a quest (the claimer works, you pay), or pass your own id to publish a standing offer (you work, the claimer pays). 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 an agreement — direct, broadcast, or link; there are no separate publish tools. Direction is stated, never guessed: providerId = your own id ("I work, you pay") or providerId = proposedTo ("you work, I pay") on direct proposals. Broadcast with proposedTo="everyone" (fully public) or "org" (your org only): no providerId publishes a QUEST any agent may claim; providerId = your own id publishes a STANDING OFFER (claiming is agreement_claim; browsing is marketplace_view). engagementKind "link" with proposedTo = an agent id proposes bilateral trust. 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
- 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 });
35
+ export const agreementProposeTool = defineTool('agreement_propose', { description: { type: 'string', required: true }, proposedTo: { type: 'string', required: true }, engagementKind: { type: 'string', enum: ['hire', 'service', 'link'], description: "Contract type. Defaults to service. 'link' proposes a bilateral trust link (no work, no money)." }, parentAgreementId: 'string', parentTaskId: 'string', providerId: { type: 'string', description: 'Who does the work — required on direct proposals. Pass your own id when YOU are offering to do the work, or the proposedTo id when you are commissioning the RECIPIENT (any other third party needs a matching published offer). On a broadcast: omit for a quest (the claimer works, you pay), or pass your own id to publish a standing offer (you work, the claimer pays). The payer is always derived as the non-providing side.' }, price: { type: 'number', description: 'Amount in CENTS — 500 means $5.00. Quote it to a human in the same units the agreement charges.' }, lifecycle: lifecycleParam, expiresAt: 'string', maxExecutions: 'integer', 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: planReviewTimingParam, requireMidWorkPlanAck: 'boolean' }, handlerFor('agreement-propose'), { description: 'Propose an agreement — direct, broadcast, or link; there are no separate publish tools. Direction is stated, never guessed: providerId = your own id ("I work, you pay") or providerId = proposedTo ("you work, I pay") on direct proposals. Broadcast with proposedTo="everyone" (fully public) or "org" (your org only): no providerId publishes a QUEST any agent may claim; providerId = your own id publishes a STANDING OFFER (claiming is agreement_claim; browsing is marketplace_view). engagementKind "link" with proposedTo = an agent id proposes bilateral trust. 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 });
36
+ 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: { type: 'number', description: 'Amount in CENTS — 500 means $5.00. Quote it to a human in the same units the agreement charges.' }, lifecycle: lifecycleParam, expiresAt: 'string', maxExecutions: 'integer', agreementDescription: 'string', plan: { type: 'object' }, planReviewTiming: planReviewTimingParam, requireMidWorkPlanAck: 'boolean' }, handlerFor('agreement-subcontract'), { description: 'Delegate work to another agent under an existing parent agreement. Requires parentAgreementId.', isAgreementCreation: true });
24
37
  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 DIRECT agreement proposal addressed to you. Open broadcasts (quests, standing offers, link invites) have no personal approval slot — claim those with agreement_claim instead, or ignore them to pass.' });
25
38
  export const agreementClaimTool = defineTool('agreement_claim', { agreementId: { type: 'string', required: true, description: 'The open agreement to claim (quest / offer / link invite id)' } }, handlerFor('agreement-claim'), { description: 'Claim an open broadcast agreement by id — a quest (you do the work, the publisher pays), a standing offer (you buy, the publisher works), or a link invite (bilateral trust forms immediately). You become the open party and the agreement activates. Find quests/offers with marketplace_view; direct proposals are approved with agreement_respond instead, not claimed.' });
26
- export const taskSetResultTool = defineTool('task_set_result', { taskId: { type: 'string', required: true }, status: { type: 'string', required: true, enum: ['completed', 'failed', 'cancelled'] }, result: 'string' }, handlerFor('task-update'), { description: 'Transition a task to its terminal state — completed, failed, or cancelled — and record the result. Finished work is the task result: set it here, never as a chat message. Include a result string for completed tasks.' });
27
- export const agreementCounterTool = defineTool('agreement_counter', { 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_get.' });
39
+ export const taskSetResultTool = defineTool('task_set_result', {
40
+ taskId: { type: 'string', required: true },
41
+ // ZIG-957: same param name as MCP ziggs_task_set_result / PATCH /tasks/:id/state.
42
+ state: { type: 'string', required: true, enum: ['completed', 'failed', 'cancelled'] },
43
+ // Same object contract as MCP ({ summary, status, links, … }).
44
+ result: {
45
+ type: 'object',
46
+ description: 'Outcome payload (summary, status, links, …). Finished work is the task result — never a chat message.',
47
+ },
48
+ }, handlerFor('task-update'), { description: 'Transition a task to its terminal state — completed, failed, or cancelled — and record the result. Finished work is the task result: set it here as an object ({ summary, status, links }), never as a chat message. Pass state (not status).' });
49
+ export const agreementCounterTool = defineTool('agreement_counter', { agreementId: { type: 'string', required: true }, price: { type: 'number', description: 'Amount in CENTS — 500 means $5.00. Quote it to a human in the same units the agreement charges.' }, agreementDescription: 'string', expiresAt: 'string', lifecycle: lifecycleParam, maxExecutions: 'integer', description: 'string', plan: { type: 'object' }, planReviewTiming: planReviewTimingParam, requireMidWorkPlanAck: 'boolean' }, handlerFor('agreement-counter-proposal'), { description: 'Counter a pending proposal with revised terms instead of approving or rejecting. Check status first with agreement_get.' });
28
50
  export const agreementGetTool = defineTool('agreement_get', { agreementId: { type: 'string', required: true } }, handlerFor('agreement-check-proposal'), { description: 'Read the current proposal status for an agreement (pending, approved, rejected, countered, expired).' });
29
51
  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.' });
30
52
  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_set_result (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.' });
31
53
  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." });
32
- export const taskCreateTool = defineTool('task_create', { 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: 'Create 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.' });
54
+ export const taskCreateTool = defineTool('task_create', { 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: planReviewTimingParam, requireMidWorkPlanAck: 'boolean' }, handlerFor('task-spawn'), { description: 'Create 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.' });
33
55
  export const PROTOCOL_TOOLS = [
34
56
  agreementProposeTool, agreementSubcontractTool,
35
57
  agreementRespondTool, agreementClaimTool, agreementCounterTool, agreementGetTool, agreementRevokeTool,
package/dist/types.d.ts CHANGED
@@ -70,6 +70,8 @@ export type Outcome = {
70
70
  } | {
71
71
  kind: 'task-closed';
72
72
  status: 'completed' | 'failed';
73
+ /** Task being closed — required to clear only that entry (ZIG-977). */
74
+ taskId?: string;
73
75
  result?: unknown;
74
76
  } | {
75
77
  kind: 'subtask-finished';
@@ -151,23 +153,49 @@ export interface CollectedSubtaskResult {
151
153
  /** task.result from the finishing event; null when the event carried none. */
152
154
  result: unknown;
153
155
  }
156
+ /**
157
+ * Per-assigned-task (or coordinator-lane) bookkeeping under one chat lane.
158
+ * Keyed on Ctx.tasks by taskId (ZIG-977) — task-closed clears only its entry.
159
+ */
160
+ export interface TaskBookkeeping {
161
+ agreementId: string | null;
162
+ /** Spawned-but-unfinished children of this engagement, keyed by child taskId. */
163
+ pendingSubtasks: Record<string, PendingSubtask>;
164
+ /** Finished children of this engagement, arrival order. */
165
+ collectedResults: CollectedSubtaskResult[];
166
+ /** Sub-agreement ids recorded on task-delegated under this engagement. */
167
+ delegatedAgreementIds: string[];
168
+ }
154
169
  /**
155
170
  * Per-lane runtime state. Outcome is "what just happened"; Ctx is "what I know."
156
171
  *
157
- * Persistent identifiers (activeAgreementId, pendingProposalAgreementId,
158
- * pendingSubtasks) survive across outcomes — they're the memory of which
159
- * agreement the agent is currently bound to and which subtasks it's awaiting.
172
+ * Task bookkeeping lives in `tasks` (keyed by taskId). `activeTaskId` /
173
+ * `activeAgreementId` are the focus scalars for prompt + FSM convenience —
174
+ * assigning task B no longer destroys task A's pending/collected state, and
175
+ * closing A leaves B intact (ZIG-977).
160
176
  */
161
177
  export interface Ctx {
162
178
  identity: Identity;
163
- activeAgreementId: string | null;
179
+ /**
180
+ * Open engagements in this lane, keyed by taskId (assigned work) or the
181
+ * reserved coordinator bucket (see LANE_BOOK_KEY) when spawning without an
182
+ * assigned parent task.
183
+ */
184
+ tasks: Record<string, TaskBookkeeping>;
185
+ /** Focus task — most recently assigned; prompts/FSM read this. */
164
186
  activeTaskId: string | null;
187
+ /** Focus agreement — sync'd with tasks[activeTaskId] when a task is focused. */
188
+ activeAgreementId: string | null;
165
189
  pendingProposalAgreementId: string | null;
166
- delegatedAgreementIds: string[];
167
- /** Durable barrier for orchestration: every spawned-but-unfinished subtask, keyed by taskId. */
190
+ /**
191
+ * Aggregate views over `tasks` (all engagements in the lane). Kept in sync by
192
+ * applyOutcomeToCtx / bootstrapCtx so existing workflow `when` guards that
193
+ * read these fields (e.g. motifs awaitingAll) keep working after ZIG-977.
194
+ * Prefer reading `tasks[taskId]` when you need per-task isolation.
195
+ */
168
196
  pendingSubtasks: Record<string, PendingSubtask>;
169
- /** Every finished subtask of the current engagement, arrival order. Synthesis prompts render ALL of these. */
170
197
  collectedResults: CollectedSubtaskResult[];
198
+ delegatedAgreementIds: string[];
171
199
  toolResults: ToolResultEntry[];
172
200
  lastOutcome: Outcome;
173
201
  /** Claude Agent SDK session id for resume across ticks (claude-sdk cognition only). */
@@ -361,8 +389,18 @@ export interface WakeWriteKeys {
361
389
  artifactKey(): string | undefined;
362
390
  /** Key for a task state transition. Derived from (taskId, state), so it is stable regardless of tool-call order within the wake. */
363
391
  taskStateKey(taskId: string, state: string): string | undefined;
364
- /** 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. */
365
- messageKey(): string | undefined;
392
+ /**
393
+ * Default messageId for a chat-message send of this wake — the backend dedups
394
+ * on (chatId, messageId). Derived from the emission's content (text + chat +
395
+ * receiver), not a per-wake ordinal (ZIG-1045): a wake that says two things
396
+ * gets two keys, and a redelivered wake that re-says the same text reuses the
397
+ * same key, while a re-think with different text does not collide and drop.
398
+ */
399
+ messageKey(fields: {
400
+ text: string;
401
+ sessionId?: string;
402
+ receiverId?: string;
403
+ }): string | undefined;
366
404
  /** 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. */
367
405
  taskSpawnKey(fields: {
368
406
  agreementId: string;