@ziggs-ai/agent-sdk 0.7.1 → 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.
@@ -1,5 +1,6 @@
1
1
  import { query } from '@anthropic-ai/claude-agent-sdk';
2
2
  import { outcomeFromEvent } from '../runtime/buildOutcome.js';
3
+ import { getBatchEvents } from '../context/batch.js';
3
4
  import { bootstrapCtx } from '../runtime/bootstrapCtx.js';
4
5
  import { createWakeWriteKeys } from '../runtime/wakeWriteKeys.js';
5
6
  import { CLAUDE_SDK_PROTOCOL } from './claudeSdkProtocol.js';
@@ -7,6 +8,21 @@ import { buildZiggsSdkMcpServer } from './toolManagerToSdkMcp.js';
7
8
  import { claudeQueryOptions, buildClaudeEventPrompt, summarizeContext, extractAssistantText, } from './claudeCognition.js';
8
9
  import { assertHostedAgentZiggsTools } from './hostedAgentConnection.js';
9
10
  import { runtimeLog } from '../shared/runtimeLog.js';
11
+ /**
12
+ * ZIG-1035: lifecycle / status notices must not spend a Claude turn answering
13
+ * nobody. Real work wakes arrive as chat `message` or as synthetic `task_result`
14
+ * from `wakeTaskAssignment` (task channel). Terminal task_result notices still
15
+ * run so orchestrators can consume completions.
16
+ */
17
+ function isNoticeOnlyWake(event) {
18
+ const events = getBatchEvents(event);
19
+ if (events.length === 0)
20
+ return false;
21
+ return events.every((ev) => {
22
+ const t = ev?.type;
23
+ return t === 'notification' || t === 'agreement_lifecycle';
24
+ });
25
+ }
10
26
  const DEFAULT_STATE = 'ready';
11
27
  /**
12
28
  * Per-task turn cap. A wake drives one `query()` that may loop up to this many
@@ -186,6 +202,17 @@ export class ClaudeSdkAgent {
186
202
  };
187
203
  }
188
204
  nextCtx.recentWakes = [...recentWakes, now];
205
+ if (isNoticeOnlyWake(rawEvent)) {
206
+ runtimeLog.info('ClaudeSdkAgent', `notice-only wake — skipping LLM session=${identity.sessionId} agent=${this.agentId} ` +
207
+ `type=${rawEvent?.type ?? 'unknown'}`);
208
+ return {
209
+ ctx: nextCtx,
210
+ state: state ?? DEFAULT_STATE,
211
+ parked: true,
212
+ stateTimeoutUnhandled: false,
213
+ metrics: { llmCalls: 0, tokens: { total: 0, input: 0, output: 0 } },
214
+ };
215
+ }
189
216
  let contextSnap = null;
190
217
  try {
191
218
  contextSnap = await eff({
@@ -72,8 +72,13 @@ export function summarizeContext(snap) {
72
72
  function renderEventLines(event, lines) {
73
73
  if (event?.type)
74
74
  lines.push(`Event type: ${event.type}`);
75
- if (event?.text)
76
- lines.push(`Message: ${event.text}`);
75
+ // ZIG-1035: lifecycle notices are events, not counterparty speech.
76
+ if (event?.text) {
77
+ const notice = event.type === 'notification' ||
78
+ event.type === 'agreement_lifecycle' ||
79
+ event.entryType === 'notification';
80
+ lines.push(`${notice ? 'Notification' : 'Message'}: ${event.text}`);
81
+ }
77
82
  if (event?.senderId)
78
83
  lines.push(`Sender: ${event.senderId}`);
79
84
  if (event?.operation)
@@ -47,19 +47,21 @@ export const chatSendTool = defineTool('chat_send', {
47
47
  const receiverId = args.receiverId ?? c.senderId;
48
48
  if (!receiverId)
49
49
  throw new Error('chat_send: receiverId is required (this wake has no sender to default to)');
50
- // Default to the wake's replay-stable key: a crash-replay of this wake
51
- // re-derives the same messageId and the backend drops the duplicate
52
- // (ZIG-718). An explicit LLM idempotencyKey still wins (retry-safe resend
53
- // of the same logical message across wakes); random only when the wake has
54
- // no durable identity.
50
+ // Default to the wake's content-derived key (ZIG-1045): a crash-replay that
51
+ // re-sends the same text re-derives the same messageId and the backend drops
52
+ // the duplicate (ZIG-718); a re-think with different text gets a new key
53
+ // instead of colliding on a per-wake ordinal. An explicit LLM idempotencyKey
54
+ // still wins (retry-safe resend of the same logical message across wakes);
55
+ // random only when the wake has no durable identity.
56
+ const text = String(args.text);
55
57
  const messageId = args.idempotencyKey
56
58
  ? `sdk_idem_${String(args.idempotencyKey)}`
57
- : c.wakeKeys?.messageKey() ?? `sdk_${randomUUID()}`;
59
+ : c.wakeKeys?.messageKey({ text, sessionId, receiverId }) ?? `sdk_${randomUUID()}`;
58
60
  return c.eff({
59
61
  kind: 'send-message',
60
62
  sessionId,
61
63
  receiverId,
62
- text: String(args.text),
64
+ text,
63
65
  messageId,
64
66
  });
65
67
  }, { description: 'Send a user-visible chat message. Conversation only — never use for finished-work reporting.' });
@@ -24,6 +24,11 @@ export class HistoryFormatter {
24
24
  const label = labelTypes ? '[CHAT] ' : '';
25
25
  return `${label}${this.formatMessage(entry, agentId, timePrefix)}`;
26
26
  }
27
+ // ZIG-1035: notices are not counterparty turns — no "from → to:" speech framing.
28
+ if (entryType === 'notification') {
29
+ const label = labelTypes ? '[NOTICE] ' : '';
30
+ return `${label}${timePrefix}[notice] ${entry['text'] || ''}`;
31
+ }
27
32
  if (entryType === 'task_history') {
28
33
  const label = labelTypes ? '[TASK] ' : '';
29
34
  return `${label}${this.formatTaskHistory(entry, agentId, timePrefix)}`;
@@ -11,7 +11,14 @@ export interface NormalizedEvent {
11
11
  */
