@ziggs-ai/agent-sdk 0.6.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/dist/AgentHost.d.ts +1 -1
  2. package/dist/agent/ClaudeSdkAgent.js +1 -1
  3. package/dist/agent/claudeCognition.js +1 -1
  4. package/dist/agent/claudeSdkProtocol.d.ts +1 -1
  5. package/dist/agent/claudeSdkProtocol.js +4 -3
  6. package/dist/agent/claudeSdkTools.d.ts +3 -2
  7. package/dist/agent/claudeSdkTools.js +48 -11
  8. package/dist/context/routingLabels.js +1 -1
  9. package/dist/index.d.ts +3 -2
  10. package/dist/index.js +3 -2
  11. package/dist/runtime/PromptBuilder.js +13 -6
  12. package/dist/runtime/bootstrapCtx.d.ts +2 -2
  13. package/dist/runtime/bootstrapCtx.js +13 -6
  14. package/dist/runtime/buildOutcome.d.ts +3 -2
  15. package/dist/runtime/buildOutcome.js +46 -26
  16. package/dist/runtime/deriveRespondsToProposals.d.ts +1 -1
  17. package/dist/runtime/deriveRespondsToProposals.js +1 -1
  18. package/dist/runtime/kickoff.js +8 -8
  19. package/dist/runtime/runTurn.js +42 -7
  20. package/dist/runtime/taskBookkeeping.d.ts +35 -0
  21. package/dist/runtime/taskBookkeeping.js +149 -0
  22. package/dist/runtime/taskIntake.js +6 -6
  23. package/dist/server/ZiggsEffectHandler.d.ts +1 -1
  24. package/dist/server/ZiggsEffectHandler.js +9 -5
  25. package/dist/server/agreements/AgreementService.d.ts +13 -0
  26. package/dist/server/agreements/AgreementService.js +16 -1
  27. package/dist/server/inboxLoop.js +13 -0
  28. package/dist/server/tools/index.d.ts +2 -2
  29. package/dist/server/tools/index.js +6 -4
  30. package/dist/server/tools/tier1/protocolRunner.d.ts +2 -2
  31. package/dist/server/tools/tier1/protocolRunner.js +58 -7
  32. package/dist/server/tools/tier1/protocolTools.d.ts +5 -4
  33. package/dist/server/tools/tier1/protocolTools.js +33 -10
  34. package/dist/server/tools/tier2/chatTools.js +1 -1
  35. package/dist/server/tools/tier2/connectionTools.js +2 -2
  36. package/dist/server/tools/tier2/linkTools.d.ts +2 -5
  37. package/dist/server/tools/tier2/linkTools.js +7 -6
  38. package/dist/server/tools/tier2/marketplaceTools.d.ts +1 -1
  39. package/dist/server/tools/tier2/marketplaceTools.js +9 -22
  40. package/dist/tasks/protocolRegistry.d.ts +5 -4
  41. package/dist/tasks/protocolRegistry.js +5 -4
  42. package/dist/tools/recordReport.d.ts +1 -1
  43. package/dist/types.d.ts +36 -8
  44. package/package.json +2 -2
@@ -377,10 +377,18 @@ export async function runTurn(input) {
377
377
  lastActionDef = actionDef;
378
378
  }