12
12
  messageId?: string;
13
13
  timestamp: number;
14
- type: 'task_result' | 'message' | 'agreement_lifecycle';
14
+ /**
15
+ * `notification` = lifecycle / status notice (ZIG-1035). Not counterparty
16
+ * speech; Claude parks without a model turn. Terminal task notices still
17
+ * use `task_result` so orchestrators see completions.
18
+ */
19
+ type: 'task_result' | 'message' | 'agreement_lifecycle' | 'notification';
20
+ /** Wire `entryType` when present (kept so brains can tell notice vs speech). */
21
+ entryType?: string;
15
22
  operation?: string;
16
23
  agreementId?: string;
17
24
  result?: unknown;
@@ -72,9 +79,9 @@ interface NormalizeArgs {
72
79
  * backend policy now (delivery law, phase 4): the inbox loop only ever delivers
73
80
  * what is fenced to this agent, so there is no client-side "is this for me"
74
81
  * relevance gate — this function decides SHAPE (message vs task_result vs
75
- * agreement_lifecycle) and the fields the FSM / SessionResolver / wakeWriteKeys
76
- * depend on, nothing more. `ownAgentId` is accepted for call-site compatibility
77
- * but no longer gates anything.
82
+ * agreement_lifecycle vs notification) and the fields the FSM / SessionResolver /
83
+ * wakeWriteKeys depend on, nothing more. `ownAgentId` is accepted for call-site
84
+ * compatibility but no longer gates anything.
78
85
  */
79
86
  export declare function normalizeIncomingEvent({ text, metadata }: NormalizeArgs): NormalizedEvent;
80
87
  export {};
@@ -26,14 +26,17 @@ function isAgreementLifecycleNotification(metadata) {
26
26
  typeof operation === 'string' &&
27
27
  PROPOSAL_RESOLVED_OPERATIONS.has(operation));
28
28
  }
29
+ function isTerminalTaskState(state) {
30
+ return state === 'completed' || state === 'failed' || state === 'cancelled';
31
+ }
29
32
  /**
30
33
  * Classify + shape a wire delivery into a lane-routable event. Targeting is
31
34
  * backend policy now (delivery law, phase 4): the inbox loop only ever delivers
32
35
  * what is fenced to this agent, so there is no client-side "is this for me"
33
36
  * relevance gate — this function decides SHAPE (message vs task_result vs
34
- * agreement_lifecycle) and the fields the FSM / SessionResolver / wakeWriteKeys
35
- * depend on, nothing more. `ownAgentId` is accepted for call-site compatibility
36
- * but no longer gates anything.
37
+ * agreement_lifecycle vs notification) and the fields the FSM / SessionResolver /
38
+ * wakeWriteKeys depend on, nothing more. `ownAgentId` is accepted for call-site
39
+ * compatibility but no longer gates anything.
37
40
  */
38
41
  export function normalizeIncomingEvent({ text, metadata = {} }) {
39
42
  // ZIG-936: classifier data lives under `service` on the inbox-pull wire;
@@ -42,12 +45,16 @@ export function normalizeIncomingEvent({ text, metadata = {} }) {
42
45
  const chatId = normalizeChatId(metadata);
43
46
  const receiverId = normalizeReceiverId(metadata, taskData);
44
47
  const senderType = metadata.sender?.type ? String(metadata.sender.type).toUpperCase() : undefined;
48
+ const entryType = typeof metadata.entryType === 'string' && metadata.entryType
49
+ ? metadata.entryType
50
+ : undefined;
45
51
  const base = {
46
52
  senderId: metadata.sender?.id,
47
53
  senderType,
48
54
  receiverId: receiverId || undefined,
49
55
  messageId: typeof metadata.messageId === 'string' && metadata.messageId ? metadata.messageId : undefined,
50
56
  timestamp: Date.now(),
57
+ ...(entryType ? { entryType } : {}),
51
58
  };
52
59
  if (isAgreementLifecycleNotification(metadata)) {
53
60
  const operation = metadata.service?.operation ?? metadata.operation;
@@ -65,11 +72,42 @@ export function normalizeIncomingEvent({ text, metadata = {} }) {
65
72
  };
66
73
  }
67
74
  if (isTaskRelated(metadata, taskData)) {
75
+ // ZIG-1035: active/spawn chat notices are not work — real assignment arrives
76
+ // on the task channel (`tasksAwaitingMe` → wakeTaskAssignment). Keep terminal
77
+ // task notices as `task_result` so orchestrators still see completions.
78
+ if (entryType === 'notification' && !isTerminalTaskState(taskData?.state)) {
79
+ return {
80
+ chatId,
81
+ event: {
82
+ ...base,
83
+ type: 'notification',
84
+ result: taskData,
85
+ text,
86
+ operation: metadata.service?.operation ?? metadata.operation,
87
+ },
88
+ };
89
+ }
68
90
  return {
69
91
  chatId,
70
92
  event: { ...base, type: 'task_result', result: taskData, text },
71
93
  };
72
94
  }
95
+ // ZIG-1035: residual lifecycle notices (proposal_created, agreement active, …)
96
+ // must not fall through as counterparty `message` speech.
97
+ if (entryType === 'notification') {
98
+ const operation = metadata.service?.operation ?? metadata.operation;
99
+ const rawAgreementId = metadata.service?.agreementId ?? metadata.agreementId;
100
+ return {
101
+ chatId,
102
+ event: {
103
+ ...base,
104
+ type: 'notification',
105
+ text,
106
+ ...(typeof operation === 'string' ? { operation } : {}),
107
+ ...(typeof rawAgreementId === 'string' ? { agreementId: rawAgreementId } : {}),
108
+ },
109
+ };
110
+ }
73
111
  return {
74
112
  chatId,
75
113
  event: { ...base, type: 'message', text },
@@ -356,9 +356,17 @@ export class AgentMachine {
356
356
  if (!userId)
357
357
  return;
358
358
  try {
359
- // Wake-derived messageId: a crash-replay that errors the same way parks
360
- // again and re-sends this apology — same key, backend drops the dupe.
361
- await this.eff({ kind: 'send-message', sessionId: this.ctx.identity.sessionId, receiverId: userId, text, messageId: this.wakeKeys?.messageKey() });
359
+ // Content-derived wake messageId (ZIG-1045): a crash-replay that errors
360
+ // the same way parks again and re-sends this apology — same key, backend
361
+ // drops the dupe.
362
+ const sessionId = this.ctx.identity.sessionId;
363
+ await this.eff({
364
+ kind: 'send-message',
365
+ sessionId,
366
+ receiverId: userId,
367
+ text,
368
+ messageId: this.wakeKeys?.messageKey({ text, sessionId, receiverId: userId }),
369
+ });
362
370
  }
363
371
  catch { /* best-effort */ }
364
372
  }
@@ -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;
@@ -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)
package/dist/types.d.ts CHANGED
@@ -389,8 +389,18 @@ export interface WakeWriteKeys {
389
389
  artifactKey(): string | undefined;
390
390
  /** Key for a task state transition. Derived from (taskId, state), so it is stable regardless of tool-call order within the wake. */
391
391
  taskStateKey(taskId: string, state: string): string | undefined;
392
- /** 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. */
393
- 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;
394
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. */
395
405
  taskSpawnKey(fields: {
396
406
  agreementId: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ziggs-ai/agent-sdk",
3
- "version": "0.7.1",
3
+ "version": "0.7.2",
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.6.1",
29
+ "@ziggs-ai/api-client": "^0.6.3",
30
30
  "ajv": "^8.17.1",
31
31
  "dotenv": "^17.2.3",
32
32
  "openai": "^4.0.0",