379
379
  catch (err) {
380
- // Both the streaming path AND the non-streaming fallback failed —
381
- // the user got nothing. End the turn with a loud send-failed error.
382
- runtimeLog.error('runTurn', `respond send failed after stream fallback: ${err.message}`);
383
- turnError = { source: 'protocol', cause: 'send-failed' };
380
+ 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`);
382
+ allEmittedEvents.push({ type: 'message_duplicate_skipped', receiverId: decision.receiverId, chatId: sessionId, senderId: 'system' });
383
+ lastAction = chosenAction;
384
+ lastActionDef = actionDef;
385
+ }
386
+ else {
387
+ // Both the streaming path AND the non-streaming fallback failed —
388
+ // the user got nothing. End the turn with a loud send-failed error.
389
+ runtimeLog.error('runTurn', `respond send failed after stream fallback: ${err.message}`);
390
+ turnError = { source: 'protocol', cause: 'send-failed' };
391
+ }
384
392
  }
385
393
  break;
386
394
  }
@@ -560,7 +568,7 @@ function annotateToolCallDedup(toolCalls, toolManager, initialSawCreate = false)
560
568
  sawCreate = true;
561
569
  return { tc, skipExecute: false };
562
570
  }
563
- if (name === 'task_update') {
571
+ if (name === 'task_set_result') {
564
572
  let taskId = null;
565
573
  try {
566
574
  const a = JSON.parse(tc.function.arguments || '{}');
@@ -569,7 +577,7 @@ function annotateToolCallDedup(toolCalls, toolManager, initialSawCreate = false)
569
577
  catch { }
570
578
  const key = taskId || tc.id || '_';
571
579
  if (seenUpdateTaskIds.has(key))
572
- return { tc, skipExecute: true, skipReason: 'duplicate task_update for same taskId' };
580
+ return { tc, skipExecute: true, skipReason: 'duplicate task_set_result for same taskId' };
573
581
  seenUpdateTaskIds.add(key);
574
582
  return { tc, skipExecute: false };
575
583
  }
@@ -652,6 +660,20 @@ function truncateMsg(msg) {
652
660
  return msg;
653
661
  return msg.slice(0, MAX_MSG_CHARS) + `\n\n[truncated to ${MAX_MSG_CHARS} of ${msg.length} chars]`;
654
662
  }
663
+ /**
664
+ * A send rejected with 409 hit the backend's (chatId, messageId) idempotency
665
+ * gate on a wake-derived id: this wake's reply already exists. Two legal ways
666
+ * that happens — a crash-replay re-ran the wake, or a second host raced the
667
+ * same at-least-once delivery — and in both the re-run LLM produced different
668
+ * text, so the backend rejects instead of no-op'ing (ZIG-932 treats
669
+ * same-key-same-content as replay, same-key-different-content as conflict).
670
+ * The user already has the winner's reply. Re-sending under a fresh id would
671
+ * double-message; failing the turn marks an answered wake as an error. Treat
672
+ * it as delivered-by-the-other-attempt and move on.
673
+ */
674
+ function isReplySlotTaken(err) {
675
+ return err?.status === 409;
676
+ }
655
677
  async function executeSendMessage(decision, eff, sessionId) {
656
678
  // Callers (executeDecision) guarantee message + receiverId, and sessionId is
657
679
  // always ctx.identity.sessionId. A send failure throws to the turn loop,
@@ -662,7 +684,15 @@ async function executeSendMessage(decision, eff, sessionId) {
662
684
  // to the current session otherwise.
663
685
  const targetSessionId = decision.chatId || sessionId;
664
686
  const safeMessage = truncateMsg(decision.message);
665
- await eff({ kind: 'send-message', sessionId: targetSessionId, receiverId: decision.receiverId, text: safeMessage });
687
+ try {
688
+ await eff({ kind: 'send-message', sessionId: targetSessionId, receiverId: decision.receiverId, text: safeMessage });
689
+ }
690
+ catch (err) {
691
+ if (!isReplySlotTaken(err))
692
+ throw err;
693
+ runtimeLog.warn('runTurn', `reply already persisted for this wake (idempotency 409) — a replay or second host answered first; dropping duplicate to ${decision.receiverId}`);
694
+ return [{ type: 'message_duplicate_skipped', receiverId: decision.receiverId, chatId: targetSessionId, senderId: 'system' }];
695
+ }
666
696
  const events = [{ type: 'message_sent', receiverId: decision.receiverId, chatId: targetSessionId, message: safeMessage, senderId: 'system' }];
667
697
  if (targetSessionId !== sessionId)
668
698
  events.push({ type: 'report_sent', message: 'Reported to owner.', senderId: 'system' });
@@ -693,6 +723,11 @@ async function executeSendMessages(decision, eff, sessionId) {
693
723
  await eff({ kind: 'send-message', sessionId: targetSessionId, receiverId: msg.receiverId, text: safeMessage });
694
724
  }
695
725
  catch (err) {
726
+ if (isReplySlotTaken(err)) {
727
+ runtimeLog.warn('runTurn', `reply already persisted for this wake (idempotency 409) — dropping duplicate to ${msg.receiverId}`);
728
+ events.push({ type: 'message_duplicate_skipped', receiverId: msg.receiverId, chatId: targetSessionId, senderId: 'system' });
729
+ continue;
730
+ }
696
731
  // Scenario: backend rejected or transport dropped this one send. Record
697
732
  // it and keep going — remaining recipients must still get their message.
698
733
  failures.push({ receiverId: msg.receiverId, error: err.message });
@@ -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_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.',
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,10 +43,10 @@ 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
- tool: 'task_update',
49
- // Function form so a failed task_update surfaces as an error outcome
48
+ tool: 'task_set_result',
49
+ // Function form so a failed task_set_result surfaces as an error outcome
50
50
  // (→ dflt error transition) instead of a phantom task-closed.
51
51
  produces: (result) => {
52
52
  const r = result;
@@ -59,9 +59,9 @@ 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
- tool: 'task_update',
64
+ tool: 'task_set_result',
65
65
  produces: (result) => {
66
66
  const r = result;
67
67
  const status = r?.task?.state ?? r?.state ?? r?.status;
@@ -59,7 +59,7 @@ export interface ZiggsEffectDeps {
59
59
  * side effects — that is the whole point of funneling both paths here.
60
60
  *
61
61
  * `eff` is threaded into the tool context so eff-backed tools
62
- * (`ziggs_context_snapshot`, `ziggs_send_message`) resolve through this same path
62
+ * (`context_snapshot`, `chat_send`) resolve through this same path
63
63
  * regardless of which cognition invoked them.
64
64
  */
65
65
  export declare function executeToolWithSideEffects(deps: Pick<ZiggsEffectDeps, 'toolManager' | 'artifactsClient' | 'telemetryClient' | 'telemetryIngest' | 'operatorKey' | 'agentId' | 'taskService' | 'agreementService' | 'contextReadClient' | 'messageSender'>, eff: EffectHandler, effect: Extract<Effect, {
@@ -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',
@@ -98,7 +102,7 @@ async function emitToolOperationArtifact(client, chatId, phase, payload, idempot
98
102
  * side effects — that is the whole point of funneling both paths here.
99
103
  *
100
104
  * `eff` is threaded into the tool context so eff-backed tools
101
- * (`ziggs_context_snapshot`, `ziggs_send_message`) resolve through this same path
105
+ * (`context_snapshot`, `chat_send`) resolve through this same path
102
106
  * regardless of which cognition invoked them.
103
107
  */
104
108
  export async function executeToolWithSideEffects(deps, eff, effect, roster) {
@@ -214,7 +218,7 @@ export function createZiggsEffectHandler(deps) {
214
218
  // Grant-fenced aggregate read: the backend composes the ContextSnapshot
215
219
  // (GET /context/snapshot) through the same access + temporal fence as
216
220
  // the per-type reads. Every producer — ClaudeSdkAgent.tick, runTurn,
217
- // and the on-demand ziggs_context_snapshot tool — routes through here.
221
+ // and the on-demand context_snapshot tool — routes through here.
218
222
  const snap = await buildSnapshot(deps, effect.sessionId, deps.agentId, {
219
223
  limit: effect.opts?.maxMessages,
220
224
  });
@@ -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',
@@ -19,6 +19,19 @@ export declare class AgreementService {
19
19
  publishOffer(payload: PublishOfferPayload): Promise<Agreement>;
20
20
  delegate(payload: DelegateAgreementData): Promise<Agreement>;
21
21
  respond(agreementId: string, action: 'approve' | 'reject'): Promise<Agreement>;
22
+ /**
23
+ * ZIG-1021 — claim any open broadcast (quest, standing offer, link invite).
24
+ * Routing (offer vs agreement claim endpoint) is decided in the shared flow.
25
+ */
26
+ claim(agreementId: string): Promise<{
27
+ agreement: Agreement;
28
+ kind: 'link' | 'offer' | 'quest';
29
+ }>;
30
+ /**
31
+ * ZIG-1022 — a link proposal is an agreement (engagementKind 'link') to one
32
+ * agent; the target owner approves. Folds the old request_link tool.
33
+ */
34
+ requestLink(providerId: string, description?: string): Promise<Agreement>;
22
35
  counter(agreementId: string, counter: CounterAgreementData): Promise<Agreement>;
23
36
  getStatus(agreementId: string): Promise<unknown | null>;
24
37
  list(filters?: ListAgreementsFilters): Promise<Agreement[]>;
@@ -1,4 +1,4 @@
1
- import { proposeAgreement, proposeBroadcast as proposeBroadcastApi, delegateAgreement, respondToAgreement, counterAgreement, getAgreementStatus, listAgreements, getMyAgreements, getAgreement, publishOffer as publishOfferApi, createAgreement, revokeAgreement, fulfillAgreement, linkAgreementToChat, getChatsForAgreement, joinAgreement, linkArtifactToAgreement, getArtifactsForAgreement, linkUserToAgreement, getUsersForAgreement, } from '@ziggs-ai/api-client';
1
+ import { proposeAgreement, proposeBroadcast as proposeBroadcastApi, delegateAgreement, respondToAgreement, counterAgreement, claimOpenAgreement, getAgreementStatus, listAgreements, getMyAgreements, getAgreement, publishOffer as publishOfferApi, createAgreement, revokeAgreement, fulfillAgreement, linkAgreementToChat, getChatsForAgreement, joinAgreement, linkArtifactToAgreement, getArtifactsForAgreement, linkUserToAgreement, getUsersForAgreement, } from '@ziggs-ai/api-client';
2
2
  export class AgreementService {
3
3
  creds;
4
4
  constructor(operatorKey, agentId) {
@@ -37,6 +37,21 @@ export class AgreementService {
37
37
  respond(agreementId, action) {
38
38
  return respondToAgreement(agreementId, action, this.creds);
39
39
  }
40
+ /**
41
+ * ZIG-1021 — claim any open broadcast (quest, standing offer, link invite).
42
+ * Routing (offer vs agreement claim endpoint) is decided in the shared flow.
43
+ */
44
+ claim(agreementId) {
45
+ return claimOpenAgreement(agreementId, this.creds);
46
+ }
47
+ /**
48
+ * ZIG-1022 — a link proposal is an agreement (engagementKind 'link') to one
49
+ * agent; the target owner approves. Folds the old request_link tool.
50
+ */
51
+ async requestLink(providerId, description) {
52
+ const { agreement } = await createAgreement({ engagementKind: 'link', providerId, ...(description ? { description } : {}) }, this.creds);
53
+ return agreement;
54
+ }
40
55
  counter(agreementId, counter) {
41
56
  return counterAgreement(agreementId, counter || {}, this.creds);
42
57
  }
@@ -1,5 +1,6 @@
1
1
  import { ContextReadClient, InboxClient, } from '@ziggs-ai/api-client';
2
2
  import { runtimeLog } from '../shared/runtimeLog.js';
3
+ import { isRateLimited } from '@ziggs-ai/api-client';
3
4
  const READ_PAGE = 100;
4
5
  function log(label, msg) {
5
6
  runtimeLog.info(label, msg);
@@ -208,6 +209,18 @@ export function runInboxLoop(deps, opts = {}) {
208
209
  if (!running)
209
210
  break;
210
211
  consecutiveErrors++;
212
+ // ZIG-1019: a 429 already told us how long to wait, and every refused
213
+ // call still counts against the cap — so the exponential ladder digs
214
+ // the hole deeper (1s, 2s, 4s… of pure refusals). Wait the server's
215
+ // number instead, and do not let a throttle inflate the ladder for the
216
+ // real errors that come after it.
217
+ const throttleMs = isRateLimited(err) ? (err.retryAfterMs ?? 30_000) : null;
218
+ if (throttleMs !== null) {
219
+ consecutiveErrors = 0;
220
+ warn(label, `inbox loop throttled: ${err?.message ?? err} — waiting ${Math.round(throttleMs)}ms as instructed`);
221
+ await sleep(throttleMs);
222
+ continue;
223
+ }
211
224
  const backoff = Math.min(ERROR_BACKOFF_MAX_MS, ERROR_BACKOFF_BASE_MS * 2 ** (consecutiveErrors - 1));
212
225
  const jitter = backoff * (0.5 + (consecutiveErrors % 7) / 14); // deterministic-ish, no Math.random
213
226
  warn(label, `inbox loop pass failed (#${consecutiveErrors}): ${err?.message ?? err} — retrying in ~${Math.round(jitter)}ms`);
@@ -15,7 +15,7 @@
15
15
  * its `tools:` array. Each tool reads operatorKey/agentId from tool context and
16
16
  * wraps a backend HTTP client — no per-agent plumbing.
17
17
  */
18
- export { PROTOCOL_TOOLS, agreementProposeTool, agreementSubcontractTool, agreementRespondTool, agreementCounterProposalTool, agreementCheckProposalTool, agreementRevokeTool, taskSpawnTool, taskUpdateTool, taskUpdatePlanStepTool, } from './tier1/protocolTools.js';
18
+ export { PROTOCOL_TOOLS, agreementProposeTool, agreementSubcontractTool, agreementRespondTool, agreementClaimTool, agreementCounterTool, agreementGetTool, agreementRevokeTool, agreementFulfillTool, taskCreateTool, taskSetResultTool, taskUpdatePlanStepTool, } from './tier1/protocolTools.js';
19
19
  export { dispatchProtocolOp } from './tier1/protocolRunner.js';
20
20
  export { DISCOVERY_TOOLS, agentSearchTool, agentGetTool } from './tier2/discoveryTools.js';
21
21
  export { MARKETPLACE_TOOLS, marketplaceViewTool } from './tier2/marketplaceTools.js';
@@ -23,6 +23,6 @@ export { PAYMENT_TOOLS, paymentBalanceTool, paymentTransferTool, paymentWaitForA
23
23
  export { CONNECTION_TOOLS, connectionProxyTool, connectionListGrantsTool, requestConnectionTool, } from './tier2/connectionTools.js';
24
24
  export { CONTEXT_TOOLS, contextReadTool, contextDelegateTool, contextExpandReachTool, contextDiscoverGrantableTool, } from './tier2/contextTools.js';
25
25
  export { GRANTS_TOOLS, listGrantsTool } from './tier2/grantsTools.js';
26
- export { LINK_TOOLS, requestLinkTool, createLinkInviteTool, claimLinkInviteTool, listLinksTool, revokeLinkTool, } from './tier2/linkTools.js';
26
+ export { LINK_TOOLS, linkCreateInviteTool, linkListTool, } from './tier2/linkTools.js';
27
27
  export { ARTIFACT_TOOLS, recordArtifactTool } from './tier2/artifactTools.js';
28
28
  export { CHAT_TOOLS, openConversationTool } from './tier2/chatTools.js';
@@ -16,7 +16,7 @@
16
16
  * wraps a backend HTTP client — no per-agent plumbing.
17
17
  */
18
18
  // ── Tier 1: protocol grammar ────────────────────────────────────────────────
19
- export { PROTOCOL_TOOLS, agreementProposeTool, agreementSubcontractTool, agreementRespondTool, agreementCounterProposalTool, agreementCheckProposalTool, agreementRevokeTool, taskSpawnTool, taskUpdateTool, taskUpdatePlanStepTool, } from './tier1/protocolTools.js';
19
+ export { PROTOCOL_TOOLS, agreementProposeTool, agreementSubcontractTool, agreementRespondTool, agreementClaimTool, agreementCounterTool, agreementGetTool, agreementRevokeTool, agreementFulfillTool, taskCreateTool, taskSetResultTool, taskUpdatePlanStepTool, } from './tier1/protocolTools.js';
20
20
  export { dispatchProtocolOp } from './tier1/protocolRunner.js';
21
21
  // ── Tier 2: capability bundles ──────────────────────────────────────────────
22
22
  export { DISCOVERY_TOOLS, agentSearchTool, agentGetTool } from './tier2/discoveryTools.js';
@@ -28,9 +28,11 @@ export { CONTEXT_TOOLS, contextReadTool, contextDelegateTool, contextExpandReach
28
28
  // payment_list_grants). Always-available on the hosted agent — see CLAUDE_SDK_TOOLS.
29
29
  export { GRANTS_TOOLS, listGrantsTool } from './tier2/grantsTools.js';
30
30
  // ZIG-898: links (agreements with engagementKind 'link') — cross-org reach.
31
- export { LINK_TOOLS, requestLinkTool, createLinkInviteTool, claimLinkInviteTool, listLinksTool, revokeLinkTool, } from './tier2/linkTools.js';
31
+ // ZIG-1021/1022: the rail is two tools; propose/claim/revoke ride the
32
+ // agreement grammar.
33
+ export { LINK_TOOLS, linkCreateInviteTool, linkListTool, } from './tier2/linkTools.js';
32
34
  // ZIG-899: deliberate artifact recording (the model decides "record this").
33
35
  export { ARTIFACT_TOOLS, recordArtifactTool } from './tier2/artifactTools.js';
34
- // ZIG-900 decision: open_conversation only — chat listing stays a context-rail
35
- // read (list_grants scopeKind=chat), no duplicate lister (ZIG-626 rule).
36
+ // ZIG-900 decision: chat_open only — chat listing stays a context-rail
37
+ // read (grant_list scopeKind=chat), no duplicate lister (ZIG-626 rule).
36
38
  export { CHAT_TOOLS, openConversationTool } from './tier2/chatTools.js';
@@ -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;
@@ -30,7 +30,7 @@ interface ProtocolPayload {
30
30
  plan?: unknown;
31
31
  planReviewTiming?: string;
32
32
  requireMidWorkPlanAck?: boolean;
33
- engagementKind?: 'hire' | 'service';
33
+ engagementKind?: 'hire' | 'service' | 'link';
34
34
  subtasks?: (string | {
35
35
  description?: string;
36
36
  })[];
@@ -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,7 +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);
65
+ return shapeAgreementResult(await agreementService.respond(payload.agreementId, payload.action));
66
+ case 'agreement-claim': {
67
+ // ZIG-1021: the one claim verb — quest, standing offer, or link invite.
68
+ if (!payload.agreementId)
69
+ throw new Error('agreement-claim requires `agreementId`');
70
+ const claimed = await agreementService.claim(payload.agreementId);
71
+ return {
72
+ ...claimed,
73
+ agreement: shapeAgreementResult(claimed.agreement),
74
+ };
75
+ }
53
76
  case 'agreement-counter-proposal':
54
77
  if (!payload.agreementId)
55
78
  return null;
@@ -76,11 +99,11 @@ export async function dispatchProtocolOp(services, payload, chatId, agents = [],
76
99
  case 'agreement-revoke':
77
100
  if (!payload.agreementId)
78
101
  return null;
79
- return agreementService.revoke(payload.agreementId);
102
+ return shapeRevokeResult(await agreementService.revoke(payload.agreementId));
80
103
  case 'agreement-fulfill':
81
104
  if (!payload.agreementId)
82
105
  return null;
83
- return agreementService.fulfill(payload.agreementId);
106
+ return shapeRevokeResult(await agreementService.fulfill(payload.agreementId));
84
107
  case 'task-spawn': {
85
108
  if (!payload.agreementId || !payload.description)
86
109
  return null;
@@ -160,9 +183,14 @@ export async function dispatchProtocolOp(services, payload, chatId, agents = [],
160
183
  });
161
184
  }
162
185
  case 'task-update': {
163
- 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)
164
192
  return null;
165
- const state = normalizeState(payload.status);
193
+ const state = normalizeState(terminalRaw);
166
194
  // Wake-derived idempotency key: a crash-replay re-issuing this transition
167
195
  // no-ops server-side instead of erroring on the already-terminal task.
168
196
  return taskService.updateState(payload.taskId, state, payload.result, wakeKeys?.taskStateKey(payload.taskId, state));
@@ -192,13 +220,36 @@ async function proposeAgreement(services, payload, chatId, _agents, actingAgentI
192
220
  if (!payload.description)
193
221
  throw new Error('agreement-propose requires `description`');
194
222
  if (!payload.proposedTo)
195
- throw new Error('agreement-propose requires `proposedTo` (userId or agentId). To spawn a task under an existing agreement, use `task_spawn` instead.');
223
+ throw new Error('agreement-propose requires `proposedTo` (userId or agentId). To create a task under an existing agreement, use `task_create` instead.');
196
224
  const isBroadcast = payload.proposedTo === 'everyone' || payload.proposedTo === 'org';
225
+ // ZIG-1022: a link proposal is an agreement with engagementKind 'link' —
226
+ // no chat, no money; the target owner approves. Folds the old request_link.
227
+ if (payload.engagementKind === 'link') {
228
+ if (isBroadcast) {
229
+ throw new Error('A broadcast link is an invite — use link_create_invite instead; propose links to one agent id.');
230
+ }
231
+ return shapeAgreementResult(await services.agreementService.requestLink(payload.proposedTo, payload.description));
232
+ }
197
233
  if (!isBroadcast && !payload.providerId) {
198
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).');
199
235
  }
200
236
  if (isBroadcast && payload.providerId) {
201
- throw new Error('agreement-propose forbids `providerId` on a broadcast — the claiming agent fills the open side.');
237
+ // ZIG-1022: providerId = self on a broadcast publishes a STANDING OFFER
238
+ // (seller side open to any claimer). Any other providerId stays an error.
239
+ if (actingAgentId && payload.providerId === actingAgentId) {
240
+ const priced = applyFleetPricingForProtocol('agreement-propose', payload, actingAgentId);
241
+ return services.agreementService.publishOffer({
242
+ description: String(priced.description ?? ''),
243
+ price: typeof priced.price === 'number' ? priced.price : undefined,
244
+ lifecycle: typeof priced.lifecycle === 'string' ? priced.lifecycle : undefined,
245
+ expiresAt: typeof priced.expiresAt === 'string' ? priced.expiresAt : undefined,
246
+ maxExecutions: typeof priced.maxExecutions === 'number' ? priced.maxExecutions : undefined,
247
+ engagementKind: priced.engagementKind === 'hire' ? 'hire' : priced.engagementKind === 'service' ? 'service' : undefined,
248
+ billing: priced['billing'] === 'per_task' ? 'per_task' : priced['billing'] === 'total' ? 'total' : undefined,
249
+ audience: payload.proposedTo === 'org' ? 'org' : 'everyone',
250
+ });
251
+ }
252
+ throw new Error('On a broadcast, providerId must be your own agent id (a standing offer) or omitted (a quest — the claiming agent fills the open side).');
202
253
  }
203
254
  const priced = applyFleetPricingForProtocol('agreement-propose', payload, actingAgentId);
204
255
  const { operation: _op, ...fields } = priced;
@@ -2,11 +2,12 @@ import { type ToolDefinition } from '../../../tools/defineTool.js';
2
2
  export declare const agreementProposeTool: ToolDefinition;
3
3
  export declare const agreementSubcontractTool: ToolDefinition;
4
4
  export declare const agreementRespondTool: ToolDefinition;
5
- export declare const taskUpdateTool: ToolDefinition;
6
- export declare const agreementCounterProposalTool: ToolDefinition;
7
- export declare const agreementCheckProposalTool: ToolDefinition;
5
+ export declare const agreementClaimTool: ToolDefinition;
6
+ export declare const taskSetResultTool: ToolDefinition;
7
+ export declare const agreementCounterTool: ToolDefinition;
8
+ export declare const agreementGetTool: ToolDefinition;
8
9
  export declare const agreementRevokeTool: ToolDefinition;
9
10
  export declare const agreementFulfillTool: ToolDefinition;
10
11
  export declare const taskUpdatePlanStepTool: ToolDefinition;
11
- export declare const taskSpawnTool: ToolDefinition;
12
+ export declare const taskCreateTool: ToolDefinition;
12
13
  export declare const PROTOCOL_TOOLS: ToolDefinition[];